nodes/projectile.pyΒΆ
Part of Q1K3.
1"""Projectile entities for Q1K3 port.
2
3Mirrors upstream `entity_projectile_*.js`. Each projectile is a small
4``MeshInstance3D`` (cube for simplicity) that integrates physics each frame
5and dispatches damage on hit.
6"""
7
8from __future__ import annotations
9
10import math
11import random
12from typing import TYPE_CHECKING
13
14from simvx.core import Material, MeshInstance3D, Node3D, Vec3
15
16from . import audio, meshes, textures
17from .physics import PhysicsBody, update_physics
18
19if TYPE_CHECKING: # pragma: no cover
20 from .root import Q1K3Root
21
22
23# Cached materials so each projectile doesn't re-bake a texture
24_PROJ_MAT_CACHE: dict[int, Material] = {}
25
26
27def _mat_for(tex_id: int, emissive: bool = False) -> Material:
28 key = tex_id + (1000 if emissive else 0)
29 if key not in _PROJ_MAT_CACHE:
30 img = textures.get(tex_id)
31 kwargs = {"albedo_map": img, "roughness": 0.4, "metallic": 0.2}
32 if emissive:
33 r, g, b = img[32, 32, :3] / 255.0
34 kwargs["emissive_colour"] = (float(r), float(g), float(b), 4.0)
35 _PROJ_MAT_CACHE[key] = Material(**kwargs)
36 return _PROJ_MAT_CACHE[key]
37
38
39class Projectile(Node3D, PhysicsBody):
40 """Base class: subclasses tweak die_at, gravity, mesh, on-hit behaviour."""
41
42 kind = "shell"
43 tex_id = textures.TEX_PARTICLE
44 mesh_scale = (4, 4, 4)
45 lifetime = 0.1
46 damage = 4
47 bounciness = 0.0
48 gravity = 0.0
49 explosive = False
50 emissive = False
51
52 def __init__(self, game: Q1K3Root, pos: Vec3, vel: Vec3, yaw: float, pitch: float, group: str = "enemy") -> 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.v = Vec3(vel.x, vel.y, vel.z)
59 self.s = Vec3(2, 2, 2)
60 self._gravity = self.gravity
61 self._bounciness = self.bounciness
62 self._die_at = game.game_time + self.lifetime
63 self._yaw = yaw - math.pi / 2
64 self._pitch = -pitch
65 self._check_against = 2 if group == "enemy" else 1 # which list to test
66 self._dead = False
67
68 # Visual
69 self._inst = MeshInstance3D(
70 mesh=meshes.cube(),
71 material=_mat_for(self.tex_id, emissive=self.emissive),
72 scale=self.mesh_scale,
73 )
74 self.add_child(self._inst)
75
76 def on_update(self, dt: float) -> None:
77 if self._dead:
78 return
79 if self._die_at <= self.game.game_time:
80 self._kill()
81 return
82 update_physics(
83 self, self.game.world, self.game.enemies_list(), self.game.friendlies_list(), dt, self.game.game_time
84 )
85 self.position = self.p
86 self.rotate_y(self.v.length() * 0.0001)
87
88 def did_collide(self, axis: int) -> None:
89 self._on_hit_wall(axis)
90
91 def did_collide_with_entity(self, other) -> None:
92 self._on_hit_entity(other)
93
94 def _on_hit_wall(self, axis: int) -> None:
95 self._kill()
96
97 def _on_hit_entity(self, other) -> None:
98 if hasattr(other, "receive_damage"):
99 other.receive_damage(self, self.damage)
100 self._kill()
101
102 def _kill(self) -> None:
103 if self._dead:
104 return
105 self._dead = True
106 self.game.queue_remove(self)
107
108
109# ---- concrete projectiles ---------------------------------------------------
110
111
112class Shell(Projectile):
113 kind = "shell"
114 lifetime = 0.1
115 damage = 4
116 mesh_scale = (1.0, 1.0, 1.0)
117 tex_id = textures.TEX_PARTICLE
118 emissive = True
119
120 def _on_hit_wall(self, axis: int) -> None:
121 self.game.spawn_particles(self.p, count=2, speed=80, lifetime=0.4, tex_id=textures.TEX_PARTICLE)
122 self.game.spawn_temp_light(self.p, intensity=0.8, colour=(1.0, 0.9, 0.3), duration=0.1)
123 self._kill()
124
125
126class Nail(Projectile):
127 kind = "nail"
128 tex_id = textures.TEX_NAIL
129 lifetime = 3.0
130 damage = 9
131 mesh_scale = (1.5, 1.5, 4.0)
132 emissive = False
133
134 def _on_hit_wall(self, axis: int) -> None:
135 self.game.play_sfx_at(audio.sfx_nailgun_hit(), self.p)
136 self.game.spawn_particles(self.p, count=2, speed=80, lifetime=0.4, tex_id=textures.TEX_PARTICLE)
137 self._kill()
138
139
140class Plasma(Projectile):
141 kind = "plasma"
142 tex_id = textures.TEX_PLASMA
143 lifetime = 3.0
144 damage = 15
145 mesh_scale = (3.0, 3.0, 3.0)
146 emissive = True
147
148 def _on_hit_wall(self, axis: int) -> None:
149 self.game.play_sfx_at(audio.sfx_nailgun_hit(), self.p)
150 self.game.spawn_particles(self.p, count=2, speed=80, lifetime=0.4, tex_id=textures.TEX_PLASMA)
151 self.game.spawn_temp_light(self.p + Vec3(0, 10, 0), intensity=2.0, colour=(1.0, 0.5, 0.1), duration=0.15)
152 self._kill()
153
154
155class Grenade(Projectile):
156 kind = "grenade"
157 tex_id = textures.TEX_GRENADE
158 lifetime = 2.0
159 damage = 120
160 mesh_scale = (4.0, 4.0, 4.0)
161 bounciness = 0.5
162 gravity = 1.0
163
164 def __init__(self, game, pos, vel, yaw, pitch, group="enemy"):
165 super().__init__(game, pos, vel, yaw, pitch, group)
166 self.f = 0.5 # air friction; 5x on ground
167
168 def on_update(self, dt: float) -> None:
169 super().on_update(dt)
170 if not self._dead:
171 self.f = 5.0 if self._on_ground else 0.5
172 # Pulsing point light
173 self.game.add_dynamic_light(self.p + Vec3(0, 16, 0), intensity=2.0, colour=(1.0, 0.3, 0.05))
174
175 def _on_hit_wall(self, axis: int) -> None:
176 # Bounce, don't die
177 if axis != 1 or self.v.y < -128:
178 self._yaw += random.random()
179 self.game.play_sfx_at(audio.sfx_grenade_bounce(), self.p)
180
181 def _on_hit_entity(self, other) -> None:
182 # Grenade passes through entities until lifetime expires (matches upstream feel)
183 pass
184
185 def _kill(self) -> None:
186 if self._dead:
187 return
188 self._dead = True
189 # AoE damage
190 for entity in self.game.entities_in_group(self._check_against):
191 d = self.p - entity.p
192 dist = math.sqrt(float(d.x) ** 2 + float(d.y) ** 2 + float(d.z) ** 2)
193 if dist < 196:
194 if hasattr(entity, "receive_damage"):
195 falloff = max(0.0, (196 - dist) / 196)
196 entity.receive_damage(self, self.damage * falloff)
197 self.game.play_sfx_at(audio.sfx_grenade_explode(), self.p)
198 self.game.spawn_particles(self.p, count=20, speed=400, lifetime=0.8, tex_id=textures.TEX_PARTICLE)
199 self.game.spawn_temp_light(self.p + Vec3(0, 16, 0), intensity=8.0, colour=(1.0, 0.5, 0.05), duration=0.4)
200 self.game.queue_remove(self)
201
202
203class Gib(Projectile):
204 kind = "gib"
205 tex_id = textures.TEX_GIB
206 lifetime = 2.0
207 damage = 10
208 mesh_scale = (3, 3, 3)
209 gravity = 1.0
210 bounciness = 0.0
211
212 def _on_hit_wall(self, axis: int) -> None:
213 if axis == 1 and self.v.y < -128:
214 self.game.play_sfx_at(audio.sfx_enemy_hit(), self.p)
215
216
217# Factory ---------------------------------------------------------------------
218
219_KIND_TO_CLASS = {
220 "shell": Shell,
221 "nail": Nail,
222 "plasma": Plasma,
223 "grenade": Grenade,
224 "gib": Gib,
225}
226
227
228def spawn_projectile(
229 game: Q1K3Root, kind: str, pos: Vec3, vel: Vec3, yaw: float, pitch: float, group: str
230) -> Projectile:
231 cls = _KIND_TO_CLASS[kind]
232 proj = cls(game, pos, vel, yaw, pitch, group)
233 game.add_entity(proj)
234 return proj