HexGL¶
3D anti-grav racer, Catmull-Rom banked track, lap-attack, AI ghost.
▶ Run in browserUpstream: https://github.com/BKcore/HexGL
Tags: port tier-2
HexGL: SimVX Port¶
A SimVX port of BKcore/HexGL (anti-grav 3D racer in the WipEout tradition).
Run¶
# Interactive (Vulkan window)
uv run python ported_games/hexgl/simvx_port/main.py
# Headless capture sweep: writes 8 PNGs into screenshots/
uv run python ported_games/hexgl/simvx_port/main.py --test
# Smoke harness (no graphics)
uv run python ported_games/hexgl/simvx_port/harness.py
# Web export (single HTML)
uv run simvx export web ported_games/hexgl/simvx_port/main.py \
-o ported_games/hexgl/simvx_port/web/index.html
Controls¶
Action |
Keys |
|---|---|
Thrust |
W / Up |
Brake |
S / Down |
Steer left / right |
A / D / Left / Right |
Air-brake left |
Q / Left-Shift |
Air-brake right |
E / Right-Shift |
Restart race |
R |
Quit |
Esc |
Track and ships¶
The track is procedurally generated from an 8-point Catmull-Rom spline,
extruded along the curve into a banked floor + side walls. 6 virtual
checkpoints, 3 emissive boost pads. The hull and ghost AI are built from
SimVX Mesh.cone + Mesh.sphere primitives. No upstream assets are
redistributed: upstream’s track meshes and HUD textures are CC-BY-NC.
Source¶
1"""HexGL: 3D anti-grav racer, Catmull-Rom banked track, lap-attack, AI ghost.
2
3# /// simvx
4# tags = ["port", "tier-2"]
5# upstream = "https://github.com/BKcore/HexGL"
6# web = { width = 1280, height = 720, responsive = true }
7# ///
8
9Run:
10 uv run python ported_games/hexgl/simvx_port/main.py # interactive
11 uv run python ported_games/hexgl/simvx_port/main.py --test # headless capture
12"""
13
14from __future__ import annotations
15
16import math
17import sys
18from pathlib import Path
19
20_PORT_DIR = Path(__file__).parent
21if str(_PORT_DIR) not in sys.path:
22 sys.path.insert(0, str(_PORT_DIR))
23
24from nodes.ai_ship import GhostShip # noqa: E402
25from nodes.camera import ChaseCamera # noqa: E402
26from nodes.hud import HexHUD # noqa: E402
27from nodes.race import RaceManager # noqa: E402
28from nodes.ship import Ship # noqa: E402
29from nodes.track import Track # noqa: E402
30
31from simvx.core import ( # noqa: E402
32 DirectionalLight3D,
33 Input,
34 InputMap,
35 Key,
36 Node,
37 Vec3,
38 WorldEnvironment,
39)
40from simvx.graphics import App # noqa: E402
41
42WIDTH = 1280
43HEIGHT = 720
44
45
46def _make_environment_map() -> dict:
47 """Return the WorldEnvironment.environment_map spec.
48
49 If `assets/sky.hdr` exists, load it as an equirect HDR cubemap (P5);
50 otherwise return a procedural twilight gradient.
51 """
52 hdr_path = _PORT_DIR / "assets" / "sky.hdr"
53 if hdr_path.exists():
54 return {"path": str(hdr_path)}
55 return {"colour": (0.04, 0.05, 0.10)}
56
57
58class HexGLRoot(Node):
59 """Root scene: Track + Ship + GhostShip + ChaseCamera + RaceManager + HUD."""
60
61 def on_ready(self) -> None:
62 # Input: root.on_ready (web export skips main()).
63 InputMap.add_action("thrust", [Key.W, Key.UP])
64 InputMap.add_action("brake", [Key.S, Key.DOWN])
65 InputMap.add_action("steer_left", [Key.A, Key.LEFT])
66 InputMap.add_action("steer_right", [Key.D, Key.RIGHT])
67 InputMap.add_action("airbrake_left", [Key.Q, Key.LEFT_SHIFT])
68 InputMap.add_action("airbrake_right", [Key.E, Key.RIGHT_SHIFT])
69 InputMap.add_action("restart", [Key.R])
70 InputMap.add_action("quit", [Key.ESCAPE])
71
72 # Environment with race-feel post-FX. Bloom on emissive thrusters,
73 # motion blur for speed, mild fog for depth.
74 env = WorldEnvironment(environment_map=_make_environment_map())
75 env.bloom_enabled = True
76 env.bloom_threshold = 0.95
77 env.bloom_intensity = 0.85
78 env.tonemap_mode = "aces"
79 env.tonemap_exposure = 1.05
80 env.motion_blur_enabled = True
81 env.motion_blur_intensity = 0.55
82 env.motion_blur_samples = 12
83 env.fog_enabled = True
84 env.fog_mode = "exponential"
85 env.fog_colour = (0.06, 0.08, 0.13, 1.0)
86 env.fog_density = 0.008
87 env.ambient_light_colour = (0.10, 0.12, 0.18, 1.0)
88 env.ambient_light_energy = 0.7
89 self.add_child(env)
90
91 # Sun.
92 sun = DirectionalLight3D(name="Sun", intensity=1.7)
93 sun.colour = (1.0, 0.9, 0.78)
94 sun.position = Vec3(40.0, 60.0, 30.0)
95 sun.look_at(Vec3(0.0, 0.0, 0.0))
96 self.add_child(sun)
97
98 # Track.
99 self.track = Track(width=14.0, length_segments=400, name="Track")
100 self.add_child(self.track)
101
102 # Player ship.
103 self.ship = Ship(track=self.track, name="Ship")
104 self.add_child(self.ship)
105
106 # AI opponent: start about 10 % of the track ahead so the player overtakes.
107 self.ghost = GhostShip(track=self.track, base_speed=58.0, t_offset=0.1, name="Ghost")
108 self.add_child(self.ghost)
109
110 # Chase camera: close enough to see hull detail, but high enough to see road.
111 self.camera = ChaseCamera(
112 target=self.ship,
113 distance=8.0,
114 height=2.8,
115 lerp=6.0,
116 look_ahead=6.0,
117 fov=68.0,
118 near=0.1,
119 far=600.0,
120 name="ChaseCam",
121 )
122 self.add_child(self.camera)
123
124 # Race manager.
125 self.race = RaceManager(ship=self.ship, track=self.track, max_laps=3, name="Race")
126 self.race.start()
127 self.add_child(self.race)
128
129 # HUD on top.
130 self.hud = HexHUD(ship=self.ship, race=self.race, name="HUD")
131 self.add_child(self.hud)
132
133 def on_process(self, dt: float) -> None:
134 if Input.is_action_just_pressed("quit"):
135 self.app.quit()
136 return
137 if Input.is_action_just_pressed("restart"):
138 self.ship.reset()
139 self.race.start()
140
141
142# -----------------------------------------------------------------------------
143# Headless --test capture
144# -----------------------------------------------------------------------------
145
146def _run_headless() -> None:
147 """Capture 8 staged screenshots with run_headless."""
148 from simvx.graphics import save_png
149
150 out_dir = _PORT_DIR / "screenshots"
151 out_dir.mkdir(exist_ok=True)
152
153 app = App(width=WIDTH, height=HEIGHT, title="HexGL (test)", visible=False)
154 root = HexGLRoot()
155
156 # Stage schedule: (capture_frame_idx, description, mutator).
157 stages: list[tuple[int, str, callable]] = []
158
159 def stage(idx: int, name: str, fn: callable) -> None:
160 stages.append((idx, name, fn))
161
162 # Frame 30: spawn, first proper render after the countdown banner shows.
163 stage(30, "01_spawn.png", lambda r: None)
164
165 def _force_running(r):
166 """Skip countdown so the GO! banner clears for screenshots after stage 1."""
167 r.race._countdown = -1.0
168 r.race.race_started = True
169
170 # Frame 90: thrust on a straight section.
171 def go_straight(r):
172 _force_running(r)
173 r.ship.t = 0.05
174 r.ship.lateral = 0.0
175 r.ship.speed = 50.0
176 r.ship._sync_world_transform()
177 r.camera._smoothed_pos = None # snap camera to new position
178 r.camera._smoothed_target = None
179 stage(90, "02_straight.png", go_straight)
180
181 # Frame 130: banked turn.
182 def banked(r):
183 _force_running(r)
184 r.ship.t = 0.30
185 r.ship.lateral = 2.0
186 r.ship.speed = 45.0
187 r.ship._sync_world_transform()
188 r.camera._smoothed_pos = None
189 r.camera._smoothed_target = None
190 stage(130, "03_banked_turn.png", banked)
191
192 # Frame 170: boost pad effect (mid-pad).
193 def boosting(r):
194 _force_running(r)
195 r.ship.t = 0.51
196 r.ship.lateral = 0.0
197 r.ship.boost = r.ship.booster_speed
198 r.ship.speed = 60.0
199 r.ship._sync_world_transform()
200 r.camera._smoothed_pos = None
201 r.camera._smoothed_target = None
202 stage(170, "04_boost.png", boosting)
203
204 # Frame 210: AI overtake, ghost is just ahead, player drawing alongside on the inside.
205 def overtake(r):
206 _force_running(r)
207 r.ship.t = 0.42
208 r.ship.lateral = -2.5 # inside lane
209 r.ghost.t = 0.425 # slightly ahead, on the right
210 r.ghost._sync_world_transform()
211 r.ship.speed = 65.0
212 r.ship._sync_world_transform()
213 r.camera._smoothed_pos = None
214 r.camera._smoothed_target = None
215 stage(210, "05_ai_overtake.png", overtake)
216
217 # Frame 250: lap completed UI, fake the race state.
218 def lap_done(r):
219 _force_running(r)
220 r.ship.t = 0.02
221 r.ship.lateral = 0.0
222 r.ship.speed = 55.0
223 r.ship._sync_world_transform()
224 r.race.lap = 2
225 r.race.elapsed = 47.235
226 r.race.best_lap = 47.235
227 r.race.lap_times = [47.235]
228 r.camera._smoothed_pos = None
229 r.camera._smoothed_target = None
230 stage(250, "06_lap_complete.png", lap_done)
231
232 # Frame 290: final straight, high speed motion blur showcase.
233 def final_straight(r):
234 _force_running(r)
235 r.ship.t = 0.92
236 r.ship.lateral = 0.0
237 r.ship.speed = 72.0
238 r.ship.boost = r.ship.booster_speed
239 r.ship._sync_world_transform()
240 r.camera._smoothed_pos = None
241 r.camera._smoothed_target = None
242 stage(290, "07_final_straight.png", final_straight)
243
244 # Frame 330: race finished UI.
245 def finished(r):
246 _force_running(r)
247 r.ship.t = 0.0
248 r.ship.lateral = 0.0
249 r.ship.speed = 25.0
250 r.ship._sync_world_transform()
251 r.race.lap = 3
252 r.race.elapsed = 142.18
253 r.race.best_lap = 47.235
254 r.race.lap_times = [47.235, 47.5, 47.4]
255 r.race.race_ended = True
256 r.race.dnf = False
257 r.camera._smoothed_pos = None
258 r.camera._smoothed_target = None
259 stage(330, "08_finish.png", finished)
260
261 capture_frames = [s[0] for s in stages]
262 schedule: dict[int, callable] = {}
263 for capture_idx, _name, mutator in stages:
264 # Run the mutator a few frames *before* the capture so physics catches up.
265 schedule[max(0, capture_idx - 4)] = mutator
266
267 total_frames = max(capture_frames) + 4
268
269 def on_frame(idx, _t):
270 fn = schedule.get(idx)
271 if fn is not None:
272 try:
273 fn(root)
274 except Exception as exc: # don't crash the sweep
275 print(f"[--test] stage at frame {idx} failed: {exc!r}")
276 return None
277
278 captured = app.run_headless(
279 root,
280 frames=total_frames,
281 capture_frames=capture_frames,
282 on_frame=on_frame,
283 )
284
285 for (_idx, name, _fn), img in zip(stages, captured, strict=False):
286 # Force opaque alpha: Wave 2 rollup: alpha-blend dst factor truncates.
287 try:
288 img[..., 3] = 255
289 except Exception:
290 pass
291 save_png(out_dir / name, img)
292 print(f"saved {out_dir / name}")
293
294
295def main() -> None:
296 if "--test" in sys.argv:
297 _run_headless()
298 return
299 app = App(width=WIDTH, height=HEIGHT, title="HexGL (SimVX)")
300 app.run(HexGLRoot())
301
302
303if __name__ == "__main__":
304 main()
305
306
307# Silence unused-import warnings for type-only imports on cold scenes.
308_ = math