TURNCRAFT/src/demo/engineDemo.ts
jing 9c668d340c Headshell Workshop + Glow-Up phase: assembly minigame, art pass, review fixes
- Workshop (WORKSHOP_CARTRIDGE): five-stage cartridge assembly at Deck A —
  seat/square, crimp four tag-wires, torque screws, ride-the-arm counterweight
  balance, needle-drop diagnostic with per-fault audio + scope. Relay-synced
  (per-field co-op merge: held screw + carried wire survive remote state).
- Glow-Up (G1-G5): 32px atlas with per-voxel variants, selective LED bloom
  (quality-gated), screen-print decal system + party flyers, mixer/PCB worldgen
  density pass, record groove-sheen side texture.
- 10 confirmed multi-agent review fixes, incl. co-op screw-stomp soft-lock,
  ride-snap collider-identity (magnet feet / eaten record-fling), workshop SFX
  exact-match map (rca_seated hijack), beam ride colliders to the head,
  double-crimp guard, WIRING INCOMPLETE diagnosis mode, skate-abort timer,
  completedState wiring, per-tick material churn, trackingHeavy platter drag.
- Crossfader playtest fix: slew-limited sled (3.4 v/s) + ribbed grip caps with
  amber index — the sled reads as a heavy handle, not a teleporting wall.
- Demo harnesses: machinesDemo hold-key wiring, playerDemo seesaw phase
  continuity, audioDemo incomplete fault button.
- Workshop sync: exact 1.0 screw endpoint gets its own emit signature.

