drive-between-the-lines/src/debug.ts
dejvino 6c1132b133 Parts become money, and the debug panel can hand you some
"0.34 parts" read as a fraction of something unnamed. It is a payment,
so it now looks like one: ¤34. The symbol is Unicode's generic currency
sign - it looks like money and is nobody's actual money, which is right
for a place whose banks are somebody else's problem.

The scale moves with it. FUNDS_PER_CONDITION fixes the exchange rate in
one place, so every balance number stays in the unit it is actually
about and how large the currency reads stays a presentational decision.
A supply run pays ¤34, an overhaul runs ¤200 to undo a crash's
permanent cost, and a full rebuild after a bad one is about a dozen
missions.

The debug panel gets ±¤ next to the existing condition levers, so the
workshop and the board can be exercised without first driving eight
missions to afford looking at them.

Save format bumped: quests.parts is now quests.funds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 08:06:57 +02:00

205 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, unknown>;
info: () => Record<string, unknown>;
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<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,
/** 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');
}