nodes/enemy.py

Part of Q1K3.

  1"""Enemy entities with state-machine AI for Q1K3 port.
  2
  3Mirrors upstream `entity_enemy.js` and per-enemy subclasses. Each enemy is
  4represented by a single textured cube: upstream's animated mesh blending is
  5deliberately dropped, since the port's point is the AI and the collision
  6integrator rather than the character art.
  7
  8State machine (matches upstream):
  9    IDLE -> [LOS / hurt] -> ATTACK_AIM -> ATTACK_PREPARE -> ATTACK_EXEC ->
 10    ATTACK_RECOVER -> FOLLOW -> [in range] -> ATTACK_AIM (loop)
 11                                       -> [too close] -> EVADE -> ATTACK_AIM
 12"""
 13
 14from __future__ import annotations
 15
 16import math
 17import random
 18from typing import TYPE_CHECKING
 19
 20from simvx.core import Material, MeshInstance3D, Node3D, Vec3
 21
 22from . import audio, meshes, textures
 23from .mathutil import rotate_y, rotate_yaw_pitch
 24from .physics import PhysicsBody, trace_los, update_physics
 25from .projectile import spawn_projectile
 26
 27if TYPE_CHECKING:  # pragma: no cover
 28    from .root import Q1K3Root
 29
 30
 31def _angle_to(a: Vec3, b: Vec3) -> float:
 32    return math.atan2(float(b.x - a.x), float(b.z - a.z))
 33
 34
 35def _anglemod(r: float) -> float:
 36    return math.atan2(math.sin(r), math.cos(r))
 37
 38
 39class Enemy(Node3D, PhysicsBody):
 40    """Generic enemy. Subclasses override `_attack()` and tune parameters."""
 41
 42    health = 40
 43    speed = 196.0
 44    attack_distance = 800.0
 45    evade_distance = 96.0
 46    attack_chance = 0.65
 47    keep_off_ledges = True
 48    tex_id = textures.TEX_GRUNT
 49    body_scale = (28, 56, 28)
 50    body_colour: tuple[float, float, float, float] | None = None  # if set, overrides texture
 51
 52    def __init__(self, game: Q1K3Root, pos: Vec3, patrol_dir: int = 0) -> None:
 53        super().__init__()
 54        self._physics_init()
 55        self.game = game
 56        self.position = pos
 57        self.p = Vec3(pos.x, pos.y, pos.z)
 58        self.s = Vec3(12, 28, 12)
 59        self.f = 10.0
 60        self._step_height = 17.0
 61        self._gravity = 1.0
 62        self._dead = False
 63        self._health = type(self).health
 64        self._target_yaw = 0.0
 65        self._yaw = 0.0
 66        self._state_update_at = 0.0
 67        self._turn_bias = 1.0
 68        self._keep_off_ledges = type(self).keep_off_ledges
 69        self._check_against = 1  # collide with friendlies (player)
 70
 71        # State definitions: [anim_index, speed_mult, next_state_update, next_state]
 72        self._STATE_IDLE = (0, 0.0, 0.1, None)
 73        self._STATE_PATROL = (1, 0.5, 0.5, None)
 74        self._STATE_FOLLOW = (2, 1.0, 0.3, None)
 75        self._STATE_ATTACK_RECOVER = (0, 0.0, 0.1, "FOLLOW")
 76        self._STATE_ATTACK_EXEC = (4, 0.0, 0.4, "ATTACK_RECOVER")
 77        self._STATE_ATTACK_PREPARE = (3, 0.0, 0.4, "ATTACK_EXEC")
 78        self._STATE_ATTACK_AIM = (0, 0.0, 0.1, "ATTACK_PREPARE")
 79        self._STATE_EVADE = (2, 1.0, 0.8, "ATTACK_AIM")
 80
 81        # Visual: lift the cube so its bottom sits at the entity's foot.
 82        # entity.p is the centre of the collision AABB, but for a humanoid we
 83        # want the body to stand on the ground, so offset up by body_scale[1]/2.
 84        sy = float(self.s.y)
 85        body_h = float(type(self).body_scale[1])
 86        self._inst = MeshInstance3D(
 87            mesh=meshes.cube(),
 88            material=self._build_material(),
 89            scale=type(self).body_scale,
 90            position=(0, body_h / 2 - sy, 0),  # bottom at p.y - sy
 91        )
 92        self.add_child(self._inst)
 93
 94        # Patrol direction in 90° increments (1=E, 2=S, 3=W, 4=N)
 95        if patrol_dir:
 96            self._set_state("PATROL")
 97            self._target_yaw = (math.pi / 2) * patrol_dir
 98        else:
 99            self._set_state("IDLE")
