@ -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 play er
// Wind rout er
// ---------------------------------------------------------------------------
/ * *
* 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 create WindRouter( 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 ;
}