Make the running game inspectable, and stop the beacons swallowing the view

The pane this build runs in does not composite, so requestAnimationFrame never
fires, the loop stalls, and nothing can be screenshotted from outside. Adds a
`?debug=1` handle that steps the simulation by hand and renders on demand, plus
a dev-only endpoint that takes a POSTed frame and writes it to shots/. Moving
the image as bytes rather than a pasted data URL matters: a truncated base64
string decodes to nothing.

Looking at the result immediately found a real problem. Base beacons were wide
translucent cylinders — legible from a distance, a solid green wall from inside,
and the car parks in the middle of one. Now a slim pillar plus a ring on the
ground that marks where to stop. Also raised the sky and fog brightness, which
was dark enough to read as night.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-07 15:38:27 +02:00
parent 06c7555188
commit 405f1b0dfe
6 changed files with 251 additions and 21 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ dist/
# local Claude Code settings
.claude/settings.local.json
shots/

115
src/debug.ts Normal file
View File

@ -0,0 +1,115 @@
/**
* A debug handle on the running game, exposed as `window.__dbtl` when the page
* is loaded with `?debug=1`.
*
* This exists because the game cannot always be looked at directly a
* backgrounded or non-compositing tab never fires requestAnimationFrame, so the
* loop stalls and nothing can be screenshotted. With this, the simulation can be
* stepped by hand and a frame rendered and read back as an image on demand,
* which makes the 3D view inspectable from a script instead of only by eye.
*
* It is also just useful: jump to a place, fast-forward an hour of war, and look
* at what the world did.
*/
import * as THREE from 'three';
import type { SceneView } from './render/scene';
import type { PhysicsWorld } from './physics/physics';
import type { LoopHandlers } from './core/loop';
import { STEP } from './core/loop';
export interface CaptureOptions {
/** Output width in pixels. Height follows 16:9. */
width?: number;
/** 'chase' uses the game camera; 'top' looks straight down at the car. */
view?: 'chase' | 'top' | 'far';
/** Metres above the car for the overhead views. */
height?: number;
/** JPEG quality, 0..1. Lower keeps the data URL small enough to move around. */
quality?: number;
}
export interface DebugApi {
handlers: LoopHandlers;
view: SceneView;
physics: PhysicsWorld;
info: () => Record<string, unknown>;
teleport: (x: number, z: number) => void;
}
export function exposeDebug(api: DebugApi): void {
const captureCamera = new THREE.PerspectiveCamera(60, 16 / 9, 0.5, 4000);
const debug = {
/** Advances the simulation by hand, for when rAF is not running. */
step(seconds: number): void {
const steps = Math.round(seconds / STEP);
for (let i = 0; i < steps; i++) api.handlers.fixedUpdate(STEP);
},
/** Renders one frame through the normal render path. */
render(): void {
api.handlers.render(0, STEP);
},
/**
* Renders and hands back a JPEG data URL. Done synchronously in one task so
* the drawing buffer is still intact when it is read.
*/
capture(options: CaptureOptions = {}): string {
const { width = 800, view = 'chase', height = 120, quality = 0.72 } = options;
const { renderer, scene, car } = api.view;
const previous = new THREE.Vector2();
renderer.getSize(previous);
renderer.setSize(width, Math.round((width * 9) / 16), false);
if (view === 'chase') {
api.handlers.render(0, STEP);
} else {
const distance = view === 'far' ? height * 4 : height;
// Fog is tuned for a driver's eye line; from altitude it hides the map.
const fog = scene.fog;
if (view === 'far') scene.fog = null;
captureCamera.aspect = 16 / 9;
captureCamera.position.set(car.position.x, distance, car.position.z - distance * 0.35);
captureCamera.lookAt(car.position);
captureCamera.updateProjectionMatrix();
renderer.render(scene, captureCamera);
scene.fog = fog;
}
const data = renderer.domElement.toDataURL('image/jpeg', quality);
renderer.setSize(previous.x, previous.y, false);
return data;
},
/** Step, then capture, in one call — the common case from a script. */
run(seconds: number, options?: CaptureOptions): string {
debug.step(seconds);
return debug.capture(options);
},
/**
* Capture and post the frame to the dev server, which writes it to `shots/`.
* Moving the image as bytes rather than as a pasted data URL is the whole
* point a truncated base64 string decodes to nothing.
*/
async shot(name: string, options?: CaptureOptions): Promise<unknown> {
const dataUrl = debug.capture(options);
const blob = await (await fetch(dataUrl)).blob();
const response = await fetch(`/__shot?name=${encodeURIComponent(name)}`, {
method: 'POST',
body: blob,
});
return response.json();
},
teleport: api.teleport,
info: api.info,
three: THREE,
api,
};
(window as unknown as { __dbtl: typeof debug }).__dbtl = debug;
console.info('debug handle ready: window.__dbtl');
}

