The yard's meshes are placeholders; its layout is not. Anchor positions, ground heights, the garden rect and the sun direction are what the other lanes build against, so Lane E's GLBs drop into named slots without anyone re-deriving them. Sun sits in the north (Australian yard), which puts the house's yard-facing wall in permanent backlight. That's correct, but the fascia line carries three of the seven anchors, so sky fill is turned up to keep it readable rather than a void. Camera collision resolves toward the geometry, not away from it: clamping up to a comfort distance after the raycast is what put the camera 17cm inside the north wall. Ground is handled by heightAt() instead of a raycast — exact, can't be tunnelled, and free. main.js runs a fixed-dt accumulator so sim modules only ever see FIXED_DT. That is what lets selftest fast-forward a 90s storm and get the numbers the player got. Nothing auto-runs on import; index.html calls boot(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
7.9 KiB
JavaScript
240 lines
7.9 KiB
JavaScript
/**
|
|
* SHADES — boot, game loop, phase machine. Lane A owns this file.
|
|
*
|
|
* Nothing is auto-run on import: index.html calls boot(). That keeps createGame()
|
|
* importable from selftest.html, which must never construct a WebGLRenderer.
|
|
*
|
|
* The loop is a fixed-dt accumulator. Sim modules only ever see FIXED_DT, never
|
|
* a real frame delta — that is the whole reason selftest can fast-forward a 90 s
|
|
* storm in a few milliseconds and get the same numbers the player got.
|
|
*/
|
|
|
|
import * as THREE from '../vendor/three.module.js';
|
|
import { FIXED_DT, PHASES, STORM_LEN, YARD, Emitter, createStubWind } from './contracts.js';
|
|
import { createWorld, heightAt } from './world.js';
|
|
import { createCameraRig } from './camera.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase machine
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* forecast → prep → storm → aftermath → forecast.
|
|
* @returns {import('./contracts.js').Game}
|
|
*/
|
|
export function createGame() {
|
|
const emitter = new Emitter();
|
|
let phase = 'forecast';
|
|
let phaseT = 0;
|
|
|
|
return {
|
|
get phase() { return phase; },
|
|
/** Seconds since this phase began. The storm clock. */
|
|
get phaseT() { return phaseT; },
|
|
|
|
on(type, fn) { return emitter.on(type, fn); },
|
|
|
|
/** @param {'forecast'|'prep'|'storm'|'aftermath'} next */
|
|
setPhase(next) {
|
|
if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`);
|
|
if (next === phase) return;
|
|
const from = phase;
|
|
phase = next;
|
|
phaseT = 0;
|
|
emitter.emit('phaseChange', { from, to: next });
|
|
},
|
|
|
|
/** Advance to the next phase in loop order. */
|
|
advance() {
|
|
this.setPhase(PHASES[(PHASES.indexOf(phase) + 1) % PHASES.length]);
|
|
},
|
|
|
|
tick(dt) {
|
|
phaseT += dt;
|
|
// The storm is on a timer; every other phase waits for the player.
|
|
if (phase === 'storm' && phaseT >= STORM_LEN) this.setPhase('aftermath');
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// M0 placeholder player
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A 1.7 m capsule that walks. This exists ONLY so the camera has something to
|
|
* follow and the yard scale is legible before Lane D lands.
|
|
*
|
|
* Lane D: replace the call site in boot() with your player.js factory — the
|
|
* shape you need to satisfy is `Player` in contracts.js ({pos, carrying, busy,
|
|
* update}) — then delete this function. Everything else in this file already
|
|
* talks to you through that contract, so nothing else should need to change.
|
|
*/
|
|
function createPlaceholderPlayer(scene, world, cameraRig) {
|
|
const WALK = 2.2, RUN = 4.5; // m/s
|
|
|
|
const mesh = new THREE.Mesh(
|
|
new THREE.CapsuleGeometry(0.28, 1.14, 4, 12),
|
|
new THREE.MeshStandardMaterial({ color: 0xffd27a, roughness: 0.7 }),
|
|
);
|
|
mesh.name = 'player_placeholder';
|
|
mesh.castShadow = true;
|
|
scene.add(mesh);
|
|
|
|
const keys = new Set();
|
|
const onDown = (e) => {
|
|
keys.add(e.key.toLowerCase());
|
|
if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(e.key.toLowerCase())) e.preventDefault();
|
|
};
|
|
const onUp = (e) => keys.delete(e.key.toLowerCase());
|
|
addEventListener('keydown', onDown);
|
|
addEventListener('keyup', onUp);
|
|
|
|
const pos = new THREE.Vector3(0, heightAt(0, 6), 6);
|
|
const move = new THREE.Vector3();
|
|
const fwd = new THREE.Vector3();
|
|
const right = new THREE.Vector3();
|
|
let facing = 0;
|
|
|
|
const hx = YARD.width / 2 - 0.5, hz = YARD.depth / 2 - 0.5;
|
|
|
|
return {
|
|
pos,
|
|
carrying: null,
|
|
busy: false,
|
|
mesh,
|
|
|
|
update(dt) {
|
|
const yaw = cameraRig.yaw;
|
|
// Camera-relative: forward is where the camera is looking, flattened.
|
|
fwd.set(-Math.sin(yaw), 0, -Math.cos(yaw));
|
|
right.set(Math.cos(yaw), 0, -Math.sin(yaw));
|
|
|
|
move.set(0, 0, 0);
|
|
if (keys.has('w') || keys.has('arrowup')) move.add(fwd);
|
|
if (keys.has('s') || keys.has('arrowdown')) move.sub(fwd);
|
|
if (keys.has('d') || keys.has('arrowright')) move.add(right);
|
|
if (keys.has('a') || keys.has('arrowleft')) move.sub(right);
|
|
|
|
if (move.lengthSq() > 0) {
|
|
move.normalize().multiplyScalar((keys.has('shift') ? RUN : WALK) * dt);
|
|
pos.x = Math.max(-hx, Math.min(hx, pos.x + move.x));
|
|
pos.z = Math.max(-hz, Math.min(hz, pos.z + move.z));
|
|
facing = Math.atan2(move.x, move.z);
|
|
}
|
|
pos.y = world.heightAt(pos.x, pos.z);
|
|
|
|
mesh.position.set(pos.x, pos.y + 0.85, pos.z); // capsule centre
|
|
mesh.rotation.y = facing;
|
|
},
|
|
|
|
dispose() {
|
|
removeEventListener('keydown', onDown);
|
|
removeEventListener('keyup', onUp);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Boot
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @param {object} [opts]
|
|
* @param {HTMLCanvasElement} [opts.canvas]
|
|
*/
|
|
export function boot(opts = {}) {
|
|
const canvas = opts.canvas ?? document.getElementById('c');
|
|
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.0;
|
|
|
|
const scene = new THREE.Scene();
|
|
|
|
// Lane C: swap createStubWind() for your createWeather(). Everything that
|
|
// moves reads this one object, so that swap is the whole integration.
|
|
const wind = createStubWind({ calm: true });
|
|
|
|
const world = createWorld(scene, { wind });
|
|
const cameraRig = createCameraRig(canvas);
|
|
cameraRig.setSolids(world.solids);
|
|
cameraRig.setGround(world.heightAt);
|
|
|
|
const player = createPlaceholderPlayer(scene, world, cameraRig);
|
|
const game = createGame();
|
|
|
|
// --- dev overlay (temporary — Lane A's hud.js replaces it after M0) ----
|
|
const hud = document.getElementById('dev');
|
|
const banner = document.getElementById('banner');
|
|
game.on('phaseChange', ({ to }) => {
|
|
if (banner) {
|
|
banner.textContent = to.toUpperCase();
|
|
banner.style.opacity = '1';
|
|
setTimeout(() => { banner.style.opacity = '0'; }, 1400);
|
|
}
|
|
});
|
|
addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') game.advance();
|
|
});
|
|
|
|
// --- resize ------------------------------------------------------------
|
|
function resize() {
|
|
const w = canvas.clientWidth || innerWidth;
|
|
const h = canvas.clientHeight || innerHeight;
|
|
renderer.setSize(w, h, false);
|
|
cameraRig.resize(w, h);
|
|
}
|
|
addEventListener('resize', resize);
|
|
resize();
|
|
|
|
// --- loop --------------------------------------------------------------
|
|
const clock = new THREE.Clock();
|
|
let acc = 0;
|
|
let simT = 0;
|
|
let frames = 0, fpsT = 0, fps = 0;
|
|
|
|
function step(dt, t) {
|
|
game.tick(dt);
|
|
world.update(dt, t);
|
|
player.update(dt, t);
|
|
// Lane B: sailRig.step(dt, wind, t) goes here.
|
|
// Lane C: debris.step(dt, wind, t) goes here.
|
|
}
|
|
|
|
function frame() {
|
|
// Clamped so a background tab or a breakpoint doesn't make the sim try to
|
|
// catch up over thousands of steps and lock the page.
|
|
const raw = Math.min(0.25, clock.getDelta());
|
|
acc += raw;
|
|
let guard = 0;
|
|
while (acc >= FIXED_DT && guard++ < 60) {
|
|
step(FIXED_DT, simT);
|
|
simT += FIXED_DT;
|
|
acc -= FIXED_DT;
|
|
}
|
|
|
|
cameraRig.update(raw, player.pos);
|
|
renderer.render(scene, cameraRig.object);
|
|
|
|
frames++; fpsT += raw;
|
|
if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; }
|
|
if (hud) {
|
|
const w = wind.sample(player.pos, simT);
|
|
hud.textContent =
|
|
`${fps.toFixed(0)} fps | phase ${game.phase} ${game.phaseT.toFixed(1)}s | ` +
|
|
`wind ${w.length().toFixed(1)} m/s | t ${simT.toFixed(1)}s`;
|
|
}
|
|
requestAnimationFrame(frame);
|
|
}
|
|
requestAnimationFrame(frame);
|
|
|
|
// Handy for poking at the world from the console.
|
|
const api = { renderer, scene, world, cameraRig, player, game, wind, get simT() { return simT; } };
|
|
globalThis.SHADES = api;
|
|
return api;
|
|
}
|