Assemble the game: real wind, player, sail, sky and debris (gate 1)
SPRINT2 §Lane A steps 1-4. The placeholder capsule and stub wind are gone; every lane's proven module now runs in one yard. Wind goes through a router. Every consumer binds to `wind` exactly once at construction — the yard closes over it for tree sway, createPlayer takes it in opts, createDebris reads its event stream — so swapping storm_01 for storm_02 at the phase change has to be a re-point, not a re-wire, or half the game would still be sampling a calm day while the other half is in a gale. Shelters apply to every storm, since the trees don't stop existing when the weather turns. skyfx is rebuilt rather than re-pointed: it reads the storm's sky block at construction. Verified its dispose() hands world.sun/world.hemi back exactly (2.0/1.8 after a 40 s storm had them at 1.07/1.13) and that nothing compounds over repeated phase cycles — that was Lane C's §Lane C.5 ask. Two seams needed care. createSailView reads rig.pos/rig.tris, which don't exist until attach() allocates them, and a re-rig can change the grid — so rigSail() is the one door both boot and Lane B's picking adapter come through, rebuilding the view and re-wiring interact (whose targets close over a corners array that attach() replaces). And knockdown(t, dirX, dirZ) takes the sim clock first, not the impact: passing debris impact there would jam ~40 into the state machine's start time and the player would never get up. Storm_02 verified end to end by hand: the carabiner blows at t=45.4 s, and p2's shackle cascades at t=56 s — one second after the southerly change. Coverage over the bed is 1.0 intact and 0 once two corners are gone, which is the whole game in one number. 0.63 ms/frame (0.17 sim + 0.45 render) against a 16.67 ms budget. Selftest 121/0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7f3ef69685
commit
323352fe50
@ -4,24 +4,8 @@
|
||||
{
|
||||
"name": "shades3d",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": [
|
||||
"server.py",
|
||||
"--port",
|
||||
"8809"
|
||||
],
|
||||
"port": 8809
|
||||
},
|
||||
{
|
||||
"name": "shades-proto",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": [
|
||||
"-m",
|
||||
"http.server",
|
||||
"8642",
|
||||
"--directory",
|
||||
"prototype"
|
||||
],
|
||||
"port": 8642
|
||||
"runtimeArgs": ["server.py", "--port", "8811"],
|
||||
"port": 8811
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -36,7 +36,13 @@
|
||||
</script>
|
||||
<script type="module">
|
||||
import { boot } from './js/main.js';
|
||||
boot();
|
||||
// boot() is async now — it fetches two storm defs, the ped, the clip pack
|
||||
// and Lane E's debris GLBs. Surface a failure on the page rather than
|
||||
// letting it die as an unhandled rejection behind a blue screen.
|
||||
boot().catch((err) => {
|
||||
console.error(err);
|
||||
document.getElementById('dev').textContent = `BOOT FAILED — ${err.message} (see console)`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,18 +1,32 @@
|
||||
/**
|
||||
* 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.
|
||||
* This is the assembly point: every other lane's module is proven in isolation,
|
||||
* and this file is where they become one game. Two rules make that possible and
|
||||
* are worth not breaking:
|
||||
*
|
||||
* 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.
|
||||
* - **Nothing auto-runs 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 milliseconds and get the 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 { FIXED_DT, PHASES, STORM_LEN, HARDWARE, Emitter } from './contracts.js';
|
||||
import { createWorld } from './world.js';
|
||||
import { createCameraRig } from './camera.js';
|
||||
import { loadStorm, createWind } from './weather.js';
|
||||
import { SailRig, createSailView } from './sail.js';
|
||||
import { createPlayer } from './player.js';
|
||||
import { Interact, wireYardActions } from './interact.js';
|
||||
import { createDebris } from './debris.js';
|
||||
import { createSkyFx } from './skyfx.js';
|
||||
|
||||
/** Which storm each phase runs under (SPRINT2 §Lane A.1). */
|
||||
const CALM_STORM = 'storm_01_gentle';
|
||||
const WILD_STORM = 'storm_02_wildnight';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase machine
|
||||
@ -58,81 +72,91 @@ export function createGame() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M0 placeholder player
|
||||
// Wind router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* One wind object whose identity never changes, delegating to whichever storm
|
||||
* is currently running.
|
||||
*
|
||||
* 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.
|
||||
* Every consumer binds to wind exactly once, at construction — the yard closes
|
||||
* over it for tree sway, createPlayer takes it in opts, createDebris reads its
|
||||
* event stream. So swapping storm_01 for storm_02 at the phase change has to be
|
||||
* a re-point, not a re-wire, or half the game would still be sampling the calm
|
||||
* day while the other half is in a gale.
|
||||
*
|
||||
* Shelters are applied to every storm rather than just the active one: they
|
||||
* describe the yard's trees, which don't stop existing when the weather turns.
|
||||
*
|
||||
* @param {object[]} all every wind this session can switch between
|
||||
*/
|
||||
function createPlaceholderPlayer(scene, world, cameraRig) {
|
||||
const WALK = 2.2, RUN = 4.5; // m/s
|
||||
function createWindRouter(all) {
|
||||
let active = all[0];
|
||||
|
||||
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 router = {
|
||||
/** The wind currently in force. Assign through use(). */
|
||||
get active() { return active; },
|
||||
use(w) { active = w; return router; },
|
||||
|
||||
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);
|
||||
sample: (pos, t, out) => active.sample(pos, t, out),
|
||||
speedAt: (pos, t) => active.speedAt(pos, t),
|
||||
gustTelegraph: (t) => active.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => active.eventsBetween(a, b),
|
||||
rainAt: (t) => active.rainAt(t),
|
||||
dirAt: (t) => active.dirAt(t),
|
||||
|
||||
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;
|
||||
setShelters(list) {
|
||||
for (const w of all) w.setShelters(list);
|
||||
return router;
|
||||
},
|
||||
setSheltersFromTrees(trees, o = {}) {
|
||||
return router.setShelters(trees.map((tr) => ({
|
||||
x: tr.pos ? tr.pos.x : tr.x,
|
||||
z: tr.pos ? tr.pos.z : tr.z,
|
||||
radius: o.radius ?? tr.radius ?? 3,
|
||||
strength: o.strength ?? 0.45,
|
||||
length: o.length ?? 14,
|
||||
})));
|
||||
},
|
||||
|
||||
dispose() {
|
||||
removeEventListener('keydown', onDown);
|
||||
removeEventListener('keyup', onUp);
|
||||
},
|
||||
get duration() { return active.duration; },
|
||||
get gusts() { return active.gusts; },
|
||||
get def() { return active.def; },
|
||||
get seed() { return active.seed; },
|
||||
get core() { return active.core; },
|
||||
};
|
||||
return router;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debris models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lane E's crates and tubs, keyed by the names storm JSON spawns and debris.js
|
||||
* has radii for. A browser can't glob a directory, so the list is explicit —
|
||||
* and it should stay matched to MODEL_SPEC in debris.js (Lane C's ask: tell them
|
||||
* rather than fighting the radii).
|
||||
*
|
||||
* Missing files are not fatal: debris.js falls back to a graybox box per piece,
|
||||
* which is exactly the degrade-quietly behaviour Lane C designed for.
|
||||
*/
|
||||
const DEBRIS_MODELS = ['BlueCrate_v2', 'BlackTub_v2', 'WhiteTub_v2', 'WoodenBin_v2'];
|
||||
|
||||
async function loadDebrisModels() {
|
||||
const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
|
||||
const loader = new GLTFLoader();
|
||||
const out = {};
|
||||
await Promise.all(DEBRIS_MODELS.map(async (name) => {
|
||||
try {
|
||||
const gltf = await loader.loadAsync(`./models/debris/${name}.glb`);
|
||||
gltf.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
|
||||
out[name] = gltf.scene;
|
||||
} catch (err) {
|
||||
console.warn(`[main] debris model ${name} unavailable, using graybox:`, err.message);
|
||||
}
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -143,7 +167,7 @@ function createPlaceholderPlayer(scene, world, cameraRig) {
|
||||
* @param {object} [opts]
|
||||
* @param {HTMLCanvasElement} [opts.canvas]
|
||||
*/
|
||||
export function boot(opts = {}) {
|
||||
export async function boot(opts = {}) {
|
||||
const canvas = opts.canvas ?? document.getElementById('c');
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
@ -155,33 +179,154 @@ export function boot(opts = {}) {
|
||||
|
||||
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 });
|
||||
// --- 1. weather ---------------------------------------------------------
|
||||
// Both storms load up front: the forecast card needs to read storm_02's shape
|
||||
// before the player has agreed to face it.
|
||||
const [calmDef, wildDef] = await Promise.all([loadStorm(CALM_STORM), loadStorm(WILD_STORM)]);
|
||||
const calmWind = createWind(calmDef);
|
||||
const wildWind = createWind(wildDef);
|
||||
const wind = createWindRouter([calmWind, wildWind]);
|
||||
|
||||
// --- world & camera -----------------------------------------------------
|
||||
const world = createWorld(scene, { wind });
|
||||
const cameraRig = createCameraRig(canvas);
|
||||
cameraRig.setSolids(world.solids);
|
||||
cameraRig.setGround(world.heightAt);
|
||||
|
||||
const player = createPlaceholderPlayer(scene, world, cameraRig);
|
||||
// Lane C: trees don't shelter anything until they're told where they are.
|
||||
wind.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
||||
|
||||
// --- 2. player ----------------------------------------------------------
|
||||
const interact = new Interact();
|
||||
const player = await createPlayer(scene, world, cameraRig, { wind, interact });
|
||||
|
||||
// --- 3. sail ------------------------------------------------------------
|
||||
const rig = new SailRig({ anchors: world.anchors });
|
||||
let sailView = null;
|
||||
|
||||
/**
|
||||
* Attach the cloth across 4 anchors and (re)build its view.
|
||||
*
|
||||
* The order here is load-bearing. createSailView reads rig.pos and rig.tris,
|
||||
* which don't exist until attach() allocates them in _build() — build the view
|
||||
* first and it throws on an undefined array. A re-rig can also change the grid,
|
||||
* so the view has to be rebuilt rather than reused. Both facts make this the
|
||||
* single door that boot and Lane B's picking adapter should come through.
|
||||
*
|
||||
* Re-wiring interact each time is deliberate: its targets close over corner
|
||||
* objects and attach() makes a fresh corners array, so stale closures would
|
||||
* point at corners the sim no longer steps. The ids are stable, so this
|
||||
* replaces the old targets rather than stacking duplicates.
|
||||
*/
|
||||
async function rigSail(anchorIds, hwChoices, tension = 1.0) {
|
||||
rig.attach(anchorIds, hwChoices, tension);
|
||||
if (sailView) {
|
||||
scene.remove(sailView);
|
||||
sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
|
||||
}
|
||||
sailView = await createSailView(rig);
|
||||
scene.add(sailView);
|
||||
wireYardActions(interact, { sailRig: rig, world });
|
||||
return sailView;
|
||||
}
|
||||
|
||||
// Until Lane B's prep-phase picking adapter lands (SPRINT2 §B.3), rig a
|
||||
// default quad so the yard has a live sail and Lane D has something to
|
||||
// repair. Deliberately the prototype's AUTO loadout — one dodgy carabiner
|
||||
// corner. It also spans most of the yard, which is the 70–192 m² problem
|
||||
// decision 2 fixes in step 6, not a fault in the cloth.
|
||||
await rigSail(['h1', 'h3', 'p2', 'p1'], [HARDWARE[2], HARDWARE[1], HARDWARE[1], HARDWARE[0]]);
|
||||
|
||||
const game = createGame();
|
||||
|
||||
// --- dev overlay (temporary — Lane A's hud.js replaces it after M0) ----
|
||||
// --- clocks -------------------------------------------------------------
|
||||
// Two of them, and the distinction matters. `simT` is wall-clock seconds since
|
||||
// boot. `windT` is STORM time — storm JSON is authored with t=0 at the storm's
|
||||
// first gust, so it's phase time during the storm, and off-storm it wraps the
|
||||
// calm day around its own duration so the breeze keeps breathing however long
|
||||
// you spend rigging. Every sim module samples windT; nothing samples simT.
|
||||
let simT = 0;
|
||||
let windT = 0;
|
||||
let acc = 0;
|
||||
|
||||
function windTime() {
|
||||
if (game.phase === 'storm') return game.phaseT;
|
||||
return simT % Math.max(1, calmWind.duration);
|
||||
}
|
||||
|
||||
// --- 4. sky, audio, debris ---------------------------------------------
|
||||
const events = [];
|
||||
const pushEvent = (text) => {
|
||||
events.push({ t: game.phaseT, text });
|
||||
if (events.length > 4) events.shift();
|
||||
};
|
||||
|
||||
const debris = createDebris({
|
||||
wind,
|
||||
scene,
|
||||
heightAt: world.heightAt,
|
||||
// knockdown(t, dirX, dirZ) — the first arg is the sim clock, NOT the impact
|
||||
// magnitude. Passing `impact` here would jam ~40 into the state machine's
|
||||
// start time and the player would never get up. The piece's own velocity is
|
||||
// the direction, so you fall the way the crate was travelling.
|
||||
onHitPlayer: (piece) => player.sim.knockdown(windT, piece.vx, piece.vz),
|
||||
onEvent: pushEvent,
|
||||
});
|
||||
debris.setModels(await loadDebrisModels());
|
||||
|
||||
// skyfx reads the storm's `sky` block at construction (darkness, cloud scroll,
|
||||
// night), so it is rebuilt when the storm changes rather than re-pointed like
|
||||
// wind. dispose() hands world.sun/world.hemi back exactly as they were, which
|
||||
// is what makes that safe to do mid-session.
|
||||
let sky = null;
|
||||
let audioUnlocked = false;
|
||||
function makeSky() {
|
||||
if (sky) sky.dispose();
|
||||
sky = createSkyFx({
|
||||
scene,
|
||||
camera: cameraRig.object,
|
||||
wind,
|
||||
sun: world.sun,
|
||||
hemi: world.hemi,
|
||||
onEvent: pushEvent,
|
||||
});
|
||||
if (audioUnlocked) sky.unlockAudio();
|
||||
return sky;
|
||||
}
|
||||
makeSky();
|
||||
|
||||
// Browsers won't start an AudioContext without a gesture. Without this the
|
||||
// storm is silent, and half of DESIGN.md's threat model is audible.
|
||||
const unlock = () => {
|
||||
if (audioUnlocked) return;
|
||||
audioUnlocked = true;
|
||||
sky?.unlockAudio();
|
||||
removeEventListener('pointerdown', unlock);
|
||||
removeEventListener('keydown', unlock);
|
||||
};
|
||||
addEventListener('pointerdown', unlock);
|
||||
addEventListener('keydown', unlock);
|
||||
|
||||
// --- dev overlay (temporary — hud.js replaces it in step 7) -------------
|
||||
const hud = document.getElementById('dev');
|
||||
const banner = document.getElementById('banner');
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') game.advance();
|
||||
});
|
||||
|
||||
// --- phases -------------------------------------------------------------
|
||||
game.on('phaseChange', ({ to }) => {
|
||||
wind.use(to === 'storm' ? wildWind : calmWind);
|
||||
makeSky();
|
||||
events.length = 0;
|
||||
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 ------------------------------------------------------------
|
||||
// --- resize -------------------------------------------------------------
|
||||
function resize() {
|
||||
const w = canvas.clientWidth || innerWidth;
|
||||
const h = canvas.clientHeight || innerHeight;
|
||||
@ -191,18 +336,19 @@ export function boot(opts = {}) {
|
||||
addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
// --- loop --------------------------------------------------------------
|
||||
// --- loop ---------------------------------------------------------------
|
||||
const clock = new THREE.Clock();
|
||||
let acc = 0;
|
||||
let simT = 0;
|
||||
let frames = 0, fpsT = 0, fps = 0;
|
||||
|
||||
function step(dt, t) {
|
||||
function step(dt) {
|
||||
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.
|
||||
simT += dt;
|
||||
windT = windTime();
|
||||
world.update(dt, windT);
|
||||
player.update(dt, windT);
|
||||
rig.step(dt, wind, windT);
|
||||
debris.step(dt, windT, { player: player.sim, sail: rig });
|
||||
sky?.step(dt, windT, { sail: rig });
|
||||
}
|
||||
|
||||
function frame() {
|
||||
@ -212,28 +358,55 @@ export function boot(opts = {}) {
|
||||
acc += raw;
|
||||
let guard = 0;
|
||||
while (acc >= FIXED_DT && guard++ < 60) {
|
||||
step(FIXED_DT, simT);
|
||||
simT += FIXED_DT;
|
||||
step(FIXED_DT);
|
||||
acc -= FIXED_DT;
|
||||
}
|
||||
|
||||
cameraRig.update(raw, player.pos);
|
||||
sailView?.update();
|
||||
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);
|
||||
const wt = windT;
|
||||
const speed = wind.speedAt(player.pos, wt);
|
||||
const tel = wind.gustTelegraph(wt);
|
||||
const worst = rig.corners.reduce((m, c) => Math.max(m, c.load || 0), 0);
|
||||
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`;
|
||||
`${fps.toFixed(0)} fps | ${game.phase} ${game.phaseT.toFixed(1)}s | ` +
|
||||
`wind ${speed.toFixed(1)} m/s${tel ? ` | GUST in ${tel.eta.toFixed(1)}s` : ''} | ` +
|
||||
`worst corner ${worst.toFixed(1)} | debris ${debris.pieces.length}` +
|
||||
`${events.length ? ` | ${events[events.length - 1].text}` : ''}`;
|
||||
}
|
||||
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; } };
|
||||
// Handy for poking at the world from the console, and for the selftest-free
|
||||
// hand checks the sprint's acceptance actually turns on.
|
||||
const api = {
|
||||
renderer, scene, world, cameraRig, player, game, wind, rig, rigSail,
|
||||
get sailView() { return sailView; },
|
||||
debris, interact, events,
|
||||
get sky() { return sky; },
|
||||
get simT() { return simT; },
|
||||
windTime,
|
||||
calmWind, wildWind,
|
||||
|
||||
/**
|
||||
* Drive the sim by hand at fixed dt, and draw on demand. rAF is throttled to
|
||||
* a standstill in a hidden tab, so these are the only honest way to
|
||||
* fast-forward a storm or capture one from a headless browser — which is
|
||||
* exactly what this sprint's "90 s storm_02 run captured" acceptance needs.
|
||||
* Same code path the rAF loop uses; no test-only branch to drift.
|
||||
*/
|
||||
step,
|
||||
render() {
|
||||
sailView?.update();
|
||||
renderer.render(scene, cameraRig.object);
|
||||
},
|
||||
};
|
||||
globalThis.SHADES = api;
|
||||
return api;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user