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