// Lane B — standalone player/physics demo. Runs with ZERO code from other // lanes: everything the controller consumes (IVoxelWorld, KinematicColliders) // is mocked here and clearly marked `// DEMO MOCK — not for integration`. import * as THREE from 'three'; import { PlayerController } from '../player'; import { FIXED_DT, PLATTER } from '../core/constants'; import { blockDef } from '../core/blocks'; import type { BlockId } from '../core/blocks'; import type { IVoxelWorld, KinematicCollider } from '../core/types'; import { bus } from '../core/events'; // --------------------------------------------------------------------------- // DEMO MOCK — not for integration: a hand-shaped voxel world. // floor y=0 across 160×160, with: // - a 1-voxel staircase (auto-step should work) // - a 2-voxel wall (auto-step must NOT work) // - a pit with a 2-wide plywood bridge // - a second pit spanned only by the moving platform // --------------------------------------------------------------------------- const SIZE = 160; function shapeSolid(x: number, y: number, z: number): boolean { if (y < 0) return true; // sentinel floor if (x < 0 || x >= SIZE || z < 0 || z >= SIZE) return false; const inPit1 = x >= 44 && x <= 63 && z >= 8 && z <= 27; const bridge1 = x >= 53 && x <= 54; // 2-wide walkway across pit 1 const inPit2 = x >= 40 && x <= 80 && z >= 110 && z <= 140; if (y === 0) { if (inPit1 && !bridge1) return false; if (inPit2) return false; return true; } if (x >= 10 && x <= 15 && z >= 10 && z <= 19) return y >= 1 && y <= x - 9; // staircase if (x === 30 && z >= 8 && z <= 40 && (y === 1 || y === 2)) return true; // 2-voxel wall return false; } function shapeBlock(x: number, y: number, z: number): BlockId { if (!shapeSolid(x, y, z)) return 0; if (y === 0) return 1; // plywood floor if (x === 30) return 5; // matte_black wall return 4; // steel_grey stairs } class MockWorld implements IVoxelWorld { readonly sizeX = SIZE; readonly sizeY = 64; readonly sizeZ = SIZE; private overlay = new Map(); private key(x: number, y: number, z: number) { return (x * 512 + y) * 512 + z; } getBlock(x: number, y: number, z: number): BlockId { const o = this.overlay.get(this.key(x, y, z)); return o !== undefined ? o : shapeBlock(x, y, z); } setBlock(x: number, y: number, z: number, id: BlockId): void { this.overlay.set(this.key(x, y, z), id); } isSolid(x: number, y: number, z: number): boolean { const o = this.overlay.get(this.key(x, y, z)); if (o !== undefined) return blockDef(o).solid; return shapeSolid(x, y, z); } } const world = new MockWorld(); // --------------------------------------------------------------------------- // Three.js scene. // --------------------------------------------------------------------------- const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(2, window.devicePixelRatio)); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0a0c); scene.fog = new THREE.Fog(0x0a0a0c, 60, 220); const camera = new THREE.PerspectiveCamera(72, window.innerWidth / window.innerHeight, 0.05, 500); scene.add(new THREE.HemisphereLight(0xbfd0ff, 0x2a2418, 0.9)); const key = new THREE.DirectionalLight(0xfff2d8, 1.1); key.position.set(60, 120, 30); scene.add(key); // Instanced voxels (built from the same shapeSolid the physics uses). function buildVoxels(): void { const cells: number[] = []; for (let x = 0; x < SIZE; x++) for (let y = 0; y <= 7; y++) for (let z = 0; z < SIZE; z++) if (shapeSolid(x, y, z)) cells.push(x, y, z); const count = cells.length / 3; const geo = new THREE.BoxGeometry(1, 1, 1); const mat = new THREE.MeshStandardMaterial({ roughness: 0.95, metalness: 0.0 }); const mesh = new THREE.InstancedMesh(geo, mat, count); const dummy = new THREE.Object3D(); const color = new THREE.Color(); for (let i = 0; i < count; i++) { const x = cells[i * 3], y = cells[i * 3 + 1], z = cells[i * 3 + 2]; dummy.position.set(x + 0.5, y + 0.5, z + 0.5); dummy.updateMatrix(); mesh.setMatrixAt(i, dummy.matrix); const t = blockDef(shapeBlock(x, y, z)).tint; color.setRGB(t[0] / 255, t[1] / 255, t[2] / 255, THREE.SRGBColorSpace); mesh.setColorAt(i, color); } mesh.instanceMatrix.needsUpdate = true; if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; scene.add(mesh); } buildVoxels(); // --------------------------------------------------------------------------- // DEMO MOCK — not for integration: two kinematic colliders. // --------------------------------------------------------------------------- // (1) Spinning disc (the "record"). Top surface flush at y = 1 so you can walk // straight on from the floor. velocityAt matches the mesh's +Y rotation. const DISC_CENTER: [number, number, number] = [110, 0.75, 45]; const DISC_RADIUS = PLATTER.radius; // 41 let discRpm: number = PLATTER.rpm33; let discAngle = 0; const disc: KinematicCollider = { id: 'disc', shape: { kind: 'cylinder', center: DISC_CENTER, radius: DISC_RADIUS, halfHeight: 0.25 }, velocityAt(x, _y, z) { const w = (discRpm * Math.PI * 2) / 60; return [w * (z - DISC_CENTER[2]), 0, -w * (x - DISC_CENTER[0])]; }, }; const discMesh = new THREE.Group(); // Visual only: lift the mesh a hair so it doesn't z-fight the coplanar floor. // The collider top stays at y=1 (halfHeight 0.25 about center 0.75). discMesh.position.set(DISC_CENTER[0], DISC_CENTER[1] + 0.12, DISC_CENTER[2]); { const platter = new THREE.Mesh( new THREE.CylinderGeometry(DISC_RADIUS, DISC_RADIUS, 0.5, 64), new THREE.MeshStandardMaterial({ color: 0x14141a, roughness: 0.6, metalness: 0.2 }), ); discMesh.add(platter); const label = new THREE.Mesh( new THREE.CylinderGeometry(8, 8, 0.52, 32), new THREE.MeshStandardMaterial({ color: 0xe8e0c8, roughness: 0.9 }), ); discMesh.add(label); // A radial stripe + rim strobe dot so the spin is obvious. const stripe = new THREE.Mesh( new THREE.BoxGeometry(DISC_RADIUS, 0.53, 1.2), new THREE.MeshStandardMaterial({ color: 0x4060ff, emissive: 0x2038a0, roughness: 0.5 }), ); stripe.position.set(DISC_RADIUS / 2, 0, 0); discMesh.add(stripe); const dot = new THREE.Mesh( new THREE.CylinderGeometry(1.4, 1.4, 0.55, 16), new THREE.MeshStandardMaterial({ color: 0xff3c32, emissive: 0xff3c32, emissiveIntensity: 1.5 }), ); dot.position.set(DISC_RADIUS - 3, 0, 0); discMesh.add(dot); } scene.add(discMesh); // (2) Oscillating platform across pit 2. const PLAT_BASE_X = 60, PLAT_AMP = 20, PLAT_W = 0.6, PLAT_HALF = 8; let platVX = 0; const platShape = { kind: 'aabb' as const, min: [PLAT_BASE_X - PLAT_HALF, 0, 106] as [number, number, number], max: [PLAT_BASE_X + PLAT_HALF, 1, 144] as [number, number, number], }; const platform: KinematicCollider = { id: 'platform', shape: platShape, velocityAt() { return [platVX, 0, 0]; }, }; const platMesh = new THREE.Mesh( new THREE.BoxGeometry(PLAT_HALF * 2, 1, 38), new THREE.MeshStandardMaterial({ color: 0xbc763e, roughness: 0.7, metalness: 0.3 }), ); platMesh.position.set(PLAT_BASE_X, 0.56, 125); // visual lift; collider top stays y=1 scene.add(platMesh); const colliders = [disc, platform]; // --------------------------------------------------------------------------- // Player. // --------------------------------------------------------------------------- const player = new PlayerController(world, { getColliders: () => colliders, camera, domElement: renderer.domElement, spawn: [20, 1, 5], }); bus.on('player:step', (e) => console.log('player:step', blockDef(e.block).name)); bus.on('player:landed', (e) => console.log('player:landed impactSpeed=', e.impactSpeed.toFixed(2))); // DEMO MOCK — not for integration: debug handle so headless checks can drive the // input state and read player state without pointer-lock mouse input. (window as { __demo?: unknown }).__demo = { player, world, colliders, setRpm: (r: number) => { discRpm = r; }, PLATTER, }; // --------------------------------------------------------------------------- // UI. // --------------------------------------------------------------------------- const $ = (id: string) => document.getElementById(id)!; const hud = $('hud'); const spinBtn = $('spin'), tpBtn = $('tp'), flyBtn = $('fly'), bobBtn = $('bob'); spinBtn.addEventListener('click', () => { discRpm = discRpm === PLATTER.rpm33 ? PLATTER.rpm45 : PLATTER.rpm33; spinBtn.textContent = `Speed: ${discRpm === PLATTER.rpm45 ? '45' : '33'}`; }); tpBtn.addEventListener('click', () => player.teleport([DISC_CENTER[0], 1.0, DISC_CENTER[2]])); flyBtn.addEventListener('click', () => { player.setFlying(!player.isFlying); flyBtn.textContent = `Fly: ${player.isFlying ? 'on' : 'off'}`; }); bobBtn.addEventListener('click', () => { player.viewBob = !player.viewBob; bobBtn.textContent = `View-bob: ${player.viewBob ? 'on' : 'off'}`; }); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); // --------------------------------------------------------------------------- // Fixed-step loop: advance colliders, then the player, then render. // --------------------------------------------------------------------------- let simTime = 0; let acc = 0; let last = performance.now(); function stepColliders(): void { simTime += FIXED_DT; discAngle += ((discRpm * Math.PI * 2) / 60) * FIXED_DT; discMesh.rotation.y = discAngle; const cx = PLAT_BASE_X + PLAT_AMP * Math.sin(simTime * PLAT_W); platVX = PLAT_AMP * PLAT_W * Math.cos(simTime * PLAT_W); platShape.min[0] = cx - PLAT_HALF; platShape.max[0] = cx + PLAT_HALF; platMesh.position.x = cx; } function frame(now: number): void { const dt = Math.min(0.1, (now - last) / 1000); last = now; acc += dt; let guard = 0; while (acc >= FIXED_DT && guard++ < 8) { stepColliders(); player.update(FIXED_DT); acc -= FIXED_DT; } const p = player.position, cv = player.carryVelocity; const cvMag = Math.hypot(cv[0], cv[2]); hud.textContent = `pos ${p[0].toFixed(1)}, ${p[1].toFixed(1)}, ${p[2].toFixed(1)}\n` + `onGround ${player.onGround}\n` + `groundedOn ${player.groundedOn ?? '—'}\n` + `carryVel ${cv[0].toFixed(2)}, ${cv[2].toFixed(2)} (|${cvMag.toFixed(2)}|)\n` + `flying ${player.isFlying}`; renderer.render(scene, camera); requestAnimationFrame(frame); } requestAnimationFrame(frame);