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