Verified: typecheck + vite build clean, live solo quest smoke, two-client
co-op relay smoke (simultaneous torque, no rewind, exact convergence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:32:07 +10:00

240 lines
9.2 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.

// TURNCRAFT — Lane A standalone demo. Run: `npm run dev` → open /demo-engine.html
//
// WHAT TO LOOK AT / PRESS (maps to the acceptance criteria in LANE_A_ENGINE.md):
// • Full-size (448×160×256) empty world + a test pattern near the origin renders
// at 60 fps with few draw calls — see the HUD (top-left): FPS, draw calls,
// triangles, meshes. Draw calls stay well under 300.
// • AO: orbit into the hollow steel CAVE (left) — inner corners visibly darken;
// no cracks or z-fighting where chunks meet.
// • Transparency: the GLASS ARCH and the BLUE VINYL wall (in front of a cream
// wall) render in a correct transparent pass over the opaque blocks behind.
// • All 10 patterns + every block id: the labeled PILLAR ROW — one stepped
// pillar per block id, each with a floating "id name" label.
// • Emissive: the LED WALL. Drag the "emissive boost" slider (0→3) — LED/strobe
// glow scales; nothing else changes.
// • Dirty remeshing: press "TORTURE" — ~500 random block edits/sec in the flicker
// cube; FPS stays ≳55 and edits appear within ~12 frames.
//
// Uses ONLY Lane A code + core. OrbitControls is a demo convenience (not shipped
// in the engine API).
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { WORLD_X, WORLD_Y, WORLD_Z } from '../core/constants';
import { BLOCKS, BLOCK_BY_NAME } from '../core/blocks';
import { createRenderer } from '../engine/renderer';
import { VoxelWorld } from '../engine/VoxelWorld';
import { ChunkManager } from '../engine/ChunkManager';
const B = (name: string) => BLOCK_BY_NAME.get(name)!.id;
const app = document.getElementById('app')!;
const { renderer, scene, camera, atlas, setEmissiveBoost, setBloom, render } = createRenderer(app);
let hud: HTMLDivElement; // populated by buildHud(), read by updateHud()
const world = new VoxelWorld(WORLD_X, WORLD_Y, WORLD_Z);
buildTestPattern(world);
const chunks = new ChunkManager(world, scene, atlas, 4);
const buildT0 = performance.now();
chunks.buildAll();
const buildMs = performance.now() - buildT0;
// Camera / controls framed on the test pattern.
camera.position.set(34, 46, 118);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(33, 8, 33);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.update();
// DEBUG: dev-only hook so a camera can be flown to a viewpoint for inspection.
(window as unknown as { __demo?: unknown }).__demo = {
scene, atlas, camera, controls, setBloom, setEmissiveBoost,
look(px: number, py: number, pz: number, tx: number, ty: number, tz: number) {
camera.position.set(px, py, pz);
controls.target.set(tx, ty, tz);
controls.update();
render();
},
render,
};
// Floating labels over each pillar.
addPillarLabels(scene);
buildHud();
// --- main loop ---
let last = performance.now();
let fps = 60, minFpsTorture = 999;
let torturing = false;
function frame() {
const t = performance.now();
const dt = Math.min(0.1, (t - last) / 1000);
last = t;
fps = fps * 0.9 + (1 / Math.max(1e-3, dt)) * 0.1;
if (torturing) {
torture(dt);
if (fps < minFpsTorture) minFpsTorture = fps;
}
controls.update();
chunks.update();
render();
updateHud(dt);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// ---------------------------------------------------------------------------
// Test pattern
// ---------------------------------------------------------------------------
function buildTestPattern(w: VoxelWorld): void {
// 64×64 plywood floor at y=0.
w.fillBox(2, 0, 2, 65, 0, 65, B('plywood'));
// Labeled pillar row: one stepped pillar per block id (skip air id 0).
for (const def of BLOCKS) {
if (def.id === 0) continue;
const x = 3 + def.id * 2;
const h = 2 + (def.id % 7);
w.fillBox(x, 1, 6, x, h, 6, def.id);
}
// Glass arch (transparent portal) — see through it to the pillars/floor.
w.fillBox(10, 1, 40, 11, 8, 41, B('glass'));
w.fillBox(20, 1, 40, 21, 8, 41, B('glass'));
w.fillBox(10, 8, 40, 21, 9, 41, B('glass'));
// Blue translucent vinyl wall in front of a cream label wall.
w.fillBox(30, 1, 46, 40, 6, 46, B('label_cream'));
w.fillBox(30, 1, 44, 40, 6, 44, B('vinyl_blue'));
// LED wall — mixed colours + strobe dots (emissive slider test).
const leds = [B('led_red'), B('led_green'), B('led_amber'), B('led_blue')];
for (let x = 48; x <= 58; x++)
for (let y = 1; y <= 8; y++) {
const id = (x + y) % 5 === 0 ? B('strobe_dot') : leds[x % 4];
w.setBlock(x, y, 40, id);
}
// Hollow steel cave — AO test on interior corners, with an entrance.
w.fillBox(2, 1, 50, 20, 12, 64, B('steel_grey'));
w.fillBox(4, 2, 52, 18, 11, 63, 0); // hollow it
w.fillBox(9, 1, 50, 13, 6, 50, 0); // doorway on the front face
}
// ---------------------------------------------------------------------------
// Torture: ~500 random edits/sec in a flicker cube (dirty-remesh stress).
// ---------------------------------------------------------------------------
const TZ = { x0: 4, x1: 28, y0: 1, y1: 24, z0: 20, z1: 44 };
const TORTURE_BLOCKS = [0, B('steel_grey'), B('vinyl_blue'), B('led_green'), B('brushed_alu')];
let editCarry = 0;
function torture(dt: number): void {
editCarry += 500 * dt;
const n = editCarry | 0;
editCarry -= n;
for (let i = 0; i < n; i++) {
const x = TZ.x0 + ((Math.random() * (TZ.x1 - TZ.x0 + 1)) | 0);
const y = TZ.y0 + ((Math.random() * (TZ.y1 - TZ.y0 + 1)) | 0);
const z = TZ.z0 + ((Math.random() * (TZ.z1 - TZ.z0 + 1)) | 0);
world.setBlock(x, y, z, TORTURE_BLOCKS[(Math.random() * TORTURE_BLOCKS.length) | 0]);
}
}
// ---------------------------------------------------------------------------
// Labels + HUD
// ---------------------------------------------------------------------------
function addPillarLabels(sc: THREE.Scene): void {
for (const def of BLOCKS) {
if (def.id === 0) continue;
const x = 3 + def.id * 2;
const h = 2 + (def.id % 7);
const sprite = makeTextSprite(`${def.id} ${def.name}`);
sprite.position.set(x + 0.5, h + 2.2, 6.5);
sc.add(sprite);
}
}
function makeTextSprite(text: string): THREE.Sprite {
const pad = 6, fs = 22;
const c = document.createElement('canvas');
const ctx = c.getContext('2d')!;
ctx.font = `${fs}px monospace`;
const w = Math.ceil(ctx.measureText(text).width) + pad * 2;
const hgt = fs + pad * 2;
c.width = w; c.height = hgt;
ctx.font = `${fs}px monospace`;
ctx.fillStyle = 'rgba(10,10,12,0.72)';
ctx.fillRect(0, 0, w, hgt);
ctx.fillStyle = '#e8e6df';
ctx.textBaseline = 'middle';
ctx.fillText(text, pad, hgt / 2);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
const mat = new THREE.SpriteMaterial({ map: tex, depthTest: true, transparent: true });
const s = new THREE.Sprite(mat);
s.scale.set((w / hgt) * 2.2, 2.2, 1);
return s;
}
function buildHud(): void {
hud = document.createElement('div');
hud.style.cssText =
'position:fixed;top:8px;left:8px;font:12px/1.5 monospace;color:#d8d6cf;' +
'background:rgba(10,9,8,0.72);padding:10px 12px;border-radius:6px;white-space:pre;' +
'user-select:none;pointer-events:none;z-index:10';
document.body.appendChild(hud);
const panel = document.createElement('div');
panel.style.cssText =
'position:fixed;top:8px;right:8px;font:12px monospace;color:#d8d6cf;' +
'background:rgba(10,9,8,0.8);padding:10px 12px;border-radius:6px;z-index:10;' +
'display:flex;flex-direction:column;gap:8px;align-items:stretch';
const btn = document.createElement('button');
btn.textContent = 'TORTURE: off';
btn.style.cssText = 'font:12px monospace;padding:6px 10px;cursor:pointer';
btn.onclick = () => {
torturing = !torturing;
if (torturing) minFpsTorture = 999;
btn.textContent = torturing ? 'TORTURE: ON (500 edits/s)' : 'TORTURE: off';
};
const label = document.createElement('label');
label.style.cssText = 'display:flex;flex-direction:column;gap:2px';
label.textContent = 'emissive boost';
const slider = document.createElement('input');
slider.type = 'range'; slider.min = '0'; slider.max = '3'; slider.step = '0.05'; slider.value = '1';
const val = document.createElement('span');
val.textContent = '1.00';
slider.oninput = () => { setEmissiveBoost(+slider.value); val.textContent = (+slider.value).toFixed(2); };
label.appendChild(slider);
label.appendChild(val);
panel.appendChild(btn);
panel.appendChild(label);
document.body.appendChild(panel);
}
let hudAccum = 0;
function updateHud(dt: number): void {
hudAccum += dt;
if (hudAccum < 0.2) return; // refresh ~5×/s
hudAccum = 0;
const calls = renderer.info.render.calls;
const tris = renderer.info.render.triangles;
hud.textContent =
`TURNCRAFT — Lane A engine demo\n` +
`world ${WORLD_X}×${WORLD_Y}×${WORLD_Z} (initial build ${buildMs.toFixed(0)} ms)\n` +
`fps ${fps.toFixed(0)}${torturing ? ` (min under torture ${minFpsTorture.toFixed(0)})` : ''}\n` +
`draw calls ${calls}\n` +
`triangles ${tris.toLocaleString()}\n` +
`meshes ${chunks.meshCount}\n` +
`dirty ${world.dirtyCount} remesh ${chunks.lastRemeshMs.toFixed(2)} ms / ${chunks.lastRemeshCount} chunk(s)`;
}