/** * 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'; import { CURRENCY } from './ui/currency'; 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; /** Live simulation state, deliberately untyped: this is a debug window. */ state: Record; info: () => Record; teleport: (x: number, z: number) => void; condition: () => { level: { engine: number; tires: number; chassis: number } }; setCondition: (level: number) => unknown; /** Reads and writes the money in hand, for trying the workshop out. */ funds: () => number; setFunds: (amount: number) => number; } /** * An on-screen panel for the levers worth pulling by hand. * * The console handle covers everything, but "drive a wrecked car for a minute" * is a thing you want to do *while driving*, not while typing — so condition * gets buttons. Only ever mounted under `?debug=1`. */ function mountPanel( debug: { nudgeCondition: (by: number) => unknown; nudgeFunds: (by: number) => number }, api: DebugApi, ): void { const panel = document.createElement('div'); panel.id = 'debug-panel'; panel.style.cssText = ` position: fixed; right: 12px; bottom: 12px; z-index: 10; display: flex; gap: 6px; align-items: center; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; color: #cfd6dd; background: rgba(12,15,19,.9); border: 1px solid #2f3944; border-radius: 4px; padding: 8px 10px; `; const reading = (width: number) => { const span = document.createElement('span'); span.style.cssText = `min-width: ${width}px; color: #8fa3b5;`; return span; }; const carReadout = reading(74); const fundsReadout = reading(66); const refresh = () => { const { engine, tires, chassis } = api.condition().level; carReadout.textContent = `car ${(((engine + tires + chassis) / 3) * 100).toFixed(0)}%`; fundsReadout.textContent = `${CURRENCY}${Math.round(api.funds())}`; }; const button = (label: string, apply: () => void) => { const element = document.createElement('button'); element.textContent = label; element.style.cssText = ` font: inherit; color: inherit; cursor: pointer; background: #1b222b; border: 1px solid #37414d; border-radius: 3px; padding: 3px 9px; `; element.addEventListener('click', () => { apply(); refresh(); // Keep the keyboard on the game, or space starts pressing this button. element.blur(); }); panel.append(element); }; panel.append(document.createTextNode('debug')); button('−', () => debug.nudgeCondition(-0.15)); button('+', () => debug.nudgeCondition(+0.15)); panel.append(carReadout); // Money, so the workshop and the board can be exercised without first // driving eight missions to afford looking at them. button('−¤', () => debug.nudgeFunds(-50)); button('+¤', () => debug.nudgeFunds(+50)); panel.append(fundsReadout); document.body.append(panel); refresh(); setInterval(refresh, 500); } 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, /** 0 is a wreck, 1 is factory fresh. */ setCondition: api.setCondition, /** Nudge condition up or down, for feeling out how decline drives. */ nudgeCondition(by: number) { const now = api.condition().level; return api.setCondition((now.engine + now.tires + now.chassis) / 3 + by); }, /** Money in hand. */ funds: api.funds, setFunds: api.setFunds, nudgeFunds(by: number) { return api.setFunds(api.funds() + by); }, info: api.info, state: api.state, three: THREE, api, }; (window as unknown as { __dbtl: typeof debug }).__dbtl = debug; mountPanel(debug, api); console.info('debug handle ready: window.__dbtl'); }