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