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)