Compare commits
5 Commits
76421a7f86
...
de86aa1662
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de86aa1662 | ||
|
|
323352fe50 | ||
|
|
7f3ef69685 | ||
|
|
0dba2d8891 | ||
|
|
6083151af6 |
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -77,3 +77,84 @@ Lane A starts first; B/C/D/E can start immediately after in parallel
|
||||
> models/debris/. Verify every export by rendering a contact sheet against the
|
||||
> 1.7 m ref capsule (the 3D=models/_thumbnails pattern). Commit script AND
|
||||
> GLBs. Log in THREADS.md.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# SPRINT 2 prompts (assembly — fire A/B/C/E together, D at gate 1)
|
||||
|
||||
Same rules: own clone (`~/Documents/shades-lane<X>` on m3ultra), branch `lane/<x>`,
|
||||
rebase onto latest main FIRST (it moved: all lanes merged + importmap + path
|
||||
fixes + M3 clip pack). Read THREADS.md from the [I] integrator entries down,
|
||||
then SPRINT2.md in full — the six decisions at the top are final, stop
|
||||
re-deciding them.
|
||||
|
||||
## Lane A — Sprint 2
|
||||
|
||||
> You are Lane A on SHADES 3D, Sprint 2. Rebase onto main, read THREADS.md's
|
||||
> [I] entries and SPRINT2.md §Lane A. Your sprint IS the assembly: in main.js
|
||||
> swap stub wind → createWind (storm_01 calm phases, storm_02 for the storm),
|
||||
> placeholder → await createPlayer (importmap already landed), add the sail
|
||||
> view + rig step, skyfx + debris + unlockAudio, dress the yard with Lane E's
|
||||
> GLBs (house per decision 6 — read fascia_anchor_* from the GLB), rework
|
||||
> anchors per decision 2 (posts in, p3 added, tree branch_anchor_* live, with
|
||||
> the new quad-area assert), then HUD (loads in kN) and the four-phase machine.
|
||||
> Post "gate 1" in THREADS.md the moment weather+player+sail are live in the
|
||||
> yard so Lane D starts. Small commits, selftest green after each, you remain
|
||||
> merge shepherd per PLAN3D §6.
|
||||
|
||||
## Lane B — Sprint 2
|
||||
|
||||
> You are Lane B on SHADES 3D, Sprint 2. Rebase onto main, read THREADS.md [I]
|
||||
> entries and SPRINT2.md §Lane B + decisions 3/4/5. Land in this order: (1) the
|
||||
> decision-4 API — repair(i), trim(i,delta), cornerPos(i) — matching Lane D's
|
||||
> existing call sites in interact.js, with contract entries + asserts; (2)
|
||||
> decision 5 — consume debris.pieces in sail.step() with a momentum assert;
|
||||
> (3) the coverageOver ray-origin fix (heightAt, not y=0); (4) the prep-phase
|
||||
> picking adapter over RiggingSession once Lane A's anchor markers exist —
|
||||
> coordinate in THREADS; (5) the joint tuning session with Lane C against real
|
||||
> m/s storms, then re-run the §7 gate against REAL wind and log the constants;
|
||||
> (6) after C lands vertical gusts, the decision-3 assert (flat-horizontal no
|
||||
> longer dominant).
|
||||
|
||||
## Lane C — Sprint 2
|
||||
|
||||
> You are Lane C on SHADES 3D, Sprint 2. Rebase onto main, read THREADS.md [I]
|
||||
> entries and SPRINT2.md §Lane C + decision 3. Land: (1) vertical gust
|
||||
> component in storm JSON (downdraft fraction, ~0.25 default, validator +
|
||||
> asserts) — this closes the flat-horizontal loophole with Lane B; (2) freeze
|
||||
> and document the debris.pieces shape in contracts.js for B's integrator;
|
||||
> (3) rain-vs-sail occlusion so the garden visibly stays dry under cloth
|
||||
> (cheap — coordinate the API with B, don't ray-test every drop); (4) the
|
||||
> joint storm-tuning session with B (your THREADS ask — if storm_02 can't
|
||||
> break a carabiner rig, raise the curve, it's a data edit); (5) verify skyfx
|
||||
> light restoration inside the real main.js phase transitions once Lane A
|
||||
> wires it.
|
||||
|
||||
## Lane D — Sprint 2 (start at gate 1)
|
||||
|
||||
> You are Lane D on SHADES 3D, Sprint 2. Rebase onto main FIRST — the M3 clip
|
||||
> pack landed: player_anims.glb now carries 17 clips (ClimbLadder, Crank, Dig,
|
||||
> PickUp, Carry/CarryTurn/CarryIdle, StandUp, TakeCover, StumbleBack,
|
||||
> PlantSeeds beside your original six; names logged in THREADS). Also note the
|
||||
> integrator fixed /world/ → ./ relative paths in player.js and dev_player.html.
|
||||
> Wait for Lane A's "gate 1" THREADS entry, then: (1) verify controls + camera
|
||||
> feel in the real yard (slopes, world.solids collision), tune speeds to yard
|
||||
> scale, gust shove + knockdown from real wind and real debris hits; (2) wire
|
||||
> the full spare loop — shed_table pickup_anchor → Carry/CarryIdle while
|
||||
> carrying → repair(i) consumes the spare (B is landing repair/trim/cornerPos
|
||||
> to your call sites, decision 4); (3) prompts track cornerPos(i) live; (4)
|
||||
> wire Crank to trim(i), TakeCover as the storm shelter verb, StumbleBack for
|
||||
> gust knockback — your state machine, your call on transitions. The §7
|
||||
> one-mid-storm-repair scenario must be playable by hand before you're done.
|
||||
|
||||
## Lane E — Sprint 2 (small)
|
||||
|
||||
> You are Lane E on SHADES 3D, Sprint 2. Rebase onto main, read SPRINT2.md
|
||||
> §Lane E. Small sprint: (1) canopy sway handles — verify your canopy_* nodes
|
||||
> sway cleanly when Lane A drives them, add sway_hint props if per-tree tuning
|
||||
> is needed; (2) a 512² sail cloth weave atlas + tear decal strip so the
|
||||
> membrane reads as fabric; (3) storm dressing set: wheelie bin (mass_hint),
|
||||
> washing line, garden gnome — same one-script determinism + contact-sheet
|
||||
> acceptance; (4) when Lane A's yard is dressed, render a contact sheet of the
|
||||
> assembled yard from the game camera for DESIGN.md.
|
||||
|
||||
66
THREADS.md
66
THREADS.md
@ -439,3 +439,69 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
`./models/…`) — the ped never loaded under `server.py`; it does now, verified in dev_player.html.
|
||||
Selftest on merged main: **121 pass / 0 skip / 0 fail** (E's suite lit up as promised).
|
||||
launch.json now runs `--port 8809` (8801 was held by another session). Next work: SPRINT2.md.
|
||||
|
||||
[I] 2026-07-16 — **M3 CLIP PACK LANDED — the mixamo wishlist is fetched and baked.** John supplied a
|
||||
logged-in Mixamo session; 11 clips downloaded Without Skin @30fps (subs where Mixamo has no such
|
||||
clip: Turning Key→Pulling Lever, Standing Up Ready→Standing Up, Covering Head→Taking Cover; bonus
|
||||
find: Dig And Plant Seeds. Hammering/Sweeping/Bracing don't exist — skipped). FBXs now canonical in
|
||||
the M1's ~/Documents/FBX/; CLIPS extended in build_player_anims.py (names: ClimbLadder, Crank, Dig,
|
||||
PickUp, Carry, CarryTurn, CarryIdle, StandUp, TakeCover, StumbleBack, PlantSeeds); rebuilt on the M1
|
||||
(Blender 5.0.1, 17 NLA tracks, 2.3 MB) and committed. Verified: GLTFLoader reads all 17 clips with
|
||||
contract names; selftest still 121/0/0. Lane D: your M3 verbs are on disk — wire when ready.
|
||||
|
||||
[A] 2026-07-16 — 🚩 **GATE 1 — the yard is live. LANE D: START.** SPRINT2 §Lane A steps 1–4 are on main.
|
||||
The placeholder capsule and stub wind are gone. `python3 server.py` → real weather, your ped walking
|
||||
in it, a rendered sail overhead with its shadow on the garden bed, rain, debris, storm audio.
|
||||
Selftest **121/0/0** after the assembly — nobody's suite moved. 0.63 ms/frame in mid-storm_02
|
||||
(0.17 sim + 0.45 render) against a 16.67 ms budget, 120 k tris / 74 draw calls, so there is a LOT of
|
||||
headroom to spend. Note my clone runs `--port 8811` (8801 and 8809 are held by other sessions).
|
||||
|
||||
[A] 2026-07-16 — **It works. storm_02, hand-driven end to end, default rig (rated/shackle/shackle/carabiner
|
||||
on h1/h3/p2/p1):** the carabiner blows at **t=45.4 s**, then p2's shackle cascades at **t=56 s — one
|
||||
second after the southerly change at 55**. That is Lane C's design landing exactly as they described
|
||||
it: the corners that were slack all storm are the loaded ones after the change. Coverage over the bed
|
||||
is **1.0 with the rig intact and 0.0 once two corners are gone** — the whole game in one number.
|
||||
Peak corner load 5427 N; cloth never went non-finite. Nothing here is asserted-only; I drove it.
|
||||
|
||||
[A] 2026-07-16 — ❗ **LANE B — two things about wiring your sail, one is a real trap.**
|
||||
· `createSailView(rig)` reads `rig.pos`/`rig.tris`, which don't exist until `attach()` allocates
|
||||
them in `_build()`. Build the view before rigging and it throws on an undefined array — cost me
|
||||
my first boot. Not asking you to change it; just documenting the order.
|
||||
· **Call `SHADES.rigSail(anchorIds, hwChoices, tension)`, NOT `rig.attach()` directly**, from your
|
||||
picking adapter. `attach()` replaces the corners array and can change the grid, so the view must
|
||||
be rebuilt and interact re-wired (its targets close over corner objects, and stale closures point
|
||||
at corners the sim no longer steps). `rigSail()` does attach + view rebuild + re-wire behind one
|
||||
door, and it's `async`. Ids are stable so re-wiring replaces rather than stacking duplicates.
|
||||
· Your view is now **eyeballed in a browser**, as you asked: it bellies, catches light, and its
|
||||
shadow lands on the bed. Screenshot going in DESIGN.md with the assembled-yard sheet.
|
||||
· FYI the default rig I boot with is the prototype's AUTO loadout and spans most of the yard — it's
|
||||
your 70–192 m² finding, visible from orbit. Decision 2 (my step 6) shrinks it; not a cloth fault.
|
||||
|
||||
[A] 2026-07-16 — ❗ **LANE D — `knockdown(t, dirX, dirZ)` takes the sim clock first, not the impact.**
|
||||
Lane C's `onHitPlayer(piece, impact)` hands you an impact magnitude, and the obvious wiring —
|
||||
`knockdown(impact)` — jams ~40 into the state machine's start time and you never get up. I wired it
|
||||
`knockdown(windT, piece.vx, piece.vz)`, so you also fall the way the crate was travelling. Flagging
|
||||
in case anything else calls it. Also: `player.pos` is a plain `{x,y,z}`, not a `Vector3` — contracts.js
|
||||
says Vector3. Everything only reads `.x/.y/.z` so it duck-types fine everywhere (camera, wind, HUD)
|
||||
and I'm NOT asking you to change it; I'll relax the contract's wording instead. Your ped, all six
|
||||
clips, walk/run and the yard clamp are confirmed working in the real yard.
|
||||
|
||||
[A] 2026-07-16 — ✅ **LANE C — your §Lane C.5 ask, answered with evidence: `dispose()` restores the lights
|
||||
exactly.** Tested inside the real main.js scene, not a bench. After a 40 s storm_02 dragged sun to
|
||||
1.067 and hemi to 1.132, a bare `sky.dispose()` with no rebuild put them back at **exactly 2.0 and
|
||||
1.8**. I also ran three full forecast→storm→forecast cycles to see if anything compounds: sun settles
|
||||
at 1.939 → 1.941 → 1.951, i.e. converging on storm_01's calm-day target, not decaying. No leak, no
|
||||
flicker. Two notes: (1) I **rebuild** skyfx on every phase change rather than re-pointing it, because
|
||||
it reads `wind.def.sky` at construction and storm_01/storm_02 have different darkness — dispose() is
|
||||
therefore on your hot path, and it holds up. (2) `dispose()` restores sun/hemi but leaves `scene.fog`
|
||||
where the storm left it; invisible in practice because the next skyfx immediately re-drives fog, and
|
||||
it only bites if something disposes without replacing. Your call whether that's worth a line.
|
||||
Wind shelters are wired (`setSheltersFromTrees` on both storms — shelters describe trees, which don't
|
||||
stop existing when the weather turns), `unlockAudio()` fires on first pointer/key, debris bounces off
|
||||
`world.heightAt`, and all four of Lane E's crate/tub GLBs load into `setModels`.
|
||||
|
||||
[A] 2026-07-16 — 🔧 `SHADES.step(dt)` and `SHADES.render()` are exposed on the debug api. rAF is throttled
|
||||
to a standstill in a hidden/background tab, so they are the only honest way to fast-forward or capture
|
||||
a storm from a headless browser — which is what this sprint's "90 s storm_02 run captured" acceptance
|
||||
needs. Same code path the rAF loop uses; no test-only branch that can drift. Everything I reported
|
||||
above was measured through them.
|
||||
|
||||
@ -46,6 +46,21 @@ CLIPS = [
|
||||
("Falling", f"{FBX}/Falling.fbx"), # limb flail; player.js pitches the root itself
|
||||
("CrouchToStand", f"{FBX}/Crouch To Stand.fbx"),
|
||||
("Reaction", f"{FBX}/Reaction.fbx"), # stagger on a debris glance
|
||||
# --- M3 verbs, fetched from Mixamo 2026-07-16 (see tools/character/mixamo_wishlist.txt).
|
||||
# Substitutions where Mixamo has no such clip: Turning Key → Pulling Lever,
|
||||
# Standing Up Ready → Standing Up, Covering Head → Taking Cover.
|
||||
# Hammering / Sweeping Floor / Bracing don't exist on Mixamo — skipped.
|
||||
("ClimbLadder", f"{FBX}/Climbing Ladder.fbx"),
|
||||
("Crank", f"{FBX}/Pulling Lever.fbx"), # turnbuckle work
|
||||
("Dig", f"{FBX}/Digging.fbx"),
|
||||
("PickUp", f"{FBX}/Picking Up Object.fbx"),
|
||||
("Carry", f"{FBX}/Carrying.fbx"),
|
||||
("CarryTurn", f"{FBX}/Carrying Turn.fbx"),
|
||||
("CarryIdle", f"{FBX}/Box Idle.fbx"),
|
||||
("StandUp", f"{FBX}/Standing Up.fbx"),
|
||||
("TakeCover", f"{FBX}/Taking Cover.fbx"), # hail/debris shelter
|
||||
("StumbleBack", f"{FBX}/Stumble Backwards.fbx"),
|
||||
("PlantSeeds", f"{FBX}/Dig And Plant Seeds.fbx"),# garden repair verb
|
||||
]
|
||||
|
||||
PREFIX_RE = re.compile(r"mixamorig\d*:")
|
||||
|
||||
@ -32,3 +32,12 @@ Standing Up Ready
|
||||
Covering Head
|
||||
Bracing
|
||||
Stumble Backwards
|
||||
|
||||
## --- FETCH LOG 2026-07-16 (fetched via browser session on m3ultra, John's Mixamo login) ---
|
||||
# Landed in ~/Documents/FBX/ on the M1 and baked into player_anims.glb (17 clips total):
|
||||
# Climbing Ladder ✓ · Pulling Lever ✓ (sub for Turning Key — no such clip on Mixamo)
|
||||
# Digging ✓ · Dig And Plant Seeds ✓ (bonus, garden verb) · Picking Up Object ✓
|
||||
# Carrying ✓ + Carrying Turn ✓ + Box Idle ✓ (the Carrying Box family)
|
||||
# Standing Up ✓ (sub for Standing Up Ready) · Taking Cover ✓ (sub for Covering Head)
|
||||
# Stumble Backwards ✓
|
||||
# Genuinely absent from Mixamo, skipped: Hammering · Sweeping Floor · Bracing
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -343,7 +343,11 @@ export function createWorld(scene, opts = {}) {
|
||||
sunDir: SUN_DIR.clone(),
|
||||
solids,
|
||||
root,
|
||||
// Lane C's skyfx MODULATES these as the storm builds and hands them back
|
||||
// untouched on dispose() — it doesn't own them. That's why the yard exposes
|
||||
// its lights rather than keeping them private.
|
||||
sun,
|
||||
hemi,
|
||||
|
||||
/** @param {string} id */
|
||||
anchor(id) {
|
||||
|
||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user