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)