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