View File

@ -29,7 +29,8 @@ import { createHeatProps } from './heatProps';
import { createPersistence } from './persistence';
import { apply, type Snapshot } from './sim/save';
import { makeRng, seedFromString } from './core/rng';
import { startLoop } from './core/loop';
import { startLoop, type LoopHandlers } from './core/loop';
import { exposeDebug } from './debug';
import { createInput } from './core/input';
import { createPhysics } from './physics/physics';
import { createDriveState, drive } from './physics/drive';
@ -39,6 +40,9 @@ import { createHud } from './ui/hud';
import { createBoard } from './ui/board';
import { CAR, WHEELS } from './carSpec';
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */
export const DEBUG = new URLSearchParams(location.search).has('debug');
/** How far the driver can see well enough to fill in the map, metres. */
const SIGHT_RADIUS = 85;
@ -201,7 +205,7 @@ async function boot() {
document.getElementById('boot')?.remove();
startLoop({
const handlers: LoopHandlers = {
fixedUpdate(dt) {
elapsed += dt;
const cmd = input.read();
@ -485,7 +489,34 @@ async function boot() {
notice: elapsed < noticeUntil ? notice : '',
});
},
};
startLoop(handlers);
if (DEBUG) {
exposeDebug({
handlers,
view,
physics,
teleport(x, z) {
physics.chassis.setTranslation({ x, y: 1.4, z }, true);
physics.chassis.setLinvel({ x: 0, y: 0, z: 0 }, true);
physics.chassis.setAngvel({ x: 0, y: 0, z: 0 }, true);
},
info: () => ({
elapsed,
control,
segment: currentSegment,
onTarmac,
parts: quests.parts,
completed: quests.completed,
condition,
front: front.boundaries,
opportunity: opportunities.current,
notice,
}),
});
}
}
boot().catch((err) => {

View File

@ -5,19 +5,44 @@ import type { Base } from '../sim/bases';
* Bases and the active objective, as things you can see from a distance.
* The player navigates by looking, so both need to be visible over scenery.
*/
const BEACON_HEIGHT = 26;
const BEACON_HEIGHT = 34;
function beacon(colour: number, radius: number): THREE.Mesh {
return new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius, BEACON_HEIGHT, 12, 1, true),
new THREE.MeshBasicMaterial({
/**
* A slim pillar plus a ring on the ground.
*
* The first version was a wide translucent cylinder, which was legible from a
* distance and a solid green wall from inside and since the car parks in the
* middle of one, that is exactly where you spend your time. Narrow enough to see
* past, tall enough to see over buildings.
*/
function beacon(colour: number, radius: number): THREE.Group {
const group = new THREE.Group();
const material = new THREE.MeshBasicMaterial({
color: colour,
transparent: true,
opacity: 0.18,
opacity: 0.3,
side: THREE.DoubleSide,
depthWrite: false,
}),
// Additive keeps it reading as light rather than as a pane of glass.
blending: THREE.AdditiveBlending,
});
const pillar = new THREE.Mesh(
new THREE.CylinderGeometry(0.9, 0.9, BEACON_HEIGHT, 8, 1, true),
material,
);
pillar.position.y = BEACON_HEIGHT / 2;
group.add(pillar);
// The ring is what tells you where to actually stop.
const ring = new THREE.Mesh(
new THREE.RingGeometry(radius - 0.7, radius, 32).rotateX(-Math.PI / 2),
material,
);
ring.position.y = 0.08;
group.add(ring);
return group;
}
export function createMarkers(scene: THREE.Scene, bases: Base[]) {
@ -34,13 +59,12 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) {
scene.add(hut);
// The beacon stays over the junction, since that is where you park.
const light = beacon(0x5fd08a, 5);
light.position.set(base.x, BEACON_HEIGHT / 2, base.z);
const light = beacon(0x5fd08a, 9);
light.position.set(base.x, 0, base.z);
scene.add(light);
}
const objective = beacon(0xffc247, 6);
objective.position.y = BEACON_HEIGHT / 2;
const objective = beacon(0xffc247, 11);
objective.visible = false;
scene.add(objective);
@ -48,7 +72,7 @@ export function createMarkers(scene: THREE.Scene, bases: Base[]) {
/** Point the objective beacon at a target, or hide it when idle. */
setObjective(target: { x: number; z: number } | null) {
objective.visible = target !== null;
if (target) objective.position.set(target.x, BEACON_HEIGHT / 2, target.z);
if (target) objective.position.set(target.x, 0, target.z);
},
/** Slow spin, so a beacon reads as a marker rather than scenery. */
update(elapsed: number) {

View File

@ -37,7 +37,11 @@ const TONES: Record<Control, { sky: number; ground: number; sun: number; fog: [n
const SKY = 0x11161c;
export function createScene(model: WorldModel): SceneView {
const renderer = new THREE.WebGLRenderer({ antialias: true });
const renderer = new THREE.WebGLRenderer({
antialias: true,
// Needed so a rendered frame can still be read back as an image afterwards.
preserveDrawingBuffer: new URLSearchParams(location.search).has('debug'),
});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
@ -242,7 +246,7 @@ export function createScene(model: WorldModel): SceneView {
hemi.color.lerp(toneColour.set(tone.sky), t);
hemi.groundColor.lerp(toneColour.set(tone.ground), t);
sun.color.lerp(toneColour.set(tone.sun), t);
(scene.background as THREE.Color).lerp(toneColour.set(tone.sky).multiplyScalar(0.22), t);
(scene.background as THREE.Color).lerp(toneColour.set(tone.sky).multiplyScalar(0.5), t);
const fog = scene.fog as THREE.Fog;
fog.color.copy(scene.background as THREE.Color);
fog.near += (tone.fog[0] - fog.near) * t;

55
vite.config.ts Normal file
View File

@ -0,0 +1,55 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { Plugin } from 'vite';
import { defineConfig } from 'vite';
/**
* Dev-only: lets the running game POST a rendered frame to disk.
*
* The game cannot always be looked at a backgrounded or non-compositing tab
* never fires requestAnimationFrame, so nothing draws and nothing can be
* screenshotted from outside. With this, plus the `?debug=1` handle in
* src/debug.ts, a script can step the simulation, render a frame, and drop the
* image somewhere it can actually be opened.
*
* Never registered in a production build.
*/
function screenshotEndpoint(): Plugin {
const directory = resolve(process.cwd(), 'shots');
return {
name: 'dbtl-screenshot',
apply: 'serve',
configureServer(server) {
server.middlewares.use('/__shot', (req, res) => {
if (req.method !== 'POST') {
res.statusCode = 405;
res.end('POST only');
return;
}
const name = (new URL(req.url ?? '', 'http://x').searchParams.get('name') ?? 'frame')
// Keep this from writing outside the shots directory.
.replace(/[^a-z0-9_-]/gi, '');
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
try {
mkdirSync(directory, { recursive: true });
const file = resolve(directory, `${name || 'frame'}.jpg`);
writeFileSync(file, Buffer.concat(chunks));
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ ok: true, file, bytes: Buffer.concat(chunks).length }));
} catch (err) {
res.statusCode = 500;
res.end(String(err));
}
});
});
},
};
}
export default defineConfig({
plugins: [screenshotEndpoint()],
});