- 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>
240 lines
9.2 KiB
TypeScript
240 lines
9.2 KiB
TypeScript
// 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 ~1–2 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)`;
|
||
}
|