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