100
101    def _build_material(self) -> Material:
102        # Boost emissive so enemies stay readable in dim corridors. Upstream's
103        # quantised colour palette and full-bright lighting hides this issue.
104        c = type(self).body_colour or (0.7, 0.55, 0.35, 1.0)
105        return Material(
106            colour=c,
107            roughness=0.7,
108            metallic=0.0,
109            emissive_colour=(c[0] * 0.3, c[1] * 0.3, c[2] * 0.3, 1.0),
110        )
111
112    def _state_tuple(self, name: str):
113        return getattr(self, f"_STATE_{name}")
114
115    def _set_state(self, name: str) -> None:
116        self._state_name = name
117        self._state = self._state_tuple(name)
118        self._state_update_at = self.game.game_time + self._state[2] + (self._state[2] / 4) * random.random()
119
120    # ------------------------------------------------------------------
121    def on_update(self, dt: float) -> None:
122        if self._dead:
123            return
124        player = self.game.player
125        if player is None:
126            return
127
128        if self._state_update_at < self.game.game_time:
129            self._update_state(player)
130
131        # Smoothly rotate to target yaw
132        self._yaw += _anglemod(self._target_yaw - self._yaw) * 0.1
133
134        # Move along yaw
135        if self._on_ground:
136            speed_mult = self._state[1]
137            self.v = rotate_y(Vec3(0, self.v.y, speed_mult * type(self).speed), self._target_yaw)
138
139        update_physics(
140            self, self.game.world, self.game.enemies_list(), self.game.friendlies_list(), dt, self.game.game_time
141        )
142        self.position = self.p
143
144        # Apply yaw rotation visually. `_yaw` is JS-frame (0 ↔ +Z forward);
145        # SimVX Node3D's local -Z forward needs +π to align with movement.
146        # Enemy meshes are uniform-textured cubes today, so this is purely a
147        # convention fix: left here so future per-side textures render right.
148        from simvx.core.math.types import Quat
149
150        self.rotation = Quat.from_axis_angle(Vec3(0, 1, 0), self._yaw + math.pi)
151
152    def _update_state(self, player) -> None:
153        self._turn_bias = 0.5 if random.random() > 0.5 else -0.5
154        dist = (self.p - player.p).length()
155        angle = _angle_to(self.p, player.p)
156
157        # Auto-advance to next-state if defined
158        if self._state[3] is not None:
159            self._set_state(self._state[3])
160
161        if self._state_name == "FOLLOW":
162            if not trace_los(self.game.world, self.p, player.p):
163                self._target_yaw = angle
164            if dist < type(self).attack_distance:
165                if dist < type(self).evade_distance or random.random() > type(self).attack_chance:
166                    self._set_state("EVADE")
167                    self._target_yaw += math.pi / 2 + random.random() * math.pi
168                else:
169                    self._set_state("ATTACK_AIM")
170        if self._state_name == "ATTACK_RECOVER":
171            self._target_yaw = angle
172        if self._state_name in ("PATROL", "IDLE"):
173            if dist < 700 and not trace_los(self.game.world, self.p, player.p):
174                self._set_state("ATTACK_AIM")
175        if self._state_name == "ATTACK_AIM":
176            self._target_yaw = angle
177            if trace_los(self.game.world, self.p, player.p):
178                self._set_state("EVADE")
179        if self._state_name == "ATTACK_EXEC":
180            self._attack()
181
182    # Subclasses override
183    def _attack(self) -> None:
184        pass
185
186    def receive_damage(self, source, amount: float) -> None:
187        if self._dead:
188            return
189        self._health -= int(amount)
190        self.game.play_sfx_at(audio.sfx_enemy_hit(), self.p)
191        # Wake up if patrolling/idle
192        if self._state_name in ("IDLE", "PATROL"):
193            self._target_yaw = _angle_to(self.p, self.game.player.p)
194            self._set_state("FOLLOW")
195        # Blood spurt
196        self.game.spawn_particles(self.p + Vec3(0, 16, 0), count=3, speed=120, lifetime=0.5, tex_id=textures.TEX_BLOOD)
197        if self._health <= 0:
198            self._kill()
199
200    def _kill(self) -> None:
201        if self._dead:
202            return
203        self._dead = True
204        self.game.play_sfx_at(audio.sfx_enemy_gib(), self.p)
205        self.game.spawn_particles(self.p + Vec3(0, 16, 0), count=8, speed=200, lifetime=0.8, tex_id=textures.TEX_GIB)
206        self.game.queue_remove(self)
207
208    def did_collide(self, axis: int) -> None:
209        if axis == 1:
210            return
211        if self._state_name == "PATROL":
212            self._target_yaw += math.pi
213        else:
214            self._target_yaw += self._turn_bias
215
216    def _spawn_projectile_offset(self, kind: str, speed: float, yaw_offset: float, pitch_offset: float):
217        player = self.game.player
218        target_pitch = math.atan2(self.p.y - player.p.y, (self.p - player.p).length()) + pitch_offset
219        vel = rotate_yaw_pitch(Vec3(0, 0, speed), self._yaw + yaw_offset, target_pitch)
220        return spawn_projectile(
221            self.game, kind, self.p + Vec3(0, 16, 0), vel, self._yaw + yaw_offset, target_pitch, group="friendly"
222        )
223
224
225# ---- concrete enemy types ---------------------------------------------------
226
227
228class Grunt(Enemy):
229    health = 40
230    tex_id = textures.TEX_GRUNT
231    body_scale = (28, 56, 28)
232    body_colour = (0.6, 0.4, 0.2, 1.0)
233
234    def _attack(self):
235        self.game.play_sfx_at(audio.sfx_shotgun_shoot(), self.p)
236        self.game.spawn_temp_light(self.p + Vec3(0, 30, 0), intensity=2.0, colour=(1.0, 0.9, 0.3), duration=0.1)
237        for _ in range(3):
238            self._spawn_projectile_offset(
239                "shell", 10000.0, random.random() * 0.08 - 0.04, random.random() * 0.08 - 0.04
240            )
241
242
243class Enforcer(Enemy):
244    health = 80
245    tex_id = textures.TEX_ENFORCER
246    body_scale = (32, 72, 32)
247    body_colour = (0.3, 0.4, 0.7, 1.0)
248
249    def _attack(self):
250        self.game.play_sfx_at(audio.sfx_plasma_shoot(), self.p)
251        self._spawn_projectile_offset("plasma", 800.0, 0.0, 0.0)
252
253
254class Ogre(Enemy):
255    health = 200
256    speed = 96.0
257    attack_distance = 350.0
258    tex_id = textures.TEX_OGRE
259    body_scale = (36, 80, 36)
260    body_colour = (0.6, 0.4, 0.25, 1.0)
261
262    def _attack(self):
263        self.game.play_sfx_at(audio.sfx_grenade_shoot(), self.p)
264        proj = self._spawn_projectile_offset("grenade", 600.0, 0.0, -0.4)
265        proj.damage = 40
266
267
268class Zombie(Enemy):
269    health = 60
270    speed = 0.0
271    attack_distance = 350.0
272    tex_id = textures.TEX_ZOMBIE
273    body_scale = (24, 48, 24)
274    body_colour = (0.35, 0.55, 0.3, 1.0)
275
276    def receive_damage(self, source, amount: float) -> None:
277        # Ignore non-gib damage (matches upstream)
278        if amount > 60:
279            super().receive_damage(source, amount)
280        else:
281            self.game.play_sfx_at(audio.sfx_enemy_hit(), self.p)
282
283    def _attack(self):
284        self.game.play_sfx_at(audio.sfx_enemy_hit(), self.p)
285        self._spawn_projectile_offset("gib", 600.0, 0.0, -0.5)
286
287
288class Hound(Enemy):
289    health = 25
290    speed = 256.0
291    attack_distance = 200.0
292    evade_distance = 64.0
293    attack_chance = 0.7
294    tex_id = textures.TEX_HOUND
295    body_scale = (24, 32, 32)
296    body_colour = (0.55, 0.35, 0.2, 1.0)
297
298    def __init__(self, game, pos, patrol_dir=0):
299        super().__init__(game, pos, patrol_dir)
300        # Hound checks against player so its leap-attack does damage on contact.
301        self._check_against = 1
302        self._did_hit = False
303        self.s = Vec3(12, 16, 12)
304
305    def _attack(self):
306        self.game.play_sfx_at(audio.sfx_hound_attack(), self.p)
307        self.v = rotate_y(Vec3(0, 250, 600), self._target_yaw)
308        self._on_ground = False
309        self._did_hit = False
310        self._keep_off_ledges = False
311
312    def did_collide_with_entity(self, other) -> None:
313        if not self._did_hit and self._state_name == "ATTACK_EXEC":
314            self._did_hit = True
315            if hasattr(other, "receive_damage"):
316                other.receive_damage(self, 14)