diff --git a/.gitignore b/.gitignore index bbd1bd1..0d70945 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ # local Claude Code settings .claude/settings.local.json +shots/ diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..d100ad4 --- /dev/null +++ b/src/debug.ts @@ -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; + 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 { + 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'); +} diff --git a/src/main.ts b/src/main.ts index c0f5cc2..001cc8e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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) => { diff --git a/src/render/markers.ts b/src/render/markers.ts index 664abe0..4109fee 100644 --- a/src/render/markers.ts +++ b/src/render/markers.ts @@ -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({ - color: colour, - transparent: true, - opacity: 0.18, - side: THREE.DoubleSide, - depthWrite: false, - }), +/** + * 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.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) { diff --git a/src/render/scene.ts b/src/render/scene.ts index 13bcb24..a158539 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -37,7 +37,11 @@ const TONES: Record { + 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()], +});