nodes/ai_ship.pyΒΆ

Part of HexGL.

 1"""Ghost AI opponent: drives the centreline at a fixed pace.
 2
 3Procedurally varies its speed by a sine-wave so the player overtakes on
 4straights and is overtaken on corners. Visually distinct hull colour so
 5screenshots show two ships clearly.
 6"""
 7
 8from __future__ import annotations
 9
10import math
11
12from simvx.core import Material, Mesh, MeshInstance3D, Node3D, Quat, Vec3
13
14from .track import Track
15
16
17class GhostShip(Node3D):
18    """AI opponent following the track centreline."""
19
20    def __init__(self, track: Track, base_speed: float = 55.0, t_offset: float = 0.012, **kwargs) -> None:
21        super().__init__(**kwargs)
22        self.track = track
23        self.base_speed = float(base_speed)
24        self.t = float(t_offset)
25        self._time = 0.0
26
27    def on_ready(self) -> None:
28        body_mat = Material(colour=(0.95, 0.45, 0.10, 1.0), roughness=0.4, metallic=0.5)
29        body = MeshInstance3D(
30            name="GhostHull",
31            mesh=Mesh.cone(radius=0.55, height=2.6, segments=12),
32            material=body_mat,
33        )
34        body.rotation = Quat.from_euler(math.radians(-90), 0.0, 0.0)
35        body.position = Vec3(0.0, 0.0, 0.4)
36        self.add_child(body)
37
38        thruster_mat = Material(
39            colour=(1.0, 0.85, 0.25, 1.0),
40            emissive_colour=(1.0, 0.85, 0.25, 2.0),
41            roughness=0.3,
42        )
43        thruster = MeshInstance3D(
44            name="GhostThrust",
45            mesh=Mesh.cone(radius=0.25, height=0.7, segments=8),
46            material=thruster_mat,
47        )
48        thruster.rotation = Quat.from_euler(math.radians(90), 0.0, 0.0)
49        thruster.position = Vec3(0.0, 0.0, 1.4)
50        self.add_child(thruster)
51
52        # Snap to centreline.
53        self._sync_world_transform()
54
55    def teleport(self, t: float) -> None:
56        """Place the ghost at track parameter ``t`` and push the pose out."""
57        self.t = float(t) % 1.0
58        self._sync_world_transform()
59
60    def on_fixed_update(self, dt: float) -> None:
61        self._time += dt
62        # Speed varies sinusoidally: slows on tight corners (~0.85x), boosts on straights (~1.15x).
63        speed = self.base_speed * (1.0 + 0.15 * math.sin(self._time * 0.6))
64        if self.track.total_length > 0.0:
65            self.t = (self.t + speed * dt / self.track.total_length) % 1.0
66        self._sync_world_transform()
67
68    def _sync_world_transform(self) -> None:
69        c, tan, side, normal, _bank = self.track.sample_at(self.t)
70        # Hold a right-side line so the player can overtake on the inside (left).
71        lateral = 3.0
72        height = 1.4
73        self.position = c + side * lateral + normal * height
74        self.rotation = Quat.look_at(tan, normal)