nodes/player.py

Part of Q1K3.

  1"""First-person player controller for Q1K3 port.
  2
  3Mirrors upstream `entity_player.js`. Owns:
  4- `Camera3D` child rendered at head height + bob offset
  5- WASD acceleration in yaw direction; jump on space
  6- Mouse-look (yaw on Player, pitch on Camera)
  7- Weapon cycling (Q / E + scroll)
  8- Fire on LMB
  9- HUD updates (health, ammo)
 10
 11Every intent is also reachable from the on-screen controls, so the port stays
 12playable on a pointer-only device.
 13"""
 14
 15from __future__ import annotations
 16
 17import math
 18from typing import TYPE_CHECKING
 19
 20from simvx.core import AudioListener3D, Camera3D, Input, Key, Mouse, Node3D, Vec3
 21
 22from . import audio
 23from .mathutil import rotate_y
 24from .physics import PhysicsBody, update_physics
 25from .weapon import Shotgun
 26
 27if TYPE_CHECKING:  # pragma: no cover
 28    from .root import Q1K3Root
 29
 30
 31class Player(Node3D, PhysicsBody):
 32    """First-person player. Handles movement, look, weapons, HUD."""
 33
 34    SPEED = 3000.0
 35    STEP_HEIGHT = 17.0
 36    JUMP_VEL = 400.0
 37
 38    def __init__(self, game: Q1K3Root, spawn_pos: Vec3, yaw: float = 0.0) -> None:
 39        super().__init__()
 40        self._physics_init()
 41        self.game = game
 42        self.position = spawn_pos
 43        self.p = Vec3(spawn_pos.x, spawn_pos.y, spawn_pos.z)
 44        self.s = Vec3(12, 24, 12)
 45        self.f = 10.0
 46        self._step_height = self.STEP_HEIGHT
 47        self._gravity = 1.0
 48        # `_yaw` is in JS-frame (yaw=0 ↔ look toward +Z). The SimVX camera's
 49        # native forward is -Z, so `_sync_camera` adds π when applying the
 50        # rotation; movement and projectile math stays identical to upstream.
 51        self._yaw = yaw
 52        self._pitch = 0.0
 53        self._can_jump = False
 54        self._can_shoot_at = 0.0
 55        self._dead = False
 56        self._health = 100
 57        self._check_against = 2  # collide with enemies
 58        self._bob = 0.0
 59        self._weapons = [Shotgun()]
 60        self._weapon_index = 0
 61        self._prev_scroll = 0.0
 62
 63        # Camera child: pitch goes here (player Node3D rotates around Y).
 64        self.camera = Camera3D(name="PlayerCam", fov=90.0, near=1.0, far=8000.0)
 65        # Spatial SFX are attenuated and panned against this listener, so it
 66        # rides the camera and inherits its pose.
 67        self.camera.add_child(AudioListener3D())
 68        self.add_child(self.camera)
 69        self._sync_camera()
 70
 71    @property
 72    def health(self) -> int:
 73        return self._health
 74
 75    @property
 76    def weapon(self):
 77        return self._weapons[self._weapon_index]
 78
 79    def add_weapon(self, weapon) -> None:
 80        # Already owned: just select it (a repeat pickup grants no extra ammo,
 81        # which is why the ammo pickups are separate entities). Otherwise
 82        # append the new weapon and select it.
 83        for i, w in enumerate(self._weapons):
 84            if type(w) is type(weapon):
 85                self._weapon_index = i
 86                return
 87        self._weapons.append(weapon)
 88        self._weapon_index = len(self._weapons) - 1
 89
 90    def add_ammo(self, weapon_class, amount: int) -> bool:
 91        for w in self._weapons:
 92            if isinstance(w, weapon_class) and w._ammo is not None:
 93                w._ammo += amount
 94                return True
 95        return False
 96
 97    # ------------------------------------------------------------------
 98    # Per-frame
 99    # ------------------------------------------------------------------
