"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>
205 lines
7.3 KiB
TypeScript
205 lines
7.3 KiB
TypeScript
/**
|
||
* 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');
|
||
}
|