nodes/prop.pyΒΆ

Part of Q1K3.

  1"""Static props: barrels (explode on damage), torches (flickering lights),
  2level-trigger (advances to next map)."""
  3
  4from __future__ import annotations
  5
  6import math
  7import random
  8from typing import TYPE_CHECKING
  9
 10from simvx.core import Material, MeshInstance3D, Node3D, PointLight3D, Vec3
 11
 12from . import audio, meshes, textures
 13
 14if TYPE_CHECKING:  # pragma: no cover
 15    from .root import Q1K3Root
 16
 17
 18class Barrel(Node3D):
 19    body_scale = (16, 32, 16)
 20
 21    def __init__(self, game: Q1K3Root, pos: Vec3) -> None:
 22        super().__init__()
 23        self.game = game
 24        self.position = pos
 25        self.p = Vec3(pos.x, pos.y, pos.z)
 26        self.s = Vec3(8, 32, 8)
 27        self._health = 10
 28        self._dead = False
 29        self._inst = MeshInstance3D(
 30            mesh=meshes.cube(),
 31            material=Material(albedo_map=textures.get(textures.TEX_PLASMA), roughness=0.5, metallic=0.4),
 32            scale=type(self).body_scale,
 33            position=(0, type(self).body_scale[1] / 2, 0),
 34        )
 35        self.add_child(self._inst)
 36
 37    def receive_damage(self, source, amount: float) -> None:
 38        if self._dead:
 39            return
 40        self._health -= int(amount)
 41        if self._health <= 0:
 42            self._kill()
 43
 44    def _kill(self) -> None:
 45        if self._dead:
 46            return
 47        self._dead = True
 48        # AoE damage
 49        for entity in self.game.enemies_list() + self.game.friendlies_list():
 50            if entity is self:
 51                continue
 52            d = (self.p - entity.p).length()
 53            if d < 256 and hasattr(entity, "receive_damage"):
 54                falloff = max(0.0, (256 - d) / 256)
 55                entity.receive_damage(self, 60 * falloff)
 56        self.game.play_sfx_at(audio.sfx_grenade_explode(), self.p)
 57        self.game.spawn_particles(
 58            self.p + Vec3(0, 16, 0), count=20, speed=400, lifetime=0.8, tex_id=textures.TEX_PARTICLE
 59        )
 60        self.game.spawn_temp_light(self.p + Vec3(0, 16, 0), intensity=8.0, colour=(1.0, 0.4, 0.05), duration=0.4)
 61        self.game.queue_remove(self)
 62
 63
 64class Torch(Node3D):
 65    """Flickering point light + textured cube body."""
 66
 67    body_scale = (8, 24, 8)
 68
 69    def __init__(self, game: Q1K3Root, pos: Vec3) -> None:
 70        super().__init__()
 71        self.game = game
 72        self.position = pos
 73        self._inst = MeshInstance3D(
 74            mesh=meshes.cube(),
 75            material=Material(
 76                albedo_map=textures.get(textures.TEX_TORCH),
 77                emissive_colour=(1.0, 0.7, 0.3, 6.0),
 78                roughness=0.6,
 79                metallic=0.0,
 80            ),
 81            scale=type(self).body_scale,
 82        )
 83        self.add_child(self._inst)
 84        self.light = PointLight3D(
 85            position=(0, 32, 0),
 86            colour=(1.0, 0.75, 0.25),
 87            intensity=3.0,
 88            range=400.0,
 89        )
 90        self.add_child(self.light)
 91        self._flicker = 0.0
 92        self._t = random.random()
 93
 94    def on_update(self, dt: float) -> None:
 95        self._t += dt
 96        # Random flicker every few frames
 97        if random.random() > 0.8:
 98            self._flicker = random.random()
 99        self.light.intensity = 2.5 + math.sin(self._t * 4) * 0.4 + self._flicker * 0.5
100
101
102class LevelTrigger(Node3D):
103    """Invisible node that advances the current map when player enters."""
104
105    RADIUS = 96.0
106
107    def __init__(self, game: Q1K3Root, pos: Vec3) -> None:
108        super().__init__()
109        self.game = game
110        self.position = pos
111        self._dead = False
112
113    def on_update(self, dt: float) -> None:
114        player = self.game.player
115        if not player or self._dead:
116            return
117        if (self.position - player.p).length() < type(self).RADIUS:
118            self._dead = True
119            self.game.next_level()