HexGL¶
3D anti-grav racer, Catmull-Rom banked track, lap-attack, AI ghost.
▶ Run in browserUpstream: https://github.com/BKcore/HexGL
Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.
Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.
Tags: port tier-2
HexGL: SimVX Port¶
A SimVX port of BKcore/HexGL (anti-grav 3D racer in the WipEout tradition).
Run¶
# from the repo root
uv run python examples/ports/hexgl/main.py # interactive
uv run python examples/ports/hexgl/main.py --test # headless capture sweep (8 PNGs into screenshots/)
uv run python examples/ports/hexgl/harness.py # smoke harness (no graphics)
uv run simvx export web examples/ports/hexgl/main.py \
-o /tmp/hexgl.html
Controls¶
The title screen starts the race on Space, Enter or a click; the race is fully playable with a mouse or a touchscreen.
Action |
Keys |
Pointer |
|---|---|---|
Thrust |
W / Up |
hold left button |
Steer left / right |
A / D / Left / Right |
slide the held pointer left / right |
Brake |
S / Down |
right button |
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 files¶
File |
Summary |
Lines |
|---|---|---|
HexGL: 3D anti-grav racer, Catmull-Rom banked track, lap-attack, AI ghost. |
310 |
|
SceneRunner-driven smoke harness for HexGL. |
55 |
|
HexGL port nodes. |
1 |
|
Ghost AI opponent: drives the centreline at a fixed pace. |
74 |
|
Racer chase camera: speed pull-back on a roll-free rig. |
70 |
|
Speedometer / shield / boost / lap timer HUD. |
170 |
|
Title screen: the port’s menu-first entry point. |
88 |
|
Race manager: checkpoints, lap tracking, end-of-race state. |
140 |
|
Player ship: anti-grav with thrust, drift, roll, banked turning. |
357 |
|
Procedural anti-grav race track. |
352 |
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
9A SimVX port of BKcore's HexGL. Nothing is loaded from disk: the track is a
10closed Catmull-Rom spline extruded at runtime into a banked floor, side walls
11and emissive boost pads. On top of it sit track-frame ship physics, a 3-lap
12time-attack race, an AI ghost to chase, WorldEnvironment post-FX (bloom,
13motion blur, exponential fog, ACES tonemapping) and a Text2D + on_draw HUD
14layered over the 3D scene.
15
16Controls: W/Up thrust, A/D or the arrow keys steer, S/Down brake, Q/E
17air-brake, R restart, Esc quit. Mouse and touch play too: hold the left
18button to thrust and slide left or right to steer, right button to brake.
19
20Run:
21 uv run python examples/ports/hexgl/main.py # interactive
22 uv run python examples/ports/hexgl/main.py --test # headless capture sweep
23"""
24
25from __future__ import annotations
26
27import sys
28from collections.abc import Callable
29from pathlib import Path
30
31_PORT_DIR = Path(__file__).parent
32if str(_PORT_DIR) not in sys.path:
33 sys.path.insert(0, str(_PORT_DIR))
34
35from nodes.ai_ship import GhostShip # noqa: E402
36from nodes.camera import RacerCamera # noqa: E402
37from nodes.hud import HexHUD # noqa: E402
38from nodes.menu import TitleScreen # noqa: E402
39from nodes.race import RaceManager # noqa: E402
40from nodes.ship import Ship # noqa: E402
41from nodes.track import Track # noqa: E402
42
43from simvx.core import ( # noqa: E402
44 DirectionalLight3D,
45 Input,
46 InputMap,
47 Key,
48 MouseButton,
49 Node,
50 Vec3,
51 WorldEnvironment,
52)
53from simvx.graphics import App # noqa: E402
54
55WIDTH = 1280
56HEIGHT = 720
57
58
59def _make_environment_map() -> dict:
60 """Return the WorldEnvironment.environment_map spec.
61
62 If `assets/sky.hdr` exists, load it as an equirect HDR cubemap; otherwise
63 return a procedural twilight gradient.
64 """
65 hdr_path = _PORT_DIR / "assets" / "sky.hdr"
66 if hdr_path.exists():
67 return {"path": str(hdr_path)}
68 return {"colour": (0.04, 0.05, 0.10)}
69
70
71class HexGLRoot(Node):
72 """Root scene: Track + Ship + GhostShip + RacerCamera + RaceManager + HUD + title."""
73
74 def on_ready(self) -> None:
75 # Input: root.on_ready (web export skips main()). The pointer bindings
76 # keep the web/touch build playable: touch arrives as MouseButton.LEFT.
77 InputMap.add_action("thrust", [Key.W, Key.UP, MouseButton.LEFT])
78 InputMap.add_action("brake", [Key.S, Key.DOWN, MouseButton.RIGHT])
79 InputMap.add_action("steer_left", [Key.A, Key.LEFT])
80 InputMap.add_action("steer_right", [Key.D, Key.RIGHT])
81 InputMap.add_action("airbrake_left", [Key.Q, Key.LEFT_SHIFT])
82 InputMap.add_action("airbrake_right", [Key.E, Key.RIGHT_SHIFT])
83 InputMap.add_action("restart", [Key.R])
84 InputMap.add_action("quit", [Key.ESCAPE])
85
86 # Environment with race-feel post-FX. Bloom on emissive thrusters,
87 # motion blur for speed, mild fog for depth.
88 env = WorldEnvironment(environment_map=_make_environment_map())
89 env.bloom_enabled = True
90 env.bloom_threshold = 0.95
91 env.bloom_intensity = 0.85
92 env.tonemap_mode = "aces"
93 env.tonemap_exposure = 1.05
94 env.motion_blur_enabled = True
95 env.motion_blur_intensity = 0.55
96 env.motion_blur_samples = 12
97 env.fog_enabled = True
98 env.fog_mode = "exponential"
99 env.fog_colour = (0.06, 0.08, 0.13, 1.0)
100 env.fog_density = 0.008
101 env.ambient_light_colour = (0.10, 0.12, 0.18, 1.0)
102 env.ambient_light_energy = 0.7
103 self.add_child(env)
104
105 # Sun.
106 sun = DirectionalLight3D(name="Sun", intensity=1.7)
107 sun.colour = (1.0, 0.9, 0.78)
108 sun.position = Vec3(40.0, 60.0, 30.0)
109 sun.look_at(Vec3(0.0, 0.0, 0.0))
110 self.add_child(sun)
111
112 # Track.
113 self.track = Track(width=14.0, length_segments=400, name="Track")
114 self.add_child(self.track)
115
116 # Player ship.
117 self.ship = Ship(track=self.track, name="Ship")
118 self.add_child(self.ship)
119
120 # AI opponent: start about 10 % of the track ahead so the player overtakes.
121 self.ghost = GhostShip(track=self.track, base_speed=58.0, t_offset=0.1, name="Ghost")
122 self.add_child(self.ghost)
123
124 # Chase camera: close enough to see hull detail, but high enough to see road.
125 self.camera = RacerCamera(
126 target=self.ship,
127 distance=8.0,
128 height=2.8,
129 look_ahead=6.0,
130 half_life=0.12,
131 fov=68.0,
132 near=0.1,
133 far=600.0,
134 name="ChaseCam",
135 )
136 self.add_child(self.camera)
137
138 # Race manager. It stays idle until the title screen hands over.
139 self.race = RaceManager(ship=self.ship, track=self.track, max_laps=3, name="Race")
140 self.add_child(self.race)
141
142 # HUD, then the title card on top of it.
143 self.hud = HexHUD(ship=self.ship, race=self.race, name="HUD")
144 self.add_child(self.hud)
145
146 self.title = TitleScreen(name="Title")
147 self.title.started.connect(self.begin_race)
148 self.add_child(self.title)
149
150 def begin_race(self) -> None:
151 """Leave the title screen and arm the start lights."""
152 self.title.active = False
153 self.ship.reset()
154 self.race.start()
155
156 def on_update(self, dt: float) -> None:
157 # The ship only answers to the controls once the lights are out.
158 self.ship.controls_enabled = self.race.racing
159
160 if Input.is_action_just_pressed("quit"):
161 self.app.quit()
162 return
163 if self.race.phase != "idle" and Input.is_action_just_pressed("restart"):
164 self.ship.reset()
165 self.race.start()
166
167
168# -----------------------------------------------------------------------------
169# Headless --test capture
170# -----------------------------------------------------------------------------
171
172
173def _run_headless() -> None:
174 """Capture 8 staged screenshots with run_headless."""
175 from simvx.graphics import save_png
176
177 out_dir = _PORT_DIR / "screenshots"
178 out_dir.mkdir(exist_ok=True)
179
180 app = App(width=WIDTH, height=HEIGHT, title="HexGL (test)", visible=False)
181 root = HexGLRoot()
182
183 Stage = Callable[[HexGLRoot], None]
184
185 # Stage schedule: (capture_frame_idx, description, mutator).
186 stages: list[tuple[int, str, Stage]] = []
187
188 def stage(idx: int, name: str, fn: Stage) -> None:
189 stages.append((idx, name, fn))
190
191 def racing(r: HexGLRoot) -> None:
192 """Leave the title card and drop straight into the green flag."""
193 r.begin_race()
194 r.race.skip_countdown()
195
196 # Frame 30: the title card over the lit track.
197 stage(30, "01_title.png", lambda r: None)
198
199 # Frame 90: thrust on a straight section.
200 def go_straight(r):
201 racing(r)
202 r.ship.teleport(0.05, speed=50.0)
203 r.camera.snap()
204
205 stage(90, "02_straight.png", go_straight)
206
207 # Frame 130: banked turn.
208 def banked(r):
209 racing(r)
210 r.ship.teleport(0.30, lateral=2.0, speed=45.0)
211 r.camera.snap()
212
213 stage(130, "03_banked_turn.png", banked)
214
215 # Frame 170: boost pad effect (mid-pad).
216 def boosting(r):
217 racing(r)
218 r.ship.teleport(0.51, speed=60.0, boost=r.ship.booster_speed)
219 r.camera.snap()
220
221 stage(170, "04_boost.png", boosting)
222
223 # Frame 210: AI overtake, ghost is just ahead, player drawing alongside on the inside.
224 def overtake(r):
225 racing(r)
226 r.ship.teleport(0.42, lateral=-2.5, speed=65.0) # inside lane
227 r.ghost.teleport(0.425) # slightly ahead, on the right
228 r.camera.snap()
229
230 stage(210, "05_ai_overtake.png", overtake)
231
232 # Frame 250: lap completed UI, fake the race state.
233 def lap_done(r):
234 racing(r)
235 r.ship.teleport(0.02, speed=55.0)
236 r.race.lap = 2
237 r.race.elapsed = 47.235
238 r.race.best_lap = 47.235
239 r.race.lap_times = [47.235]
240 r.camera.snap()
241
242 stage(250, "06_lap_complete.png", lap_done)
243
244 # Frame 290: final straight, high speed motion blur showcase.
245 def final_straight(r):
246 racing(r)
247 r.ship.teleport(0.92, speed=72.0, boost=r.ship.booster_speed)
248 r.camera.snap()
249
250 stage(290, "07_final_straight.png", final_straight)
251
252 # Frame 330: race finished UI.
253 def finished(r):
254 racing(r)
255 r.ship.teleport(0.0, speed=25.0)
256 r.race.lap = 3
257 r.race.elapsed = 142.18
258 r.race.best_lap = 47.235
259 r.race.lap_times = [47.235, 47.5, 47.4]
260 r.race.dnf = False
261 r.race.phase = "finished"
262 r.camera.snap()
263
264 stage(330, "08_finish.png", finished)
265
266 capture_frames = [s[0] for s in stages]
267 schedule: dict[int, Stage] = {}
268 for capture_idx, _name, mutator in stages:
269 # Run the mutator a few frames *before* the capture so physics catches up.
270 schedule[max(0, capture_idx - 4)] = mutator
271
272 total_frames = max(capture_frames) + 4
273
274 def on_frame(idx, _t):
275 fn = schedule.get(idx)
276 if fn is not None:
277 try:
278 fn(root)
279 except Exception as exc: # don't crash the sweep
280 print(f"[--test] stage at frame {idx} failed: {exc!r}")
281 return None
282
283 captured = app.run_headless(
284 root,
285 frames=total_frames,
286 capture_frames=capture_frames,
287 on_frame=on_frame,
288 )
289
290 for (_idx, name, _fn), img in zip(stages, captured, strict=False):
291 # The captured frame keeps the blended destination alpha; force it opaque
292 # so the PNG is not written half-transparent.
293 try:
294 img[..., 3] = 255
295 except Exception:
296 pass
297 save_png(img, out_dir / name)
298 print(f"saved {out_dir / name}")
299
300
301def main() -> None:
302 if "--test" in sys.argv:
303 _run_headless()
304 return
305 app = App(width=WIDTH, height=HEIGHT, title="HexGL (SimVX)")
306 app.run(HexGLRoot())
307
308
309if __name__ == "__main__":
310 main()