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])