TURNCRAFT/src/demo/playerDemo.ts
jing 5a39e3a947 TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
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>
2026-07-13 20:50:56 +10:00

279 lines
11 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.

// 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);