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)