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)