nodes/ship.py¶
Part of HexGL.
1"""Player ship: anti-grav with thrust, drift, roll, banked turning.
2
3Mirrors the physics model from upstream ``ShipControls.js`` while removing
4the bitmap collision/height systems. Instead, the ship:
5
6- Lives in *track-frame* coordinates: ``(t, lateral_offset, height_above_track)``.
7- Each tick the ship integrates speed along the tangent and lateral
8 velocity from steering. Track samples convert (t, lateral, height) back to
9 world position + orientation each frame.
10- Track lateral bounds keep the ship on; touching a wall bleeds speed and
11 bounces lateral velocity.
12- Boost pads are detected by t-proximity and add a one-shot booster speed.
13
14This keeps the physics deterministic (no collision-map sampling) while
15preserving the WipEout-feel: thrust + lateral grip + roll + bank-following.
16"""
17
18from __future__ import annotations
19
20import math
21
22from simvx.core import (
23 Input,
24 Material,
25 Mesh,
26 MeshInstance3D,
27 MouseButton,
28 Node3D,
29 Property,
30 Quat,
31 Signal,
32 Vec3,
33)
34
35from .track import Track
36
37
38class Ship(Node3D):
39 """Player anti-grav racer."""
40
41 # Parameters mirror the upstream constants but tuned for our metric scale.
42 thrust = Property(28.0, range=(5.0, 80.0))
43 air_resist = Property(0.55, range=(0.0, 5.0))
44 air_brake = Property(40.0, range=(0.0, 200.0))
45 max_speed = Property(75.0, range=(20.0, 200.0))
46 booster_speed = Property(28.0, range=(0.0, 80.0))
47 booster_decay = Property(8.0, range=(0.5, 30.0))
48
49 angular_speed = Property(2.4, range=(0.5, 6.0)) # rad/s base steering
50 lateral_grip = Property(6.0, range=(1.0, 20.0))
51 lateral_drag = Property(3.5, range=(0.0, 10.0))
52 drift_lerp = Property(2.0, range=(0.5, 8.0))
53
54 roll_angle = Property(0.55, range=(0.0, 1.5)) # max roll in radians
55 roll_lerp = Property(5.0, range=(0.5, 20.0))
56
57 max_shield = Property(1.0)
58
59 fired_boost = Signal()
60 crashed = Signal()
61
62 def __init__(self, track: Track, **kwargs) -> None:
63 super().__init__(**kwargs)
64 self.track = track
65
66 # Track-space state.
67 self.t: float = 0.0
68 self.lateral: float = 0.0
69 self.height: float = 1.4 # hovers ~1.4 m above the floor
70 self.speed: float = 0.0
71 self.lateral_velocity: float = 0.0
72 self.boost: float = 0.0
73 self.shield: float = float(self.max_shield)
74 self.roll: float = 0.0
75 self.steering: float = 0.0 # -1..1, smoothed input
76 self.destroyed: bool = False
77 # The race manager opens the controls when the lights go out, so the
78 # player can neither jump the start nor drive from the title screen.
79 self.controls_enabled: bool = False
80
81 # Boost-pad debouncing: don't re-fire while still on the same pad.
82 self._boost_armed: bool = True
83
84 # Mesh placeholders (built in on_ready).
85 self._body: MeshInstance3D | None = None
86 self._thruster: MeshInstance3D | None = None
87 self._cockpit: MeshInstance3D | None = None
88 self._thruster_mat: Material | None = None
89
90 @property
91 def speed_ratio(self) -> float:
92 return min(1.0, (self.speed + self.boost) / float(self.max_speed))
93
94 def on_ready(self) -> None:
95 # Hull: a flattened cube: clearly ship-shaped from any angle.
96 body_mat = Material(colour=(0.18, 0.45, 0.85, 1.0), roughness=0.35, metallic=0.6)
97 self._body = MeshInstance3D(name="Hull", mesh=Mesh.cube(), material=body_mat)
98 self._body.scale = Vec3(1.6, 0.5, 3.0) # wide-ish, low, long
99 self.add_child(self._body)
100
101 # Nose: cone forward, apex pointing -Z (local forward).
102 nose_mat = Material(colour=(0.10, 0.30, 0.65, 1.0), roughness=0.45, metallic=0.7)
103 nose = MeshInstance3D(
104 name="Nose",
105 mesh=Mesh.cone(radius=0.7, height=1.4, segments=12),
106 material=nose_mat,
107 )
108 nose.rotation = Quat.from_euler(math.radians(-90), 0.0, 0.0) # +Y → -Z
109 nose.position = Vec3(0.0, 0.0, -2.1)
110 self.add_child(nose)
111
112 # Wings: two flat cubes either side.
113 wing_mat = Material(colour=(0.85, 0.20, 0.20, 1.0), roughness=0.4, metallic=0.5)
114 for sign in (-1.0, 1.0):
115 wing = MeshInstance3D(name=f"Wing_{sign:+.0f}", mesh=Mesh.cube(), material=wing_mat)
116 wing.scale = Vec3(1.6, 0.18, 1.4)
117 wing.position = Vec3(sign * 1.6, 0.05, 0.6)
118 self.add_child(wing)
119
120 # Cockpit dome: sphere on top.
121 cockpit_mat = Material(colour=(0.05, 0.7, 0.95, 1.0), roughness=0.15, metallic=0.9)
122 self._cockpit = MeshInstance3D(
123 name="Cockpit",
124 mesh=Mesh.sphere(radius=0.5, rings=12, segments=12),
125 material=cockpit_mat,
126 )
127 self._cockpit.scale = Vec3(1.0, 0.6, 1.2)
128 self._cockpit.position = Vec3(0.0, 0.4, -0.4)
129 self.add_child(self._cockpit)
130
131 # Thruster: emissive cone, apex pointing +Z (rear). Visible from behind.
132 self._thruster_mat = Material(
133 colour=(1.0, 0.55, 0.18, 1.0),
134 emissive_colour=(1.0, 0.5, 0.15, 2.0),
135 roughness=0.3,
136 metallic=0.1,
137 )
138 self._thruster = MeshInstance3D(
139 name="Thruster",
140 mesh=Mesh.cone(radius=0.30, height=0.9, segments=10),
141 material=self._thruster_mat,
142 )
143 # Default cone apex is +Y; we want apex at +Z (rear). Pitch +90° → +Y → +Z.
144 self._thruster.rotation = Quat.from_euler(math.radians(90), 0.0, 0.0)
145 self._thruster.position = Vec3(0.0, 0.0, 1.85)
146 self.add_child(self._thruster)
147
148 # Snap to track at t=0.
149 self._sync_world_transform()
150
151 # ------------------------------------------------------------------
152 # Reset / lifecycle
153 # ------------------------------------------------------------------
154
155 def reset(self, t: float = 0.0) -> None:
156 self.t = t
157 self.lateral = 0.0
158 self.speed = 0.0
159 self.boost = 0.0
160 self.lateral_velocity = 0.0
161 self.shield = float(self.max_shield)
162 self.roll = 0.0
163 self.steering = 0.0
164 self.destroyed = False
165 self._sync_world_transform()
166
167 def teleport(
168 self,
169 t: float,
170 *,
171 lateral: float = 0.0,
172 speed: float | None = None,
173 boost: float | None = None,
174 ) -> None:
175 """Place the ship on the track at parameter ``t`` and push the pose out.
176
177 Sets the track-frame state and syncs the world transform in one call,
178 so a caller (the capture sweep, a checkpoint respawn) never has to
179 drive the ship there.
180 """
181 self.t = float(t) % 1.0
182 self.lateral = float(lateral)
183 self.lateral_velocity = 0.0
184 if speed is not None:
185 self.speed = float(speed)
186 if boost is not None:
187 self.boost = float(boost)
188 self._sync_world_transform()
189
190 # ------------------------------------------------------------------
191 # Per-frame physics
192 # ------------------------------------------------------------------
193
194 def on_fixed_update(self, dt: float) -> None:
195 if self.destroyed:
196 # Spin out gently so the camera doesn't pop.
197 self.roll += dt * 1.5
198 return
199
200 # Read inputs (polled; works in headless harness too).
201 live = self.controls_enabled
202 forward = live and Input.is_action_pressed("thrust")
203 backward = live and Input.is_action_pressed("brake")
204 left = live and Input.is_action_pressed("steer_left")
205 right = live and Input.is_action_pressed("steer_right")
206 airbrake_l = live and Input.is_action_pressed("airbrake_left")
207 airbrake_r = live and Input.is_action_pressed("airbrake_right")
208
209 # Steering: smooth toward target. Keys and pointer add, so a held
210 # touch can be trimmed with the keyboard and vice versa.
211 target_steer = (1.0 if right else 0.0) - (1.0 if left else 0.0) + self._pointer_steer()
212 if airbrake_l:
213 target_steer -= 0.6
214 if airbrake_r:
215 target_steer += 0.6
216 # Normalise.
217 target_steer = max(-1.5, min(1.5, target_steer))
218 self.steering += (target_steer - self.steering) * min(1.0, 6.0 * dt)
219
220 # Speed integration.
221 if forward:
222 self.speed += float(self.thrust) * dt
223 else:
224 self.speed -= float(self.air_resist) * dt
225 if backward:
226 self.speed -= float(self.air_brake) * dt
227 if airbrake_l or airbrake_r:
228 self.speed -= float(self.air_brake) * 0.5 * dt
229 self.speed = max(0.0, min(float(self.max_speed), self.speed))
230
231 # Booster decay.
232 if self.boost > 0.0:
233 self.boost = max(0.0, self.boost - float(self.booster_decay) * dt)
234
235 # Effective forward speed along tangent.
236 forward_speed = self.speed + self.boost
237
238 # Lateral velocity: steering pushes lateral velocity, lateral_drag pulls it back.
239 # WipEout-feel: faster speeds, weaker lateral grip → more drift.
240 steer_force = self.steering * float(self.angular_speed) * (0.5 + 0.5 * self.speed_ratio)
241 # Translate steering into lateral acceleration. The tangent at this t
242 # rotates with the track, so we don't need to apply heading manually,
243 # lateral is "metres right of the centreline" and steering makes it grow.
244 self.lateral_velocity += steer_force * forward_speed * dt
245 self.lateral_velocity -= self.lateral_velocity * float(self.lateral_drag) * dt
246 # Clamp lateral velocity so we don't reach mach-orbit.
247 max_lat_v = forward_speed * 1.2
248 self.lateral_velocity = max(-max_lat_v, min(max_lat_v, self.lateral_velocity))
249
250 self.lateral += self.lateral_velocity * dt
251
252 # Wall: hard clamp + damped bounce.
253 half_w = 0.5 * self.track.width - 0.6
254 if self.lateral > half_w:
255 self.lateral = half_w
256 self.lateral_velocity = -abs(self.lateral_velocity) * 0.4
257 self._on_wall_hit()
258 elif self.lateral < -half_w:
259 self.lateral = -half_w
260 self.lateral_velocity = abs(self.lateral_velocity) * 0.4
261 self._on_wall_hit()
262
263 # Advance along the track. tangent-distance / track length → t delta.
264 if self.track.total_length > 0.0:
265 dt_along = forward_speed * dt / self.track.total_length
266 else:
267 dt_along = 0.0
268 self.t = (self.t + dt_along) % 1.0
269
270 # Boost pads: fire when crossing within radius, debounced.
271 on_pad = self._boost_pad_under(self.t)
272 if on_pad and self._boost_armed:
273 self.boost = float(self.booster_speed)
274 self._boost_armed = False
275 self.fired_boost.emit()
276 elif not on_pad:
277 self._boost_armed = True
278
279 # Roll target: lean into steering, plus track bank.
280 c, tan, side, normal, bank = self.track.sample_at(self.t)
281 target_roll = -self.steering * float(self.roll_angle) + bank
282 self.roll += (target_roll - self.roll) * min(1.0, float(self.roll_lerp) * dt)
283
284 # Push transform out to world.
285 self._sync_world_transform(c, tan, side, normal)
286
287 # Thruster emissive intensity swells with throttle. Cap aggressively so
288 # bloom enhances but doesn't blow out the ship silhouette.
289 if self._thruster_mat is not None:
290 self._thruster_mat.emissive_strength = 1.0 + 1.6 * self.speed_ratio + (1.5 if self.boost > 0.0 else 0.0)
291 # Lengthen the thruster behind ship at speed but keep radius modest.
292 sx = 0.7 + 0.3 * self.speed_ratio
293 sz = 0.6 + 1.2 * self.speed_ratio + (0.5 if self.boost > 0.0 else 0.0)
294 if self._thruster is not None:
295 self._thruster.scale = Vec3(sx, sz, sx)
296
297 # ------------------------------------------------------------------
298 # Internal helpers
299 # ------------------------------------------------------------------
300
301 def _pointer_steer(self) -> float:
302 """Steering from a held pointer: -1 (full left) .. 1 (full right).
303
304 Touch arrives as :attr:`MouseButton.LEFT` on the web export, so one
305 path covers mouse and touch. The middle of the screen is a dead zone
306 so a straight-ahead hold (which is also the thrust input) does not
307 weave.
308 """
309 if not self.controls_enabled or self.tree is None:
310 return 0.0
311 if not Input.is_mouse_button_pressed(MouseButton.LEFT):
312 return 0.0
313 width = float(self.tree.screen_size[0])
314 if width <= 0.0:
315 return 0.0
316 offset = (float(Input.mouse_position.x) / width - 0.5) * 2.0
317 dead_zone = 0.12
318 if abs(offset) <= dead_zone:
319 return 0.0
320 return max(-1.0, min(1.0, (offset - math.copysign(dead_zone, offset)) / (1.0 - dead_zone)))
321
322 def _on_wall_hit(self) -> None:
323 sr = self.speed_ratio
324 damage = sr * sr * 0.18
325 self.shield -= damage
326 self.speed *= 0.85
327 self.boost = 0.0
328 if self.shield <= 0.0:
329 self.shield = 0.0
330 self.destroyed = True
331 self.crashed.emit()
332
333 def _boost_pad_under(self, t: float) -> bool:
334 for pad_t in self.track.boost_pads:
335 d = abs(((t - pad_t + 0.5) % 1.0) - 0.5)
336 if d < self.track.boost_pad_radius_t:
337 return True
338 return False
339
340 def _sync_world_transform(
341 self,
342 c: Vec3 | None = None,
343 tangent: Vec3 | None = None,
344 side: Vec3 | None = None,
345 normal: Vec3 | None = None,
346 ) -> None:
347 if c is None:
348 c, tangent, side, normal, _bank = self.track.sample_at(self.t)
349 # World position = centre + side * lateral + normal * height.
350 pos = c + side * self.lateral + normal * self.height
351 self.position = pos
352 # Rotation: align local -Z with tangent, local +Y with normal, local +X with side.
353 # Build matrix columns and convert to quaternion.
354 # SimVX `forward` = world_rotation * (0,0,-1), so we want
355 # ``forward = tangent`` ⇒ Quat.look_at(direction=tangent, up=normal).
356 # Then post-multiply by roll around the *local* forward axis (Z+).
357 self.rotation = Quat.look_at(tangent, normal) * Quat.from_axis_angle((0.0, 0.0, 1.0), self.roll)