Lanes A (engine), B (player), C (worldgen), D (machines/quest), E (audio/fx/ui) as landed, each with HANDOFF.md + update docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
// 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<number, BlockId>();
|
||
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);
|