100
101    def on_update(self, dt: float) -> None:
102        if self._dead:
103            return
104
105        touch = self.game.touch if self.game.touch is not None and self.game.touch.visible else None
106
107        # Mouse look. SimVX is right-handed Y-up; upstream Q1K3 (WebGL with
108        # +Z forward) is effectively the mirror-image X frame, so mouse-X and
109        # WASD-X both feed into the JS-frame yaw with a sign flip, so that
110        # mouse-right yields a right-turn and A strafes left on screen.
111        delta = Input.mouse_delta
112        look_x, look_y = float(delta.x), float(delta.y)
113        if touch is not None:
114            # The pointer is not captured in touch mode, so mouse_delta is the
115            # cursor moving over the page: the look drag is the only source.
116            look_x, look_y = touch.take_look()
117        sens = 0.0025
118        self._yaw = (self._yaw - look_x * sens) % (2 * math.pi)
119        self._pitch = max(-1.5, min(1.5, self._pitch - look_y * sens))
120
121        # Movement intent (WASD / arrows). Negate `ix` for the same X-mirror
122        # reason as mouse look: A=left, D=right in screen space.
123        ix = float(Input.pressed("left")) - float(Input.pressed("right"))
124        iz = float(Input.pressed("forward")) - float(Input.pressed("back"))
125        if touch is not None:
126            ix, iz = touch.move
127        intent = rotate_y(Vec3(ix, 0, iz), self._yaw)
128        ground_factor = 1.0 if self._on_ground else 0.3
129        self.a = Vec3(intent.x * self.SPEED * ground_factor, 0.0, intent.z * self.SPEED * ground_factor)
130
131        # Jump
132        jump_held = Input.pressed("jump") or (touch is not None and touch.jumping)
133        if jump_held and self._on_ground and self._can_jump:
134            self.v = Vec3(self.v.x, self.JUMP_VEL, self.v.z)
135            self._on_ground = False
136            self._can_jump = False
137        if not jump_held:
138            self._can_jump = True
139
140        # Weapon cycle: Q/E, the wheel, or the on-screen weapon button. The
141        # button's tap is consumed unconditionally so it can't queue up.
142        cycle_next = touch is not None and touch.take_weapon_cycle()
143        if Input.just_pressed("weapon_prev"):
144            self._weapon_index = (self._weapon_index - 1) % len(self._weapons)
145        if cycle_next or Input.just_pressed("weapon_next"):
146            self._weapon_index = (self._weapon_index + 1) % len(self._weapons)
147
148        # The wheel reports a per-frame offset rather than a press, so this one
149        # edge really does have to be latched by hand.
150        wheel = Input.scroll_delta[1]
151        if abs(wheel) > 0.01 and abs(self._prev_scroll) < 0.01:
152            self._weapon_index = (self._weapon_index + (1 if wheel > 0 else -1)) % len(self._weapons)
153        self._prev_scroll = wheel
154
155        # Fire. In touch mode the left button is the look/stick drag, so the
156        # FIRE button is the only trigger.
157        firing = touch.firing if touch is not None else Input.pressed("fire")
158        weapon = self.weapon
159        shoot_wait = self._can_shoot_at - self.game.game_time
160        if firing and shoot_wait < 0:
161            self._can_shoot_at = self.game.game_time + weapon.reload
162            if weapon.needs_ammo() and not weapon.has_ammo():
163                self.game.play_sfx(audio.sfx_no_ammo())
164            else:
165                weapon.shoot(self.game, self.p, self._yaw, self._pitch)
166                # Muzzle flash
167                self.game.spawn_temp_light(self.p + Vec3(0, 8, 0), intensity=2.0, colour=(1.0, 0.9, 0.3), duration=0.08)
168
169        # Camera bob
170        speed = math.sqrt(float(self.a.x) ** 2 + float(self.a.z) ** 2)
171        self._bob += speed * 0.0001
172        self.f = 10.0 if self._on_ground else 2.5
173
174        update_physics(
175            self, self.game.world, self.game.enemies_list(), self.game.friendlies_list(), dt, self.game.game_time
176        )
177        self.position = self.p
178        self._sync_camera()
179
180    def _sync_camera(self) -> None:
181        # `_yaw` is in JS-frame (0 ↔ +Z forward). SimVX Camera3D's local
182        # forward is -Z, so we add π so the rendered look-direction matches
183        # the player's JS-frame heading.
184        from simvx.core.math.types import Quat
185
186        self.rotation = Quat.from_axis_angle(Vec3(0, 1, 0), self._yaw + math.pi)
187        # Camera local pitch (around its own X axis); local position = head + bob
188        self.camera.rotation = Quat.from_axis_angle(Vec3(1, 0, 0), self._pitch)
189        bob_y = math.sin(self._bob) * 0.3
190        self.camera.position = Vec3(0, 8 + bob_y, 0)
191
192    def receive_damage(self, source, amount: float) -> None:
193        if self._dead:
194            return
195        self.game.play_sfx(audio.sfx_hurt())
196        self._health -= int(amount)
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._health = 0
205        self.game.on_player_died()
206
207
208def install_input_actions() -> None:
209    """Register InputMap actions; call from root.on_ready()."""
210    from simvx.core import InputMap
211
212    InputMap.add_action("forward", [Key.W, Key.UP])
213    InputMap.add_action("back", [Key.S, Key.DOWN])
214    InputMap.add_action("left", [Key.A, Key.LEFT])
215    InputMap.add_action("right", [Key.D, Key.RIGHT])
216    InputMap.add_action("jump", [Key.SPACE, Mouse.RIGHT])
217    InputMap.add_action("fire", [Mouse.LEFT])
218    InputMap.add_action("weapon_prev", [Key.Q])
219    InputMap.add_action("weapon_next", [Key.E])
220    InputMap.add_action("menu", [Key.ESCAPE])