nodes/weapon.py

Part of Q1K3.

  1"""Weapons for Q1K3 port: Shotgun, Nailgun, GrenadeLauncher.
  2
  3Mirrors upstream `weapons.js`. Each weapon spawns a projectile entity at the
  4player's position + a small offset rotated by the player's yaw/pitch.
  5"""
  6
  7from __future__ import annotations
  8
  9import random
 10
 11from simvx.core import Vec3
 12
 13from . import audio
 14from .mathutil import rotate_yaw_pitch
 15
 16
 17class Weapon:
 18    """Base class: caller invokes ``shoot(player_pos, yaw, pitch)``.
 19
 20    Subclasses set:
 21    - ``reload``: cooldown in seconds
 22    - ``ammo``: starting ammo (or ``None`` for infinite)
 23    - ``projectile_factory``: callable(world, pos, vel, yaw, pitch, group) -> Projectile
 24    - ``projectile_speed``: units/s
 25    - ``shoot_sfx``: callable returning the AudioClip to play
 26    - ``projectile_offset``: Vec3 added to muzzle position pre-yaw/pitch rotation
 27    """
 28
 29    name = "weapon"
 30    reload = 0.5
 31    ammo: int | None = None
 32    projectile_speed = 1000.0
 33    projectile_offset = Vec3(0, 0, 8)
 34    shoot_sfx = staticmethod(audio.sfx_shotgun_shoot)
 35
 36    def __init__(self) -> None:
 37        self._projectile_offset = Vec3(self.projectile_offset.x, self.projectile_offset.y, self.projectile_offset.z)
 38        self._ammo = self.ammo  # None for infinite
 39
 40    def needs_ammo(self) -> bool:
 41        return self._ammo is not None
 42
 43    def has_ammo(self) -> bool:
 44        return self._ammo is None or self._ammo > 0
 45
 46    def display_ammo(self) -> str:
 47        return "INF" if self._ammo is None else str(self._ammo)
 48
 49    def shoot(self, game, pos: Vec3, yaw: float, pitch: float) -> None:
 50        if self.needs_ammo():
 51            self._ammo -= 1
 52        # Play sound (player view: not spatial, non-attenuated)
 53        game.play_sfx(self.shoot_sfx())
 54        self._spawn_projectile(game, pos, yaw, pitch)
 55
 56    def _spawn_projectile(self, game, pos: Vec3, yaw: float, pitch: float) -> None:
 57        from .projectile import spawn_projectile
 58
 59        muzzle = pos + Vec3(0, 12, 0) + rotate_yaw_pitch(self._projectile_offset, yaw, pitch)
 60        vel = rotate_yaw_pitch(Vec3(0, 0, self.projectile_speed), yaw, pitch)
 61        spawn_projectile(game, self.projectile_kind(), muzzle, vel, yaw, pitch, group="enemy")
 62        # Alternate L/R fire (nailgun)
 63        self._projectile_offset = Vec3(-self._projectile_offset.x, self._projectile_offset.y, self._projectile_offset.z)
 64
 65    def projectile_kind(self) -> str:
 66        raise NotImplementedError
 67
 68
 69class Shotgun(Weapon):
 70    name = "shotgun"
 71    reload = 0.9
 72    ammo = None  # infinite
 73    projectile_speed = 10000.0
 74    shoot_sfx = staticmethod(audio.sfx_shotgun_shoot)
 75
 76    def projectile_kind(self) -> str:
 77        return "shell"
 78
 79    def _spawn_projectile(self, game, pos: Vec3, yaw: float, pitch: float) -> None:
 80        from .projectile import spawn_projectile
 81
 82        # 8 pellets, ±0.04 spread
 83        for _ in range(8):
 84            y = yaw + random.random() * 0.08 - 0.04
 85            p = pitch + random.random() * 0.08 - 0.04
 86            muzzle = pos + Vec3(0, 12, 0) + rotate_yaw_pitch(self._projectile_offset, y, p)
 87            vel = rotate_yaw_pitch(Vec3(0, 0, self.projectile_speed), y, p)
 88            spawn_projectile(game, "shell", muzzle, vel, y, p, group="enemy")
 89
 90
 91class Nailgun(Weapon):
 92    name = "nailgun"
 93    reload = 0.09
 94    ammo = 100
 95    projectile_speed = 1300.0
 96    projectile_offset = Vec3(6, 0, 8)
 97    shoot_sfx = staticmethod(audio.sfx_nailgun_shoot)
 98
 99    def projectile_kind(self) -> str:
100        return "nail"
101
102
103class GrenadeLauncher(Weapon):
104    name = "grenadelauncher"
105    reload = 0.65
106    ammo = 10
107    projectile_speed = 900.0
108    shoot_sfx = staticmethod(audio.sfx_grenade_shoot)
109
110    def projectile_kind(self) -> str:
111        return "grenade"