Compare commits

...

23 Commits

Author SHA1 Message Date
m3ultra
0f5f4d8368 Make rake_pivot a real pivot; log Sprint 2 in THREADS
rake_pivot shipped as a childless empty, so rotating it moved nothing — and
rotating the whole GLB instead, which is the only alternative, tips the concrete
footing out of the ground along with the post. DESIGN.md makes raking away from
the load a player decision, so the handle has to actually work.

It now holds post + pad_eye + top_anchor, with footing left on the root: rake it
8° and the post leans while the concrete stays planted. Asserted both directions
in e.test.js — the head must travel over 0.3 m and the footing under 0.01.

Same class as the canopy bug, found the same way: by driving the handle in a
test instead of eyeballing the model. Exported dims unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:56:15 +10:00
m3ultra
d3e849fcc3 Add sail cloth textures and the storm dressing set
Textures (SPRINT2 §Lane E-2). sail_weave.png is a seamless 512² knitted
shade-cloth weave with the stripe banding real cloth has; the script proves the
wrap by evaluating a second tile and requiring an exact match, because Lane B is
being told to set RepeatWrapping and a bad wrap would seam across the whole
sail. Luminance rides in a narrow band so it multiplies the base colour instead
of replacing it — high contrast reads as burlap, not HDPE. Dropped the per-pixel
noise: invisible at ±0.012 and incompressible, it cost 305 KB of the 323.

sail_tears.png is a strip of 4 escalating rips. Each is a lens, not a slit —
fabric under tension parts widest in the middle and tapers to a point, and a
constant-width gap reads as a drawn line. Threads bridge the gap, scaled to the
local width; without them a dark lens is a hole rather than a tear.

Dressing (§Lane E-3): wheelie_bin_01 (240 L, 12 kg, `lid` on its own node so it
flaps before the bin goes over), washing_line_01 (a Hills Hoist — the `head`
freewheels, giving a second wind tell at head height), garden_gnome_01
(collateral bait: a smashed gnome reads where a damage number doesn't).

Bin and tramp land in models/debris/ for Lane C to glob. All 19 assets pass, 28
output files byte-identical across two runs, selftest 129/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:51:51 +10:00
m3ultra
7cd25a5d2a Give the gum trees a canopy sway handle
world.js sways a tree by rotating a `canopy` group whose origin sits at the
trunk top, so the blobs swing about the trunk. The trees shipped canopy_01..03
as siblings of `trunk` with each origin at its own blob centre — rotating one
spins a sphere in place, which is visually nothing. Lane A could not have swayed
these trees, and the canopy lean is the gust telegraph the player reads a beat
before it hits the sail, so the tell would have gone missing rather than looked
wrong.

Adds the `canopy` empty at the trunk top with the blobs parented under it, so
A's existing code works unchanged, plus sway_amp / sway_phase / sway_pivot_y
for the per-tree tuning SPRINT2 §Lane E asks for (gum_01 is big and leans less
at 0.85; gum_02 is whippy at 1.20 and shows a gust first).

sway_phase draws from its own RNG stream: taking it from the shared one would
consume a value and shift every blob draw after it, resilhouetting a tree the
other lanes have already tuned against. Exported dims are identical to Sprint 1.

e.test.js asserts the pivot by rotating the handle and measuring that a blob
actually travels — red before this change, green after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:42:30 +10:00
m3ultra
de86aa1662 Post gate 1 in THREADS — Lane D is unblocked
Also records what the assembly turned up for the other lanes: the rigSail() door
Lane B's picking adapter must come through, the knockdown(t, ...) argument order
that would have silently broken Lane D's get-up, and the evidence for Lane C's
dispose() light-restoration ask.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:38:54 +10:00
m3ultra
323352fe50 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>
2026-07-16 23:38:03 +10:00
m3ultra
7f3ef69685 Expose the yard's hemisphere light for skyfx
Lane C's skyfx modulates sun and hemi as the storm builds and hands them back on
dispose() — it doesn't own them. It already had `sun`; `hemi` was private, so the
sky could darken but the sky-bounce fill couldn't follow it down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:38:03 +10:00
m3ultra
0dba2d8891 Add Sprint 2 lane prompts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:19:53 +10:00
m3ultra
6083151af6 Bake M3 animation pack: 11 new Mixamo clips into player_anims.glb
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:06:40 +10:00
m3ultra
76421a7f86 Add Sprint 2 assembly instructions: wire proven modules into one playable storm
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:24:35 +10:00
m3ultra
41ad12dede Merge all lanes; add importmap; fix /world/ absolute paths
Selftest on merged main: 121 pass / 0 skip / 0 fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:23:20 +10:00
m3ultra
31d9946a04 Merge remote-tracking branch 'origin/lane/d' 2026-07-16 22:14:20 +10:00
m3ultra
e7639c4264 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-16 22:14:20 +10:00
m3ultra
8791eccc08 Merge remote-tracking branch 'origin/lane/e'
# Conflicts:
#	THREADS.md
2026-07-16 22:14:09 +10:00
m3ultra
6c11368202 Expose audio state and levels
`ready` only meant the graph got built — a suspended AudioContext is still
silent, so there was no way to tell whether the storm was actually audible.
The HUD now reports the real context state.

Verified through it: context runs on first gesture; 7.5 -> 17.8 m/s takes the
wind bed 0.16 -> 0.36 gain while the howl layer goes 0.016 -> 0.104 and the
cutoff opens 428 -> 767 Hz, so a gale reads as a gale and not just a louder
breeze. Rain tracks its curve 0.03 -> 0.32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:56:22 +10:00
m3ultra
703dbc499f Log Lane B landing, unit change and two findings in THREADS
Flags for other lanes: load/rating are newtons now (HUD shows kN); the
yard's 7 anchors only admit 70-192 m2 quads when real shade sails are
20-50 m2; and flat-horizontal is currently the lowest-load geometry,
which inverts DESIGN.md's central shade-vs-survival tension and can't be
fixed inside sail.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:55:37 +10:00
m3ultra
18099c8e6f Align sail lane to contracts.js; free blown corners so they flog
Rebased onto M0 and reconciled against the real spine. checkContract
('sailRig') now conforms and js/tests/b.test.js runs 28 asserts green.

Contract fixes:
  - anchor.sway(t) is the ABSOLUTE position, not an offset (thanks A —
    I had it adding sway to pos, which would have flung every
    tree-anchored corner to double its coordinates).
  - events is an Emitter emitting {type, corner}, not a drained array.
  - coverageOver() rects are centre+size, matching world.gardenBed. It
    consumes world.sunDir directly: a hit along sunDir means shaded.
  - START_BUDGET/SPARE_COST/HARDWARE/FIXED_DT now come from contracts.js
    rather than being redeclared here.

Bug: a corner that blew was marked broken but never had its mass
returned, so invMass stayed 0 and the "blown" corner sat welded in
mid-air — no flogging, and the sail silently went dead. PLAN3D §5-B
wants flogging emergent from the freed node, so _checkFailure now frees
it. The cascade test missed this because it called _repin() by hand;
the new test drives a real overload failure instead and asserts the
corner tears 2 m off its anchor and keeps moving.

Tension dial remapped from the prototype's rest/tension to a real
pre-strain. rest/tension asks for 17% strain at dial 1.2 and 29% at 1.4
— stretching an 18 m sail by three metres — and put 68 kN on a corner of
the yard's biggest quad with no wind blowing. At 0.10 strain-per-dial it
swings a 5x5 rig's peak load 2.1x loose-to-tight and redlines a 192 m2
quad at 8.3 kN drum-tight, which is punishing and correct.

HARDWARE ratings retuned in contracts.js to real newtons per the
standing note there that Lane B owns these numbers. Costs and tier shape
untouched; $80 still buys rated hardware on at most 2 of 4 corners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:55:37 +10:00
m3ultra
c8a9128c17 Add prep-phase rigging economy and ring ordering
Ports the prototype's economy verbatim: $80 budget, $5/$15/$30 hardware
tiers, $15 spare, tension 0.6-1.4. Adds unrig-with-refund, which the
prototype lacked — a misclick there was unrecoverable, and a full refund
costs the economy nothing.

RiggingSession holds all the rules and is three-free and DOM-free, so it
tests headless. The picking UI is left as an explicit seam: it needs Lane
A's camera and anchor markers to raycast against, which do not exist yet.

One assert encodes a design invariant rather than a code fact: $80 must
not buy rated shackles on all four corners. DESIGN.md's economic tension
is that you always field one dodgy corner and choose which one; if that
test ever passes, the budget has become decoration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:55:37 +10:00
m3ultra
06ec4cbea2 Add 3D sail cloth sim with per-face wind and XPBD corner loads
Verlet cloth on a bilinear patch between 4 anchors, N=10 grid,
structural/shear/bend constraints, 5 relaxation iterations at a fixed
1/60 substep. Wind is applied per FACE so hypar twist genuinely sheds
load rather than being cosmetic.

Two deviations from PLAN3D worth flagging:

- Load is read from each constraint's XPBD Lagrange multiplier, not from
  FABRIC_K * leftover-stretch. After a fixed iteration count the leftover
  stretch is solver error, not fabric strain, so the naive reading came
  out ~50x hot (60 kN peaks on a 5x5 m sail). The multiplier is the real
  constraint impulse, which the statics assert confirms by balancing the
  corner reactions against the applied wind to 8%.

- Wind uses a signed square (d*|d|) rather than clamp(d)^2, so the
  leeward face is pushed too. A sail is double-sided.

The sim core deliberately does not import three.js: it runs headless
under node today, stays allocation-free in the hot loop, and replays
bit-for-bit. createSailView() pulls three in lazily for rendering.

Loads land in real newtons (~1-4 kN on a 5x5 m sail in a 34 m/s storm),
so hardware ratings are real working load limits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:55:37 +10:00
m3ultra
6382e604b8 Lane D: the small person — player, interactions, selftests
player.sim.js  Deterministic core, zero imports: camera-relative movement, the
               state-machine table, wind slow/gust-shove/knockdown. step(dt,t)
               is the whole clock — no Date.now, no Math.random, no rAF — so a
               storm fast-forwards identically in selftest and in the game.
player.js      The view: rig load per the 90sDJsim DEVMANUAL rig rules
               (SkeletonUtils.clone, height-normalise off the MEASURED head
               bone, canonicalised bone namespace), clip retarget, and
               createPlayer() satisfying the Player contract.
interact.js    Hold-E with radial progress + wireYardActions (re-rig 2.5 s,
               turnbuckle trim 1.2 s, carry-one-item), duck-typed so it no-ops
               cleanly until Lane B lands sailRig.repair/trim.
d.test.js      20 asserts in Lane A's harness: 38 pass / 3 skip overall.

Ported from the 2D prototype's shape (game.js:250-252), retuned to m/s: the
slow curve, and shove gated to gusts only and scaling with speed² so gusts have
teeth. Knockdown needs 0.5 s of SUSTAINED overload — deliberately the same rule
as a sail corner letting go, so cloth and people speak one language.

Two decisions worth the review:
- The knockdown pitches the root in code rather than playing the Falling clip's
  root. Shared clips must drop Hips.quaternion (a different-orientation source
  lays the target flat), so a clip physically cannot lie the body down. Doing it
  in code also lets the fall go DOWNWIND of the gust that caused it, which a
  canned clip could never do, and keeps it deterministic.
- Gust magnitude is recovered from a slow EMA of local wind rather than widening
  Lane C's contract: wind.sample() gives the total and gustTelegraph() only fires
  BEFORE a gust, so nothing reports gust size during the hold. The EMA
  self-calibrates to whatever storm JSON Lane C authors.

Verified in a real scene, not only in asserts: head bone 1.715 m at fig scale
0.983, all six clips bound, walk/run at the tuned speeds, the body lies down and
gets back up, hold-E fires exactly once per press.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:55:16 +10:00
m3ultra
31a887bf75 Log Lane C landing, contract note and per-lane asks in THREADS
Also corrects the worktree-collision attribution: the checkout that moved HEAD
off lane/a was mine, not Lane D's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:53:00 +10:00
m3ultra
11f27c493c Fix debris ground friction, cloud seam and yard coords
Three bugs the bench found once it was actually running a storm:

- Debris was glued to the floor. The scrape `v *= 0.86` ran every frame while
  grounded, which is 0.86^60 per second — it pinned a 9 kg crate at 0.7 m/s in
  a 19 m/s wind. Friction now applies on impact, with dt-scaled rolling
  friction while resting. A crate crosses the whole yard at ~6 m/s.
- Debris slid instead of tumbling: wind is horizontal, so once down there was
  no vertical force at all and it skated at constant height. Added tumbling
  lift that flips sign as it rolls — bins hop now.
- The cloud dome had a dead straight seam across the sky. The fbm claimed to
  tile and didn't; now each octave wraps at its own integer period.

Also: shelters and the bench now use Lane A's landed yard coords (t1 -9,2 /
t2 8,-2, gardenBed 1,2) instead of my guesses, so shelter tuning means
something.

Verified in-browser against a real storm: crate crosses the yard and the t=74
bin spawns from storm JSON; sail node shoved 0.32 m; a 14 kg bin at 20 m/s
knocks the player down and a tub drifting at 0.3 m/s correctly does not;
lightning peaks 0.88. Lane A's selftest: 37 pass / 3 skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:50:41 +10:00
m3ultra
383471d0f5 Add debris, skyfx and the Lane C bench; align to landed contracts
debris.js: hand-rolled kinematic tumble, drag ∝ speed² so the gust that
spikes a corner is the one that launches the neighbour's bin. Ground bounce
via world.heightAt (contracts.js documents it as ours), sphere-vs-player
knockdown reported to Lane D, sphere-vs-sail-node impulse duck-typed so it
lights up when Lane B exposes nodes and stays silent until then.

skyfx.js: instanced rain that wraps around the camera rather than respawning,
storm sky + procedural cloud dome, lightning, and synthesized WebAudio layers
(wind bed, howl, rain, gust whoosh on the telegraph, rope creak off the worst
corner, flog when one blows). It modulates Lane A's lights and hands them back
on dispose() rather than owning them.

weather_demo.html: graybox bench to drive all three before M0 — mock sail,
storm scrub, 4x, throw-a-crate.

Aligned to contracts.js now that it has landed: relative three imports (there
is no importmap), contracts' rng() instead of a local copy, and storm paths
resolved off import.meta.url — server.py serves the repo root, so an absolute
/world/... would have 404'd at integration.

storm_02: fix the southerly change. contracts.js puts north at -Z and the wind
vector blows toward (cos d, sin d), so the old swing to +2.6 blew toward due
south — a northerly wearing a southerly's name. Now slews to -1.35: a SSW
buster off the open side of the yard, into the house.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:38:21 +10:00
m3ultra
84f647a90c Add wind field, storm timelines and weather selftest
Implements the contracts.js wind surface (PLAN3D §4): sample(pos,t) and
gustTelegraph(t), plus storm defs as data.

The prototype scheduled gusts by integrating (wind.gustT += dt). We can't:
sample(pos,t) is called by sail/player/debris/rain at arbitrary t, so gusts
are precomputed into a timeline from a seeded PRNG at load and read from t.
Envelope shape is a faithful port — telegraph 1.5s / ramp 0.8s / hold 1.7s /
fade 1.0s.

Maths lives in weather.core.js with zero imports (no THREE, no DOM, no
Date.now), so the determinism rule is structural and the suite runs in node
without waiting on Lane A's M0.

storm_01_gentle peaks at 11 m/s; storm_02_wildnight sustains 20 m/s and gusts
to 32 (BOM 'destructive'), with the southerly change landing just before the
worst of it — the corners that were slack all storm are the ones that cop it.

15 asserts green: telegraph lead >=1.2s, wind continuity in time and space,
storm JSON validator (incl. 14 deliberately-broken defs it must reject),
determinism, sample-order independence, tree wind shadow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:38:12 +10:00
44 changed files with 5923 additions and 502 deletions

View File

@ -4,14 +4,8 @@
{
"name": "shades3d",
"runtimeExecutable": "python3",
"runtimeArgs": ["server.py"],
"port": 8801
},
{
"name": "shades-proto",
"runtimeExecutable": "python3",
"runtimeArgs": ["-m", "http.server", "8642", "--directory", "prototype"],
"port": 8642
"runtimeArgs": ["server.py", "--port", "8811"],
"port": 8811
}
]
}

View File

@ -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.

132
SPRINT2.md Normal file
View File

@ -0,0 +1,132 @@
# SPRINT 2 — ASSEMBLY (instructions for Opus 4.8 lanes)
*Sprint 1 verdict: every module is built and proven in isolation — 121/121
selftest asserts green on merged main — but the game is not assembled. main.js
still drives the M0 placeholder capsule and stub wind. Sprint 2 is one thing:
**wire the proven modules into one playable storm.** Read THREADS.md from your
last entry down before starting; the integrator [I] entry lists what changed
under you.*
## Decisions (made — stop waiting on them)
1. **Lanes run on m3ultra.** Lane D's recommendation is adopted: the M1 Ultra
(`johnking@100.91.239.7`) is an asset-build box you SSH to; GLBs get committed;
the game never needs it at runtime. PLAN3D §0 is amended by this line.
2. **Sail-area problem (B's 70192 m² finding): fix the yard, not the physics.**
Lane A: move p1/p2 in to roughly (4.5, 5.5) and (4.0, 6.0), add a third post
p3 near (0, 7), and register the trees' `branch_anchor_*` empties (E shipped
them with `rating_hint`) as anchors. Target: at least three pickable quads in
the 1845 m² range that can shade the garden bed, verified by a new a.test
assert that enumerates quad areas. The huge quads stay possible — the load
bars teaching "you cannot span the whole yard" is design working as intended.
3. **Flat-horizontal loophole: Lane C closes it with vertical gust structure.**
Real gusts aren't horizontal; add a per-gust vertical component (downdraft
fraction in storm JSON, default ~0.25 of gust power, validated) so a
horizontal plate carries real load. Lane B adds the assert: over 8 directions
in storm_02 wind, flat-horizontal peak load ≥ 60% of flat-pitched peak (i.e.
no longer a free lunch). Ponding stays out of scope (M4 water spike).
4. **repair/trim seam: Lane B conforms to Lane D's call sites** (D landed first,
duck-typed): add `repair(i)` (→ repairCorner with the spare's hw),
`trim(i, delta)` (→ trimCorner) and `cornerPos(i) -> Vector3` (live world
position, fresh vector) to the rig object. Contract entries + b.test asserts.
5. **debris↔sail seam: option (b)** — Lane B reads `debris.pieces`
({x,y,z,vx,vy,vz,r,mass}) inside `sail.step()` and applies impulses; momentum
bookkeeping stays in the one integrator. Lane C freezes the `pieces` shape.
6. **House GLB: no re-cut.** Lane A reads `fascia_anchor_*` positions out of
`house_yardside_v1.glb` at load and places anchors there (data wins over the
yard constants). E's 2.80 m fascia replaces the 2.6 m graybox number.
## Lane A — assemble the game (this is the sprint)
main.js boot(), in order; keep each step behind a small commit:
1. Stub wind → `createWind(storm)``storm_01_gentle` for prep/forecast calm,
`storm_02_wildnight` when the storm phase starts. Call
`wind.setSheltersFromTrees(...)` after the yard builds, per C's ask.
2. Placeholder → `await createPlayer(scene, world, cameraRig, {wind, interact})`
(async boot; D says same first three args). Delete the placeholder factory.
3. Sail: `const view = await createSailView(rig); scene.add(view); view.update()`
per frame after `rig.step(dt, wind, t)`. B's THREADS entry has the exact shape.
4. `createSkyFx({scene, camera, wind, sun, hemi})` + `unlockAudio()` on first
input; `createDebris({heightAt: world.heightAt, onHitPlayer: player.knockdown})`,
`debris.setModels()` from `models/debris/` (glob the dir).
5. Yard dressing: swap graybox house → `house_yardside_v1.glb` (decision 6),
shed + shed_table (D's spare pickup), sail posts as `sail_post_v1.glb` rotated
about `rake_pivot`, fence set, grass billboards off `textures/grass_atlas.png`.
6. Anchor rework per decision 2.
7. HUD: loads in **kN** (B's units note), per-corner bars vs rating, wind meter +
gust telegraph banner, garden % (wire `rig.coverageOver(world.gardenBed)`
HP → E's `plants_full/tattered/dead` visibility swap), phase banner, forecast
card (storm JSON summary: peak wind, change time), aftermath screen (garden %,
corners lost, budget delta).
8. Phase machine: forecast (show card, Enter) → prep (rigging UI live, budget
$80, optional timer OFF this sprint) → storm (90 s, storm_02) → aftermath.
Acceptance: `python3 server.py` → rig a sail with the mouse, press Enter, watch
storm_02 try to kill it, repair a corner mid-storm, see the aftermath screen.
60 fps during the storm on this box. Selftest stays green after every merge.
## Lane B — sail in the world
1. Decision 4 API (repair/trim/cornerPos) + asserts.
2. Decision 5: consume `debris.pieces` in step(); assert momentum is conserved
within tolerance on a crate-through-sail scenario.
3. Prep-phase picking adapter: RiggingSession → clicks. Raycast against Lane A's
anchor markers (A exposes `world.anchorMarkers` if you need meshes — agree in
THREADS), corner cycling + tension dial + spare purchase, HUD summary line
from `summary()`. This unblocks A step 8.
4. Joint tuning session with C (their THREADS ask): retune cloth ρ against real
m/s storms; then re-run the §7 gate against REAL wind (current assert used the
stub) — flat cheap rig cascades in storm_02, twisted mixed rig + one repair
survives. Log tuned constants in THREADS.
5. Small fix: `coverageOver()` rays start at `heightAt(x,z)`, not y=0 (your nit).
6. Decision 3 assert (flat-horizontal no longer dominant) once C lands vertical gusts.
## Lane C — weather in the game
1. Decision 3: vertical gust component in storm JSON + validator + asserts.
2. Decision 5: freeze and document `debris.pieces` shape in contracts.js.
3. Rain must react to the sail: cheap occlusion — sample `rig.coverageOver` cells
or raycast a handful of drops so the garden visibly stays dry under cloth.
(Coordinate the API with B; don't ray-test every drop.)
4. Storm tuning session with B (see B-4).
5. skyfx: verify light restoration on `dispose()` inside the real main.js scene —
A will call you if teardown flickers phase transitions.
6. Consider retiring weather_demo.html once main.js hosts the storm — your call,
it stops earning its place when the game IS the bench.
## Lane D — player in the storm
1. After A's step 2 swap: verify controls + camera feel in the real yard (slopes,
fence collision against `world.solids`), tune walk/run speeds against yard
scale, make gust shove + knockdown fire from real wind and real debris hits.
2. Wire the spare pickup loop end-to-end: shed_table `pickup_anchor` → carrying →
`repair(i)` consumes it (B's decision-4 API). The §7 "one mid-storm repair"
scenario must be playable by hand.
3. Interaction prompts follow `cornerPos(i)` live (flogging corner = moving prompt).
4. Kill dev_player.html only when the main game covers everything it proves.
## Lane E — polish targets (small sprint)
1. Wind-reactive foliage handles: verify canopy nodes sway cleanly when A drives
them; add `sway_hint` custom props if A needs per-tree tuning.
2. Sail cloth texture (512² weave/UV-stripe atlas) so the membrane reads as
fabric, + a simple tear decal strip for M3.
3. Storm dressing set: wheelie bin (debris, `mass_hint`), washing line, garden
gnome (collateral scoring bait). One script run, same acceptance as before.
4. Contact-sheet the assembled yard from A's camera for DESIGN.md.
## Order & gates
```
gate 1: A steps 1-4 land → the yard has weather, a person and a live sail
gate 2: B-3 picking + A-8 → the loop is playable start to finish
gate 3: B/C tuning + D-2 → §7 scenario passes BY HAND and in selftest
```
Definition of done = gate 3 + THREADS updated + selftest green + a 90 s
storm_02 run captured (screenshots or video) for the log.
## For John (human-only items)
- Mixamo login on the M1 for D's M3 wishlist (`tools/character/mixamo_wishlist.txt`
— Climbing Ladder, Turning Key, Digging). Not blocking this sprint.
- Two dev servers float around: another session holds :8801; this repo's
launch.json now uses :8809. Kill either freely; `server.py --port N` works.

View File

@ -81,6 +81,92 @@ Format: `[lane letter] YYYY-MM-DD — note`
`~/Documents/shades-laneD/` and work there** — B, C and E already have their own. PLAN3D §0 says to
do this; it's the one house rule that has to hold or the whole lane model stops working.
[D] 2026-07-16 — Worktree collision: mine, sorry — acknowledged and fixed. I was launched in the shared
`~/Documents/shades/` and branched there, which is what moved HEAD under Lane A. Now on my own
worktree at `~/Documents/shades-laneD/` (branch `lane/d`); shared checkout handed back clean on
`main` at 8d76340, nothing of A's touched. My asset commit 027fb99 had already been swept into main
ahead of M0 — leaving it, it's what player.js loads.
[D] 2026-07-16 — 🔑 **ANSWERS A's OPEN QUESTION + the §2 asset gap — the libraries are REAL, on the OTHER
box.** Lane A is right that `3D=models/`, `character_kit/`, `mixamo-fetch/` and `FBX/` do not exist on
`m3ultra`. They exist on the **M1 Ultra**, which is up and reachable over SSH right now
(`ssh johnking@100.91.239.7`, hostname `ultra.local`, no password needed). Verified there:
· `~/Documents/FBX/` — Running.fbx, Falling.fbx, Crouch To Stand.fbx, Reaction.fbx, Death.fbx…
· `~/Documents/3D=models/animations/` — 32 clips incl. Happy Idle.fbx, Walk.fbx, Start Walking.fbx
· `~/Documents/character_kit/` (rigged/, scripts/merge_anims.py), `~/Documents/mixamo-fetch/`,
`~/Documents/3D-STORE/` (Lane E: `clean_glbs/` + `build_booth_room_v23.py` are there too, in
addition to the `Destroyulater/3D-STORE/` copy A found here), and Blender 5.0.1.
**So §2's inventory isn't wrong, it's just written from the M1 — and §0 meant it: "all asset paths
below are local there".** This does NOT mean lanes should move. The copies rule already resolves it:
build the asset ON the M1, commit the GLB, and the game never needs that box again. That's what I did
`player_anims.glb` was built there and is committed; `python3 server.py` on m3ultra needs nothing
remote. **Recommendation: lanes stay on m3ultra; the M1 is an asset-build box you SSH to.** Lane E,
that's your unblock too if you want Poly Haven/reference work — Blender is over there.
[D] 2026-07-16 — ⚠️ **BLOCKS THE PLAYER SWAP — Lane A, one line in index.html.** Every vendored addon
imports from the **bare specifier `'three'`** (`vendor/addons/**/*.js` all end `} from 'three';`).
index.html has no importmap, so the moment it imports player.js it dies with "Failed to resolve
module specifier 'three'". Nothing hit this before because main/world/camera.js import
`../vendor/three.module.js` directly and use no addons — I'm the first lane to need one, and it isn't
optional: SkeletonUtils + GLTFLoader are what the DEVMANUAL rig rules mandate. Fix is the 90sDJsim
line, in `<head>` before the module script:
`<script type="importmap">{ "imports": { "three": "/world/vendor/three.module.js",
"three/addons/": "/world/vendor/addons/" } }</script>`
(selftest.html does NOT need it — d.test.js only imports the zero-dep sim, which is why it's green.)
**Lane E: this will land on you too** the moment you load a GLB. Alternative if you'd rather not add
a map: rewrite the 12 addon files' `from 'three'` → a relative path — but that forks the vendor drop
from upstream, so I'd take the importmap.
[D] 2026-07-16 — **PLAYER LANDED** on `lane/d`, rebased on M0, ready for the boot() swap.
`player.sim.js` (deterministic core, zero imports) · `player.js` (rig/view + `createPlayer`) ·
`interact.js` (hold-E + `wireYardActions`) · `js/tests/d.test.js` · `dev_player.html` (my mock
harness — Lane A owns the real shell; I never touched main.js/index.html/selftest.html).
Selftest: **38 pass / 3 skip**, 20 of them Lane D. Verified in a real scene, not just asserts:
head bone **1.715 m** at fig scale 0.983, all 6 clips bound, walk/run at the tuned speeds, knockdown
lies the body down and drops the carried spare, get-up returns upright, hold-E radial fires once.
`checkContract('player', createPlayer(...))`**CONFORMS**, and it clamps to `world.heightAt()`.
**Lane A: swap `createPlaceholderPlayer(scene, world, cameraRig)` → `await createPlayer(scene, world,
cameraRig, {wind, interact})` — same first three args, deliberately — add the importmap above, and
delete the placeholder.** It's async (two GLB fetches), so boot() must await it.
[D] 2026-07-16 — ❗ **CONTRACT NEEDS FROM LANE B — not urgent, but §5-D.4 can't finish without them.**
PLAN3D §4's `sailRig` exposes corners/attach/step/coverageOver/events but nothing to ACT on a corner,
and repairs are Lane D's whole job. `interact.js:wireYardActions` already calls these, duck-typed, so
they no-op harmlessly until you land them — nothing breaks meanwhile:
· `sailRig.repair(i)` — re-rig corner i (I gate it on the player carrying a spare, 2.5 s hold, and
I consume the spare). Needed for the M2 "one mid-storm repair must be survivable" line in §7.
· `sailRig.trim(i, delta)` — per-corner turnbuckle, ±tension at ONE corner (1.2 s hold). This is
§5-D.4's "new vs prototype, makes corners individual".
· `corner.pos` (or `sailRig.cornerPos(i)`) → world Vector3 — I need somewhere to put the prompt.
Live-read each frame, so a flogging corner's prompt tracks it.
Shout if the shapes fight your sim and I'll adapt — you own sail.js, I'll move.
[D] 2026-07-16 — 📌 **PLAN3D §5-D.1 is not buildable as written — the peds cannot go through Blender.**
§5-D.1 says merge clips onto a ped via the character_kit pipeline. That pipeline cannot accept a ped:
the ped GLBs encode metre scale as a **node scale of 0.01 on `mixamorig*:Hips`** with every child bone
in centimetres (Spine T=+10.05, LeftLeg T=+42.8). Blender bones have no rest scale, so the glTF
importer silently drops that 0.01 — straight after import the rig already reads Hips at 0.99 (metres)
while HeadTop_End reads 76.88 (centimetres) and LeftToe_End sits **96 m under the floor**. Exploded
before a single clip is merged. (It's also why `dancer.glb` is 30x small — its base, Hum_M_1.fbx, is
an FBX with no such trick, head bone 0.0563 m. And `merge_anims.py`'s own comment warns about exactly
this class of bug from the other end.) **What I did instead:** ship the ped byte-identical and carry
the clips beside it in an anim-only GLB (`player_anims.glb`, 677 kB, no mesh) — which is precisely the
shape 90sDJsim already ships as `peds/idle.glb` + `peds/walk.glb`, so it's the house pattern, not a
workaround. Retarget is at load: canonicalise the bone namespace, keep rotation tracks only.
Rebuild: `tools/character/build_player_anims.py` (header has the full why + the ssh one-liner).
Two gotchas worth knowing if you touch rigs:
· three.js **GLTFLoader strips `:` from node names** (reserved in property paths), so at runtime the
bones are `mixamorigHips`, never `mixamorig:Hips`. `_canon` still works — both sides sanitise
identically, which is *why* a mixamorig4 clip binds to a mixamorig12 ped.
· Blender 5.0 **removed `Action.fcurves`** (slotted actions — they're under
`layers[].strips[].channelbags[]`), so `character_kit/scripts/merge_anims.py` no longer runs there
as written. My script handles both layouts.
[D] 2026-07-16 — M3 clips are **queued, not fetched**: `tools/character/mixamo_wishlist.txt` (Climbing
Ladder, Turning Key, Digging + repair/storm extras). `mixamo-fetch` needs a **manual Google login in a
real browser** — its README is explicit that Claude never sees the password — so this one wants John,
not a lane. Everything M0M2 needs is already on disk and in `player_anims.glb`.
[A] 2026-07-16 — ❓ **OPEN QUESTION, needs a human.** PLAN3D §0 says lanes run on "the M1 Ultra
(`johnking@100.91.239.7`, Tailscale)", but this box is `m3ultra` and already has
`~/Documents/shades-laneB/` and `shades-laneE/` checked out — so lanes are in fact running here, and
@ -88,6 +174,95 @@ Format: `[lane letter] YYYY-MM-DD — note`
other machine, this isn't a path fix, it's a decision about where lanes run. Flagging rather than
guessing.
[B] 2026-07-16 — **sail.js + rigging.js landed on `lane/b`, rebased on M0.** `checkContract('sailRig')`
conforms; `js/tests/b.test.js` runs 28 asserts green. 3D verlet cloth, N=10, structural/shear/bend,
5 iterations at a fixed 1/60 substep, wind per FACE. `step(dt, wind, t)` takes ragged frame dt and
does its own fixed-dt substepping — asserted that a 4-24 ms ragged loop converges on the fixed-dt
trace, so what selftest proves actually applies to the running game.
[B] 2026-07-16 — **⚠️ UNITS CHANGED — Lane A (HUD) read this one.** `corner.load` and `hw.rating` are in
NEWTONS now, not the prototype's arbitrary scale. I retuned `HARDWARE` in contracts.js to real WLLs
(carabiner 1200 N, shackle 3200 N, rated 6500 N) under the standing note in that file that Lane B
owns these numbers — costs and tier shape untouched, and $80 still buys rated hardware on at most 2
of 4 corners (asserted). **HUD: show `load/1000` as kN.** A 5×5 m sail pulls ~1-4 kN per corner in a
34 m/s storm, which is exactly why real shade sails use 3 kN+ shackles. That's DESIGN.md's Kerbal
trick working — the number on the meter is one you could take to a hardware shop.
[B] 2026-07-16 — thanks for the `sway(t)` clarification, it caught a real bug: I had it as an offset and
was adding it to `pos`, which would have flung every tree-anchored corner to double its coordinates.
Also consuming `world.sunDir` and `world.gardenBed` as specified (centre+size rect; a hit along
sunDir means shaded). One nit: `coverageOver()` starts its rays at y=0 rather than `heightAt(x,z)`.
On ±0.3 m terrain under a 3 m sail that's ~0.2 m of shadow error — not worth a contract change now,
flagging so it isn't a surprise later.
[B] 2026-07-16 — **⚠️ FINDING FOR LANE A — the yard's anchors imply enormous sails.** Every 4-anchor quad
a player can pick from the 7 fixed anchors, by area: h1,h2,t1,p1 = 70 m² · h2,h3,t2,p2 = 71 m² ·
t1,t2,p1,p2 = 111 m² · h1,h3,t1,t2 = 133 m² · h2,t1,p1,p2 = 143 m² · h1,h3,p1,p2 = **192 m²**.
Real domestic shade sails are 20-50 m², and DESIGN.md itself pictures "a 30 m² kite". Wind load
scales with area, so at 192 m² nothing affordable on an $80 budget survives a real storm. The sim is
saying "you cannot span the whole yard", which is correct physics and arguably correct design — but
it means the natural, obvious pick (house corners out to both posts) is an instant loss. Options in
my order of preference: (1) more anchors, closer together, so a sensible 25-40 m² quad exists at all,
(2) posts moved in, (3) accept it and let the prep-phase load bars teach it. Not my call — flagging
with numbers rather than guessing. Nothing blocks on it; M1 is playable either way.
[B] 2026-07-16 — **❓ OPEN — the flat-horizontal loophole. Needs Lane C, or the water spike.** DESIGN.md's
core tension is "big, flat, low = great shade, death in a storm". My sim disagrees, and it is right
to. Peak corner load over 8 wind directions, same footprint: flat *pitched* 3.06 kN, hypar 1.86 kN,
flat *horizontal* **1.14 kN** — the lowest of all three. A horizontal plate in horizontal wind
genuinely has almost no drag. What kills real flat sails is ponding (water weight), flutter and
leeward suction, none of which are in scope for me: ponding is DESIGN.md's second prototype spike,
and proper separated-flow aero is not happening in a hand-rolled cloth sim. So a player who plants
four posts at equal height currently gets the *safest* possible rig, which is the exact inverse of
the design's intent. Not fixable inside sail.js. Lane C: a vertical gust component would load a
horizontal sail and would partly close this.
[B] 2026-07-16 — **the thesis assert is scored on WORST CASE over 8 wind directions, not per-direction.**
PLAN3D §5-B says "twisted peak < flat peak, same storm". Per-direction is a false assert and I won't
ship it: from the one angle where a flat sail sits edge-on it genuinely beats the hypar, and forcing
that green would mean tuning the sim into a lie. Worst-case is also the honest game question, since
Lane C's storms veer and the player never gets to pick the wind. Result: flat worst 3.06 kN (from S)
vs hypar worst 1.86 kN (from N) — the hypar sheds 39% off its worst moment. Thesis holds.
[B] 2026-07-16 — two notes for whoever next reads sail.js, because both look "simplifiable" and aren't.
(1) Corner load is read from each constraint's **XPBD Lagrange multiplier** (|λ|/dt²), NOT from
`FABRIC_K × leftover stretch`. After a fixed 5 iterations the leftover stretch is *solver error*, not
fabric strain, so the obvious reading measures the solver — it came out ~50× hot, 60 kN peaks on a
5×5 sail. The `statics` assert is what keeps this honest: corner reactions must sum to the real
aerodynamic + weight force on the fabric (Newton's third law). It balances to 8.3%. If someone
"simplifies" the load reading, that assert is what goes red. (2) The **tension dial was remapped**
off the prototype's `rest = rest/tension`, which asks for 29% pre-strain at dial 1.4 and put 68 kN on
a corner before any wind blew. It is now a real pre-strain (0.10/dial → 4% at 1.4).
[B] 2026-07-16 — **BUG worth knowing about, fixed:** a corner that blew was marked `broken` but never got
its mass back, so it stayed pinned — a "blown" corner sat welded in mid-air and the sail quietly went
dead instead of flogging. PLAN3D §5-B wants flogging emergent from the freed node, and it is now. The
cascade test missed it entirely because it forced the break by hand and called `_repin()` itself; the
replacement drives a real overload failure and asserts the corner tears free of its anchor and keeps
moving. Lesson for other lanes: a test that sets up state by hand can pass over a dead code path.
[B] 2026-07-16 — **Lane D — your API is ready.** `sailRig.repairCorner(i, hw)` re-pins a blown corner
(your 2.5 s hold-E; returns false if it isn't broken). `sailRig.trimCorner(i, ±delta)` is the
per-corner turnbuckle (your 1.2 s hold; clamps 0.85-1.15, scales rest lengths near that corner only).
Both emit on `sailRig.events`. Spare count lives on `RiggingSession.spares` — gate `canUse()` on it
and decrement on use.
[B] 2026-07-16 — **Lane A — wiring the rendered sail.** `const view = await createSailView(rig);
scene.add(view);` then `view.update()` each frame after `rig.step()`. Returns a THREE.Group,
double-sided, `castShadow` on — the shadow IS the product. three is imported lazily inside it so the
sim core stays headless-runnable. Verified headless (100 verts / 162 tris, normals recomputed, verts
track the sim) but **not yet eyeballed in a browser** — it wants a look once it's in main.js. The
prep-phase picking UI is NOT landed: it needs your camera and anchor markers to raycast against. All
the rules behind it are done and tested in `RiggingSession` (rigging.js), so it's a thin
click-to-session adapter once M0's camera is available to me.
[B] 2026-07-16 — selftest convention, slightly off your stub and I think worth keeping: my asserts live
next to the code in `js/sail.selftest.js` and `js/rigging.selftest.js` as exported `[name, fn]`
arrays, and `js/tests/b.test.js` is a 3-line adapter that feeds them to your Suite. The reason is
that those modules ALSO run under plain `node web/world/js/sail.selftest.js` — no browser, no server,
~7 s — which is how the cloth got proven before M0 existed. Same array both ways, so the two
harnesses cannot drift. `contracts.js` importing three is no obstacle to that: node imports
`three.module.js` fine.
[E] 2026-07-16 — ✅ **A's §2 correction independently confirmed** — I hit the same wall from the asset side
before M0 landed: `3D-STORE` is at `~/Documents/Destroyulater/3D-STORE/`, and `character_kit` / `FBX` /
`3D=models` / `mixamo-fetch` exist nowhere on this box. My inputs were relocated, not missing, so §5-E
@ -165,3 +340,239 @@ Format: `[lane letter] YYYY-MM-DD — note`
fascia sits at 2.80 (2.90 m ridge), and the facade is 9.20 m against a 30 m north edge. Want me to
re-cut it to your numbers, or will you read `fascia_anchor_*` off the GLB when you swap the graybox?
Either way it's one constant for me — the script regenerates everything.
[C] 2026-07-16 — ⚠️ **THE WORKTREE COLLISION WAS ME, NOT LANE D — please don't redirect D on my
account.** Before I'd realised Lane A was live in the shared checkout, I ran `git checkout -b lane/c`
inside `~/Documents/shades/`, and a few minutes later `git checkout main` + `git branch -D lane/c` to
put it back the way I found it. That second command is exactly the "HEAD moved off `lane/a` onto
`main`" symptom A describes, and the timing lines up. Nothing was lost — the delete reported
`was 8338c0f`, i.e. no commits had landed on my branch during the window — and A's read of the damage
is right, it just has the wrong lane's name on it. Lane C has been on `~/Documents/shades-laneC/`
since. (Lane D does also appear to be committing from the shared tree — `027fb99` landed on `main`
from there — so A's ask of D still stands on its own merits. I'm only disowning the checkout.)
Apologies for the noise; PLAN3D §0's clone rule earns its keep.
[C] 2026-07-16 — **LANE C LANDED on `lane/c` — weather.js, skyfx.js, debris.js, 2 storms.** Rebased on
M0; `c.test.js` is live (19 asserts) and Lane A's selftest reads **37 pass / 3 skip**. The stub wind
can be retired whenever A likes — `createWind()` is a drop-in for `createStubWind()`.
· `wind.sample(pos,t)` / `wind.gustTelegraph(t)` per contract; `checkContract('wind', …)` clean.
· Gusts are a **precomputed timeline**, not an integrator. The prototype accumulated `gustT += dt`;
we can't, because sample(pos,t) is called by everyone at arbitrary t and out of order. Same
envelope though — telegraph 1.5 / ramp 0.8 / hold 1.7 / fade 1.0, straight off the prototype.
· Storms are **data**: `data/storms/*.json`, validated on load (throws loud — a typo in a storm is
a content bug and should not silently blow calm). Tune curves without touching code.
[C] 2026-07-16 — **CONTRACT — one addition, backward compatible.** `wind.sample(pos, t, out?)` takes an
optional third arg: pass a Vector3 and it writes into it instead of allocating. Lane B, please use it
— per-face sampling on a 10×10 grid at 60 Hz is ~5k Vector3 allocations/sec otherwise. Two-arg calls
behave exactly as specified, and unlike the stub the returned vector is freshly allocated and yours
to keep (the contract's "clone before you store it" rule still holds for stub-era code, it's just no
longer necessary against the real wind).
[C] 2026-07-16 — **ASKS, one per lane. All degrade silently — nothing here blocks a merge.**
· **Lane A** — trees don't shelter anything until you tell me where they are:
`wind.setSheltersFromTrees(world.anchors.filter(a => a.type === 'tree'))` after the yard builds.
Unset = no wind shadows, which is just a flatter yard. Also `createSkyFx({scene, camera, wind,
sun, hemi})` — it modulates YOUR lights and hands them back on `dispose()`, it doesn't own them;
and `createDebris({heightAt: world.heightAt})` so debris bounces off your terrain, not y=0.
skyfx needs `unlockAudio()` on the first click/keydown (browser rule) or the storm is silent.
· **Lane B** — ❓ **the debris-vs-sail seam is your call.** I have the impulse maths but not your
nodes: contracts exposes `sailRig.corners`, not the cloth. Two options — (a) I keep driving it and
you expose `sailRig.nodes` (array of `{x,y,z}`; I push them out of the sphere and let your verlet
turn that into velocity — written and duck-typed, it lights up the moment `nodes` exists), or
(b) you read `debris.pieces` (`{x,y,z,vx,vy,vz,r,mass}`) in `sail.step` and do it yourself, since
you own the integrator. I'd take (b) if you want the momentum bookkeeping in one place. Say which
and I'll match it.
· **Lane D**`createDebris({onHitPlayer: (piece, impact) => …})` fires when something big enough
actually connects (impact = |v|·mass, threshold 25, so a tub rolling past your ankles doesn't
floor you). The knockdown state machine is yours per §5-D.3; I only report the hit.
· **Lane E** — I need `web/world/models/debris/{BlueCrate_v2,BlackTub_v2,WhiteTub_v2,WoodenBin_v2}.glb`
(your §5-E.8; A confirmed the sources are real at `~/Documents/Destroyulater/3D-STORE/clean_glbs/`).
Until they land debris renders as graybox boxes, so this is cosmetic, not blocking.
`debris.setModels({name: Object3D})`. Collision is one sphere per piece — radii in `MODEL_SPEC` in
debris.js assume a ~0.6 m crate; if you scale them differently, tell me rather than fighting it.
[C] 2026-07-16 — **Lane B: tune cloth ρ against these, not against the stub.** Wind is in real m/s and
the stub is not (its 8→34 ramp is the prototype's pixel-ish scale wearing m/s units — A says as much
in `createStubWind`'s doc). `storm_01_gentle`: sustained peaks 6.5, worst gust 11.3 m/s (41 km/h) —
the sail should breathe and nothing should break. `storm_02_wildnight`: sustained peaks 20 (72 km/h),
worst gust 32.3 (116 km/h, BOM 'destructive'), southerly change swings 2.09 rad at t=5559 with the
peak landing just after it. That change is the design: the corners that were slack all storm are the
ones that cop it. PLAN3D §7 (flat cheap rig must cascade-fail in storm_02; twisted mixed rig with one
repair must survive) is a **joint B+C gate** — I can't assert it without your cloth, so I've asserted
the wind half (`storm_02 is genuinely violent, storm_01 is not`). Ping me when sail.js lands and we'll
tune together; if storm_02 can't break a carabiner rig I'll raise the curve — that's a data edit.
[C] 2026-07-16 — Notes on my own files, so nobody trips over them:
· `weather.core.js` imports **nothing** — no THREE, no DOM, no Date.now. Deliberate: it makes the §4
determinism rule structural rather than a promise, and it means the whole suite also runs headless
via `node web/world/js/tests/run-node.mjs` (~1 s, no browser, no server). Tuning a storm curve
through a browser round trip is miserable. `weather.js` is the thin THREE adapter over it.
· Cost of that: `weather.core.js` carries its own copy of mulberry32 rather than importing
`contracts.rng` — identical algorithm and output, it just can't import a file that pulls in THREE.
`debris.js` and `skyfx.js` do use `contracts.rng`. Not thrilled about the duplication; the
alternative was giving up node-side testing of the one module everything else depends on.
· Asserts live in `js/tests/weather.selftest.js` as a plain case list; `c.test.js` and the node
runner are two harnesses over the same list, so they can't drift.
· `weather_demo.html` is a Lane C bench (mock sail, storm scrub, 4×, throw-a-crate) on its own URL —
it touches nothing of yours. Delete it whenever it stops earning its place.
· **Lane A:** a.test.js's 'gust telegraph always gives at least 1.2 s of warning' is now also
asserted against the real wind in c.test.js, per your note in the stub. Yours to drop when the
stub goes.
[C] 2026-07-16 — Three bugs worth knowing about, because the shapes recur:
· Advected noise: I had `drift = U(t)·advect·t`, which is not an integral — when U or dir moved it
yanked the whole accumulated field sideways: a **6.8 m/s jump in one frame** at the southerly
change. Now integrated once at build time into a table. If you ever advect anything by time,
integrate it.
· Debris friction: `v *= 0.86` per frame while grounded is `0.86^60` per second — glue, not scrape.
It pinned a 9 kg crate at 0.7 m/s in a 19 m/s wind. Anything per-frame that should be per-second
needs dt.
· `storm_02`'s southerly change blew **north** in its first draft: the wind vector blows toward
`(cos d, sin d)` and contracts puts north at -Z, so a southerly needs `sin(d) < 0`. Worth a second
look at anything that reasons about wind direction.
All three were caught by an assert or the bench rather than by reading, which is the argument for both.
[I] 2026-07-16 — **INTEGRATION PASS (main).** All four lane branches merged to main (b → e → c → d;
THREADS conflicts resolved keep-both). Added the importmap D+E asked for to index.html AND
selftest.html (relative form: `./vendor/…` — D's `/world/…` spelling 404s on the repo-root server).
Same absolute-path bug fixed in dev_player.html and player.js GLB URLs (`/world/models/…` →
`./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 14 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 70192 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.
[E] 2026-07-16 — 🐛 **LANE A — two of my "handles" were broken and are now fixed. Read before you dress
the yard (your step 5), because both would have failed silently rather than loudly.**
· **Canopy sway.** world.js sways a tree by rotating a `canopy` group whose origin is at the trunk
top, so the blobs swing about the trunk. My trees shipped `canopy_01..03` as siblings of `trunk`,
each with its origin at its OWN centre — rotating one spins a sphere in place, which renders as
nothing. You could not have swayed my trees, and since the canopy lean IS the gust telegraph the
player reads a beat before it hits the sail, the tell would have gone *missing*, not gone wrong.
Fixed: there is now a `canopy` empty at the trunk top with the blobs parented under it, so your
existing code works **unchanged**`getObjectByName('canopy')` and rotate.
· **`rake_pivot`.** Same trap, worse. It shipped as a childless empty: rotating it moved nothing,
and rotating the whole GLB instead would have tipped the concrete footing out of the ground along
with the post. Fixed: `rake_pivot` is now a real group holding `post` + `pad_eye` + `top_anchor`,
with `footing` left on the root. Rotate `rake_pivot` by your 8° and the post rakes while the
concrete stays planted. Asserted both ways in e.test.js (head must move >0.3 m, footing <0.01 m).
Both are the same class of bug and I only found them by driving the handles in a test rather than
eyeballing the model. If you add a handle to anything, rotate it in an assert.
[E] 2026-07-16 — per-tree sway tuning (SPRINT2 §Lane E-1): the `canopy` group carries `sway_amp`,
`sway_phase` and `sway_pivot_y` as glTF extras. gum_01 is big and heavy-limbed at amp 0.85; gum_02 is
whippy at 1.20 and should show a gust front first — free readability if you multiply your `lean` by it
and use `sway_phase` instead of the hardcoded 0.7 / 2.9. Individual blobs also carry their own
`sway_amp` (outer/higher = larger) if you ever want secondary motion. Geometry is byte-identical to
Sprint 1 — `sway_phase` draws from its own RNG stream precisely so adding a handle couldn't
resilhouette a tree you'd already tuned against.
[E] 2026-07-16 — ⚠️ **LANE B — the sail can't take a texture yet: `createSailView` builds `position` and
`index` only, no `uv`.** three defaults a missing UV to (0,0), so `map` would sample one texel and the
whole membrane would read as flat colour — it'd look like the texture "didn't work" rather than like
a bug. `sail_weave.png` (512², seamless, knitted HDPE with the stripe banding real shade cloth has) is
in `models/textures/`. The recipe, against your `N*N` grid:
const N = rig.N, uv = new Float32Array(N * N * 2);
for (let j = 0, k = 0; j < N; j++)
for (let i = 0; i < N; i++, k += 2) { uv[k] = i / (N - 1); uv[k + 1] = j / (N - 1); }
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
const tex = await new THREE.TextureLoader().loadAsync('/world/models/textures/sail_weave.png');
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(6, 6); // ~6 tiles across a 5 m sail
tex.colorSpace = THREE.SRGBColorSpace; // r175: colorSpace, not encoding
mat.map = tex; // keep mat.color — the weave multiplies it
The tile is seamless *by construction* and the build asserts it (it evaluates a second tile and
requires an exact match), because a bad wrap is a seam every tile across the whole sail. Shout if you'd
rather I ship it at a different density. `sail_tears.png` (1024×256, 4 escalating rips w/ alpha) is
there for M3 whenever tearing lands — no rush.
[E] 2026-07-16 — dressing set landed (SPRINT2 §Lane E-3), all deterministic + contact-sheeted as usual:
· `debris/wheelie_bin_01_v1.glb` — 240 L kerbside bin, 0.58×0.68×1.12 m, `mass_hint` 12 (empty; a
full one doesn't blow over). `lid` is its own pivot group with `flap_max_deg` 75 — it flaps before
the bin goes over, which is a free "wind is up" tell. Lane C: it's in debris/, so your glob has it.
· `washing_line_01_v1.glb` — a Hills Hoist, 2.84×2.84×2.28 m. `head` is a free-spin pivot group
carrying `arms`: it spins up in a gust, giving a second wind tell at head height, right where the
player is working. Not debris — it's cemented in.
· `garden_gnome_01_v1.glb` — 0.36 m, `mass_hint` 4.5, `breakable`, `collateral_value` 25. Scoring
bait per DESIGN.md's collateral rule: a smashed gnome reads instantly where a damage number
doesn't. Lane A, he wants to be somewhere a flogging sail can reach him.
[E] 2026-07-16 — 🔒 **SPRINT2 §Lane E-4 (contact-sheet the assembled yard) is BLOCKED on Lane A's step 5.**
Checked main at de86aa1: `world.js` and `main.js` contain zero `_v1.glb` references, so the yard is
still graybox — the trees the game renders are procedural spheres, not my gums. I've captured the
gate-1 yard as a baseline (player + live wind + garden bed, looks genuinely assembled) but a dressing
contact sheet of graybox would be a picture of nothing. **Ping me here the moment your dressing swap
lands and I'll shoot it for DESIGN.md same session.** Everything you need is above; nothing of mine is
blocking you.
[E] 2026-07-16 — 👀 art note for whoever owns lighting (A?), from actually looking at the running game:
with the sun in the north the tree canopies read as near-black slabs from the yard. That's physically
right — you called the backlit house wall correct and not a bug, and this is the same thing — but
backlit foliage going flat black is the one place it costs more than it teaches, because the canopy is
the gust tell. Cheap fixes in your court: lift the hemisphere light's ground colour, or I can bake a
little emissive into the leaf material so gums stay readable from underneath. Say the word and it's
one constant in my palette — not touching it unprompted since lighting is yours.

View File

@ -1,188 +1,6 @@
{
"blender": "5.1.2",
"assets": [
{
"name": "ref_capsule",
"dims": [
0.4,
0.4,
1.7
],
"tris": 220,
"nodes": [
"head_height",
"ref_capsule",
"ref_capsule_mesh"
],
"status": "PASS",
"problems": []
},
{
"name": "tree_gum_01",
"dims": [
4.5522,
4.956,
7.9702
],
"tris": 396,
"nodes": [
"branch_anchor_01",
"branch_anchor_02",
"branch_anchor_03",
"canopy_01",
"canopy_02",
"canopy_03",
"tree_gum_01",
"trunk"
],
"status": "PASS",
"problems": []
},
{
"name": "tree_gum_02",
"dims": [
3.8871,
2.7787,
5.4972
],
"tris": 288,
"nodes": [
"branch_anchor_01",
"branch_anchor_02",
"canopy_01",
"canopy_02",
"tree_gum_02",
"trunk"
],
"status": "PASS",
"problems": []
},
{
"name": "fence_post",
"dims": [
0.13,
0.13,
2.03
],
"tris": 24,
"nodes": [
"fence_post",
"post"
],
"status": "PASS",
"problems": []
},
{
"name": "fence_panel",
"dims": [
2.4,
0.054,
1.8194
],
"tris": 324,
"nodes": [
"fence_panel",
"palings",
"rails"
],
"status": "PASS",
"problems": []
},
{
"name": "gate",
"dims": [
1.045,
0.0615,
1.75
],
"tris": 220,
"nodes": [
"gate",
"gate_frame",
"gate_palings",
"hinge_axis",
"hinges"
],
"status": "PASS",
"problems": []
},
{
"name": "house_yardside",
"dims": [
9.2,
1.0547,
2.9
],
"tris": 200,
"nodes": [
"door",
"fascia",
"fascia_anchor_01",
"fascia_anchor_02",
"fascia_anchor_03",
"gutter",
"house_yardside",
"roof",
"wall",
"window"
],
"status": "PASS",
"problems": []
},
{
"name": "shed_01",
"dims": [
2.58,
1.9708,
2.2224
],
"tris": 96,
"nodes": [
"door_anchor",
"doors",
"roof",
"shed_01",
"shell"
],
"status": "PASS",
"problems": []
},
{
"name": "shed_table",
"dims": [
1.6,
0.6,
0.9
],
"tris": 72,
"nodes": [
"pickup_anchor",
"shed_table",
"table_frame",
"table_top"
],
"status": "PASS",
"problems": []
},
{
"name": "garden_bed",
"dims": [
3.0,
1.2,
0.8609
],
"tris": 2580,
"nodes": [
"bed",
"garden_bed",
"plants_dead",
"plants_full",
"plants_tattered",
"soil"
],
"status": "PASS",
"problems": []
},
{
"name": "sail_post",
"dims": [
@ -201,128 +19,7 @@
],
"status": "PASS",
"problems": []
},
{
"name": "ladder_01",
"dims": [
0.455,
0.075,
3.0
],
"tris": 276,
"nodes": [
"ladder",
"ladder_01",
"ladder_base",
"ladder_top"
],
"status": "PASS",
"problems": []
},
{
"name": "shackle",
"dims": [
0.0569,
0.019,
0.0744
],
"tris": 560,
"nodes": [
"bow",
"pin",
"shackle"
],
"status": "PASS",
"problems": []
},
{
"name": "carabiner",
"dims": [
0.049,
0.009,
0.1027
],
"tris": 476,
"nodes": [
"body",
"carabiner",
"gate"
],
"status": "PASS",
"problems": []
},
{
"name": "turnbuckle",
"dims": [
0.0292,
0.0341,
0.1955
],
"tris": 728,
"nodes": [
"body",
"eye_a",
"eye_b",
"turnbuckle"
],
"status": "PASS",
"problems": []
},
{
"name": "tramp_01",
"dims": [
2.9555,
2.9555,
0.78
],
"tris": 976,
"nodes": [
"legs",
"mat",
"pad",
"rim",
"tramp_01"
],
"status": "PASS",
"problems": []
}
],
"debris": [
{
"file": "BlueCrate_v2.glb",
"dims": [
0.36,
0.36,
0.29
],
"sane": true
},
{
"file": "BlackTub_v2.glb",
"dims": [
0.36,
0.54,
0.2
],
"sane": true
},
{
"file": "WhiteTub_v2.glb",
"dims": [
0.36,
0.54,
0.2
],
"sane": true
},
{
"file": "WoodenBin_v2.glb",
"dims": [
0.35,
0.36,
0.31
],
"sane": true
}
]
"debris": []
}

View File

@ -110,7 +110,13 @@ PAL = {
"plant_full": "#5F8A3E",
"plant_tatty": "#7A8446",
"plant_dead": "#8A7550",
"mat_black": "#2E2E30", # trampoline mat
"mat_black": "#2E2E30", # trampoline mat, bin wheels
"bin_green": "#3F5B44", # kerbside wheelie bin
"bin_lid": "#C4A63A", # recycling-yellow lid
"line_white": "#DCD9CF", # clothes line, gnome beard
"gnome_skin": "#E0A986",
"gnome_coat": "#3E6FA8",
"gnome_hat": "#B33C36",
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
}
@ -252,6 +258,20 @@ def add_tube_between(name, p0, p1, radius, material, parent=None, verts=8):
return obj
def parent_keep_transform(child, parent):
"""Blender's Ctrl+P "Keep Transform": reparent without moving the child.
Everything else in this script keeps its root empty at the origin so that
`obj.parent = root` needs no parent-inverse juggling. The canopy handle is
the one exception its pivot has to sit at the trunk top so the blobs need
the inverse or they leap upward by the trunk height on parenting.
"""
bpy.context.view_layer.update()
child.parent = parent
child.matrix_parent_inverse = parent.matrix_world.inverted()
return child
def add_empty(name, location=(0, 0, 0), parent=None, size=0.15):
bpy.ops.object.empty_add(type='PLAIN_AXES', location=location)
obj = _active()
@ -403,7 +423,8 @@ def build_ref_capsule(name):
return root
def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name,
sway_amp=1.0):
"""Eucalypt: pale chalky trunk, sparse olive canopy, low branches that a
landscaper would actually strap a sail to."""
rng = rng_for(seed_name)
@ -442,8 +463,23 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
join_group(trunk_parts, "trunk", root)
# Canopy: separate nodes — Lane A sways these, and only these.
# Canopy. `canopy` is the SWAY HANDLE: an empty at the trunk top that world.js
# rotates, with the blobs hanging off it as children so they swing about the
# trunk the way a real canopy does. Parenting them to the root instead — which
# is what shipped in Sprint 1 — leaves each blob's pivot at its own centre, so
# a lean just spins a sphere in place and the tree never visibly moves. The
# canopy lean IS the gust telegraph the player reads (world.js), so a canopy
# that can't sway silently costs the game its tell. Asserted in e.test.js.
top = (lean * trunk_h, 0, trunk_h)
canopy_grp = add_empty("canopy", top, root, size=0.6)
canopy_grp["sway_amp"] = sway_amp # per-tree lean multiplier
# Own RNG stream on purpose: drawing sway_phase from `rng` would consume a
# value and shift every blob draw after it, silently reshaping a tree the
# other lanes have already tuned against. Adding a handle must not move
# geometry.
canopy_grp["sway_phase"] = round(rng_for(f"{seed_name}:sway").uniform(0, math.tau), 3)
canopy_grp["sway_pivot_y"] = round(trunk_h, 3)
for i in range(canopy_blobs):
ang = math.tau * i / canopy_blobs + rng.uniform(-0.3, 0.3)
off = spread * rng.uniform(0.10, 0.24)
@ -453,9 +489,10 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
r = spread * rng.uniform(0.24, 0.32)
blob = add_ico(f"canopy_{i + 1:02d}", r, (cx, cy, cz),
leaf_a if i % 2 == 0 else leaf_b,
parent=root, subdiv=2,
scale=(1.0, 1.0, rng.uniform(0.55, 0.75)),
subdiv=2, scale=(1.0, 1.0, rng.uniform(0.55, 0.75)),
jitter=r * 0.10, rng=rng)
parent_keep_transform(blob, canopy_grp)
# Secondary motion if Lane A wants it: outer/higher blobs travel further.
blob["sway_amp"] = round(0.6 + 0.4 * (cz / height), 3)
# branch_anchor_* — what Lane B queries. Empties, at the limb tips.
@ -471,13 +508,17 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
def build_tree_gum_01(name):
# Big, heavy-limbed: leans less for the same wind.
return _gum_tree(name, height=8.4, canopy_blobs=3, spread=6.0,
anchor_heights=[2.6, 3.4, 4.3], seed_name=name)
anchor_heights=[2.6, 3.4, 4.3], seed_name=name,
sway_amp=0.85)
def build_tree_gum_02(name):
# Smaller and whippier — it should show a gust front first.
return _gum_tree(name, height=5.6, canopy_blobs=2, spread=4.4,
anchor_heights=[2.3, 3.1], seed_name=name)
anchor_heights=[2.3, 3.1], seed_name=name,
sway_amp=1.20)
def build_fence_post(name):
@ -788,26 +829,40 @@ def build_sail_post(name):
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
H, R = 4.0, 0.048
# The footing is cast into the ground and stays put — only the post rakes.
join_group([add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc,
verts=14),
add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc,
verts=14)], "footing", root)
join_group([add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12),
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark,
verts=12),
add_cyl(f"{name}_cap", R * 1.15, 0.02, (0, 0, H), dark,
verts=12)], "post", root)
# Pad eye at the head — where the corner chain actually clips on.
join_group([add_box(f"{name}_padeye", (0.012, 0.07, 0.09),
(0, 0, H - 0.10), dark),
add_arc_tube(f"{name}_eye", 0.026, 0.008, 0, math.tau, dark,
segs=10, center=(0, 0, H - 0.02), plane='XZ')],
"pad_eye", root)
e = add_empty("top_anchor", (0, 0, H - 0.02), root, size=0.2)
# rake_pivot is a GROUP, not a marker. Everything above the footing hangs off
# it, so rotating it rakes the post while the concrete stays level in the
# ground. Shipping it as a childless empty (as Sprint 1 did) means rotating
# it moves nothing, and rotating the whole GLB instead tips the footing out
# of the dirt with it. Same trap as the canopy handle. Asserted in e.test.js.
rake = add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25)
rake["rake_axis"] = "x/z — rake AWAY from the load (DESIGN.md)"
rake["rake_default_deg"] = 8
above = []
above.append(join_group([
add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12),
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12),
add_cyl(f"{name}_cap", R * 1.15, 0.02, (0, 0, H), dark, verts=12),
], "post"))
# Pad eye at the head — where the corner chain actually clips on.
above.append(join_group([
add_box(f"{name}_padeye", (0.012, 0.07, 0.09), (0, 0, H - 0.10), dark),
add_arc_tube(f"{name}_eye", 0.026, 0.008, 0, math.tau, dark, segs=10,
center=(0, 0, H - 0.02), plane='XZ'),
], "pad_eye"))
e = add_empty("top_anchor", (0, 0, H - 0.02), size=0.2)
e["anchor_type"] = "post"
e["rating_hint"] = 0.9
add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25)
above.append(e)
for o in above:
parent_keep_transform(o, rake)
stamp(root, name, "hardware")
root["post_height"] = H
root["rake_note"] = "rotate about rake_pivot; rake away from the load"
@ -953,9 +1008,239 @@ def build_tramp_01(name):
return root
def build_wheelie_bin_01(name):
"""240 L kerbside bin — 1.10 m, ~12 kg empty. The `lid` is its own node: it
flaps before the bin goes over, which is a free tell that the wind is up."""
root = add_empty(name)
body_m = get_material("Mat_BinBody", PAL["bin_green"], 0.75)
lid_m = get_material("Mat_BinLid", PAL["bin_lid"], 0.7)
wheel_m = get_material("Mat_Rubber", PAL["mat_black"], 0.95)
W, D, H = 0.58, 0.74, 1.02
body = [add_cone(f"{name}_shell", 0.40, 0.34, H, (0, 0, H / 2 + 0.06),
body_m, verts=4, rot=(0, 0, math.radians(45)))]
body.append(add_box(f"{name}_spine", (0.10, 0.06, H * 0.8),
(0, D / 2 - 0.06, H * 0.5), body_m))
join_group(body, "bin_body", root)
lid_pivot = (0, D / 2 - 0.10, H + 0.07)
lid_grp = add_empty("lid", lid_pivot, root, size=0.2)
lid = join_group([
add_box(f"{name}_lid_plate", (W, D * 0.92, 0.035),
(0, 0.02, H + 0.085), lid_m),
add_box(f"{name}_lid_lip", (W, 0.04, 0.05), (0, -D / 2 + 0.10, H + 0.07),
lid_m),
], "lid_plate")
parent_keep_transform(lid, lid_grp)
lid_grp["flap_axis"] = "x"
lid_grp["flap_max_deg"] = 75
wheels = [add_cyl(f"{name}_wheel_{sx}", 0.075, 0.05,
(sx * (W / 2 - 0.06), D / 2 - 0.10, 0.075), wheel_m,
verts=10, rot=(0, math.pi / 2, 0))
for sx in (-1, 1)]
join_group(wheels, "wheels", root)
stamp(root, name, "debris")
root["mass_hint"] = 12.0 # empty; a full one does not blow over
root["tumble_hint"] = "topples about the wheel axle first"
return root
def build_washing_line_01(name):
"""A Hills Hoist. Australian back yards have exactly one, and it is the
perfect storm prop: the `head` freewheels, so it spins up in a gust a
second wind tell, at head height, right where the player is working."""
root = add_empty(name)
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
line_m = get_material("Mat_Line", PAL["line_white"], 0.9)
H, ARM = 2.05, 1.42
join_group([
add_cyl(f"{name}_socket", 0.14, 0.10, (0, 0, 0.05), conc, verts=12),
add_cyl(f"{name}_mast", 0.038, H, (0, 0, H / 2), steel, verts=10),
], "mast", root)
# Everything above the collar spins.
head = add_empty("head", (0, 0, H), root, size=0.4)
head["spin_axis"] = "y"
head["free_spin"] = True
head["spin_hint"] = "freewheels; spin rate ~ wind speed"
parts = []
for i in range(4):
a = math.tau * i / 4
tip = (math.cos(a) * ARM, math.sin(a) * ARM, H - 0.16)
parts.append(add_tube_between(f"{name}_arm_{i}", (0, 0, H), tip, 0.018,
steel, verts=6))
parts.append(add_tube_between(f"{name}_stay_{i}", (0, 0, H + 0.22), tip,
0.008, steel, verts=4))
# Four courses of line between the arm tips.
for ring in range(4):
rr = ARM * (0.45 + 0.18 * ring)
for i in range(4):
a0, a1 = math.tau * i / 4, math.tau * (i + 1) / 4
z = H - 0.16 + 0.02 * ring
parts.append(add_tube_between(
f"{name}_line_{ring}_{i}",
(math.cos(a0) * rr, math.sin(a0) * rr, z),
(math.cos(a1) * rr, math.sin(a1) * rr, z), 0.004, line_m, verts=3))
spun = join_group(parts, "arms", None)
parent_keep_transform(spun, head)
stamp(root, name, "prop")
root["height"] = H
return root
def build_garden_gnome_01(name):
"""37 cm of painted concrete. He is scoring bait: DESIGN.md's collateral rule
wants something the player can fail to protect, and a smashed gnome reads
instantly where a damage number does not."""
root = add_empty(name)
skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8)
coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85)
hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85)
beard = get_material("Mat_Beard", PAL["line_white"], 0.9)
base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95)
parts = [
add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10),
add_cone(f"{name}_body", 0.072, 0.045, 0.16, (0, 0, 0.10), coat, verts=10),
add_ico(f"{name}_head", 0.042, (0, 0, 0.205), skin, subdiv=2),
add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (0, -0.020, 0.176),
beard, verts=8, rot=(math.radians(14), 0, 0)),
add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (0, 0.004, 0.295), hat,
verts=10),
add_ico(f"{name}_nose", 0.011, (0, -0.038, 0.208), skin, subdiv=1),
]
join_group(parts, "gnome", root)
stamp(root, name, "prop")
root["mass_hint"] = 4.5
root["collateral_value"] = 25 # $ — Lane A's aftermath screen
root["breakable"] = True
return root
# ============================================================================
# GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9)
# ============================================================================
def save_png(arr, name):
"""arr: (h, w, 4) float32 RGBA in 0..1, row 0 = BOTTOM (bpy's convention).
Blender ships no PIL, so every texture here is numpy -> bpy's image API."""
import numpy as np # noqa: F401
h, w = arr.shape[0], arr.shape[1]
os.makedirs(TEXTURES_DIR, exist_ok=True)
out = os.path.join(TEXTURES_DIR, f"{name}.png")
img = bpy.data.images.new(name, w, h, alpha=True)
img.pixels.foreach_set(arr.reshape(-1))
img.filepath_raw = out
img.file_format = 'PNG'
img.save()
bpy.data.images.remove(img)
return out, os.path.getsize(out) // 1024
def build_sail_textures():
"""Shade-cloth weave + tear decals (SPRINT2 §Lane E-2).
sail_weave.png is SEAMLESS and meant to tile: every frequency is an integer
number of cycles across the image, so the wrap is exact. Lane B sets
wrapS/wrapT = RepeatWrapping and repeat (6,6) on a ~5 m sail.
Deliberately subtle luminance rides in a narrow band so it multiplies the
base colour rather than replacing it. A high-contrast weave reads as burlap,
and this is knitted HDPE shade cloth.
"""
import numpy as np
SIZE, K = 512, 64 # K threads across; 512/64 = 8 px per thread
def weave_lum(X, Y):
# Over-under: in one checker cell the weft rides on top, in the next the
# warp. Every frequency is an integer number of cycles across SIZE, which
# is what makes the wrap exact.
warp = 0.5 + 0.5 * np.cos(2 * np.pi * K * X / SIZE)
weft = 0.5 + 0.5 * np.cos(2 * np.pi * K * Y / SIZE)
over = (((X * K) // SIZE) + ((Y * K) // SIZE)) % 2 == 0
knit = np.where(over, weft, warp)
# The knit banding real shade cloth has, every 8th thread — the "UV stripe".
stripe = 1.0 - 0.045 * ((((X * K) // SIZE) % 8) == 0)
stripe *= 1.0 - 0.030 * ((((Y * K) // SIZE) % 8) == 0)
# No per-pixel noise: at ±0.012 it was invisible, but it is incompressible
# and took the PNG from 18 KB to 323 KB. The knit carries it alone.
return np.clip((0.80 + 0.20 * knit) * stripe, 0.0, 1.0).astype(np.float32)
Y, X = np.mgrid[0:SIZE, 0:SIZE]
lum = weave_lum(X, Y)
# Prove it tiles. Lane B is being told "RepeatWrapping, repeat ~(6,6)" — if
# the wrap isn't exact that's a visible seam every tile across the whole sail,
# so evaluating one tile to the right must reproduce this one exactly.
Y2, X2 = np.mgrid[0:SIZE, SIZE:2 * SIZE]
if not np.array_equal(lum, weave_lum(X2, Y2)):
raise AssertionError("sail_weave is not seamless — it would seam on repeat")
weave = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
weave[:, :, 0] = lum
weave[:, :, 1] = lum
weave[:, :, 2] = lum * 0.985 # a hair warm, so white cloth isn't clinical
weave[:, :, 3] = 1.0
p1, kb1 = save_png(weave, "sail_weave")
print(f" sail_weave.png {SIZE}x{SIZE}, seamless, {K} threads, {kb1} KB")
# --- tear decals ------------------------------------------------------
# A strip of 4, RGBA, alpha 0 everywhere but the rip. Overlay on a damaged
# panel for M3. Each tear = a jagged slit with frayed threads pulling out of
# both lips, because fabric fails along the weave, not in a clean line.
TW, TH = 1024, 256
cell = TH
tears = np.zeros((TH, TW, 4), dtype=np.float32)
def stamp(px, x, y, rgb, a):
xi, yi = int(round(x)), int(round(y))
if px <= xi < px + cell and 0 <= yi < TH: # clip inside this decal's cell
tears[yi, xi, 0:3] = rgb
tears[yi, xi, 3] = a
# Four escalating rips. Each is a LENS, not a slit: fabric under tension
# parts widest in the middle and tapers to a point at both ends. A
# constant-width gap reads as a drawn line, which is what the first pass did.
for c in range(4):
r = rng_for(f"sail_tear_{c}")
px = c * cell
length = cell * (0.48 + 0.09 * c)
max_gap = cell * (0.055 + 0.042 * c) # the 4th gapes ~4x the 1st
x0 = px + (cell - length) / 2
steps = int(length)
yy = cell * 0.5
lips = []
for s in range(steps):
t = s / max(1, steps - 1)
yy = max(cell * 0.3, min(cell * 0.7, yy + r.uniform(-1.1, 1.1)))
half = max_gap * (math.sin(math.pi * t) ** 0.7)
jag = r.uniform(-0.08, 0.08) * max_gap # ragged, not spiky
top, bot = yy - half + jag, yy + half + jag
for y in np.arange(top, bot, 0.5):
stamp(px, x0 + s, y, (0.10, 0.09, 0.08), 1.0) # the gap
if half > 1.5:
lips.append((x0 + s, top, +1, half)) # +1 = toward the gap
lips.append((x0 + s, bot, -1, half))
# Threads pulling off both lips and bridging the gap. These are the tell:
# without them a lens of dark pixels is a hole, not a tear. Length scales
# with the LOCAL gap so some strands span it completely.
for _ in range(int(55 + c * 30)):
x, y, into, half = lips[r.randrange(len(lips))]
span = half * r.uniform(0.5, 1.9)
for s in np.arange(0.0, span, 0.5):
stamp(px, x + r.uniform(-0.6, 0.6), y + into * (s + 1.0),
(0.82, 0.76, 0.62), 1.0)
p2, kb2 = save_png(tears, "sail_tears")
print(f" sail_tears.png {TW}x{TH}, 4 decals, alpha, {kb2} KB")
return [p1, p2]
def build_grass_atlas():
"""4-tuft billboard atlas, 2x2 cells. Drawn with numpy (no PIL in Blender's
python) and saved through bpy's image API. Lane A instances quads with this."""
@ -997,16 +1282,8 @@ def build_grass_atlas():
blade(px, py, base_x, py + 2, h, lean,
cell * rng.uniform(0.012, 0.022), rgb)
os.makedirs(TEXTURES_DIR, exist_ok=True)
out = os.path.join(TEXTURES_DIR, "grass_atlas.png")
bimg = bpy.data.images.new("grass_atlas", SIZE, SIZE, alpha=True)
bimg.pixels.foreach_set(img.reshape(-1))
bimg.filepath_raw = out
bimg.file_format = 'PNG'
bimg.save()
bpy.data.images.remove(bimg)
kb = os.path.getsize(out) // 1024
print(f" grass_atlas.png {SIZE}x{SIZE}, {CELLS*CELLS} tufts, {kb} KB")
out, kb = save_png(img, "grass_atlas")
print(f" grass_atlas.png {SIZE}x{SIZE}, {CELLS * CELLS} tufts, {kb} KB")
return out
@ -1020,11 +1297,11 @@ ASSETS = [
nodes=["ref_capsule_mesh", "head_height"]),
dict(name="tree_gum_01", fn=build_tree_gum_01,
dims=((3.0, 7.5), (3.0, 7.5), (7.5, 9.5)),
nodes=["trunk", "canopy_01", "canopy_02", "canopy_03",
nodes=["trunk", "canopy", "canopy_01", "canopy_02", "canopy_03",
"branch_anchor_01", "branch_anchor_02", "branch_anchor_03"]),
dict(name="tree_gum_02", fn=build_tree_gum_02,
dims=((2.0, 5.5), (2.0, 5.5), (5.0, 6.5)),
nodes=["trunk", "canopy_01", "canopy_02",
nodes=["trunk", "canopy", "canopy_01", "canopy_02",
"branch_anchor_01", "branch_anchor_02"]),
dict(name="fence_post", fn=build_fence_post,
dims=((0.10, 0.16), (0.10, 0.16), (1.95, 2.10)),
@ -1064,10 +1341,19 @@ ASSETS = [
dict(name="turnbuckle", fn=build_turnbuckle,
dims=((0.015, 0.05), (0.015, 0.05), (0.12, 0.20)),
nodes=["body", "eye_a", "eye_b"]),
# Lands in models/debris/ — Lane C spawns debris from that directory.
# These land in models/debris/ — Lane C globs that directory to spawn from.
dict(name="tramp_01", fn=build_tramp_01, dir=DEBRIS_DIR,
dims=((2.8, 3.1), (2.8, 3.1), (0.70, 0.85)),
nodes=["mat", "rim", "pad", "legs"]),
dict(name="wheelie_bin_01", fn=build_wheelie_bin_01, dir=DEBRIS_DIR,
dims=((0.50, 0.70), (0.65, 0.85), (1.00, 1.20)),
nodes=["bin_body", "lid", "lid_plate", "wheels"]),
dict(name="washing_line_01", fn=build_washing_line_01,
dims=((2.7, 3.1), (2.7, 3.1), (2.0, 2.4)),
nodes=["mast", "head", "arms"]),
dict(name="garden_gnome_01", fn=build_garden_gnome_01,
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
nodes=["gnome"]),
]
@ -1382,6 +1668,7 @@ def main():
build_all(only)
reset_to_empty()
build_grass_atlas()
build_sail_textures()
debris = [] if no_debris else copy_debris()
failures = []

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 191 KiB

View File

@ -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*:")

View File

@ -0,0 +1,43 @@
# SHADES animation wishlist — Mixamo search terms, one per line. (Lane D, for M3)
#
# Same pipeline as 90sDJsim's dj_wishlist.txt and the op-shop clerk's wishlist.txt:
# cd ~/Documents/mixamo-fetch
# node fetch.cjs login # a real browser opens; John logs in by hand, once
# cp <this file> ./shades_wishlist.txt
# node fetch.cjs anims # loops the wishlist, downloads each "Without Skin"
# then add the new FBX to CLIPS in tools/character/build_player_anims.py and rebuild the pack.
#
# NOTE: fetch.cjs needs a MANUAL Google login in a real browser window (its README is explicit that
# Claude never sees the password), so this list is queued for John to run — not something a lane
# fetches on its own. Everything M0-M2 needs is already on disk and already in player_anims.glb:
# Idle / Walk / Run / Falling / CrouchToStand / Reaction.
#
# '#' and blank lines ignored. Names are fuzzy search terms (top hit wins); a miss is skipped.
# In-place clips only — the game translates the player itself, and player.js keeps rotation tracks
# only, so anything with big root travel is wasted.
## --- M3: the repair verbs (PLAN3D §5-D.1) ---
Climbing Ladder
Turning Key
Digging
## --- M3: worth having while the browser is open (DESIGN.md "limited hands" toolset) ---
Hammering
Sweeping Floor
Picking Up Object
Carrying Box
Standing Up Ready
## --- storm reactions (better than reusing Reaction for everything) ---
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

View File

@ -0,0 +1,29 @@
{
"name": "Sea Breeze",
"blurb": "Fresh afternoon breeze, chance of a light shower. Good day to test a rig.",
"rating": 1,
"seed": 1017,
"duration": 90,
"baseCurve": [[0, 3.0], [20, 5.0], [50, 6.5], [75, 6.0], [90, 5.5]],
"gusts": {
"firstAt": 4,
"minGap": 6,
"maxGap": 14,
"powBase": 2,
"powRand": 3,
"powRamp": 2
},
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],
"dirWander": { "amp": 0.35, "rate": 0.09 },
"spatial": { "amp": 0.15, "scale": 12, "advect": 0.5 },
"events": [],
"rain": { "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] },
"sky": { "darkness": 0.15, "cloudScroll": 0.02 }
}

View File

@ -0,0 +1,41 @@
{
"name": "Wild Night",
"blurb": "Front crossing after dark. Southerly change around the hour mark, damaging gusts. Rig for the swing, not the lull.",
"rating": 4,
"seed": 20260716,
"duration": 90,
"_comment": "Sustained builds 7 -> 20 m/s (25 -> 72 km/h); worst gusts land near 33 m/s (~120 km/h), which is BOM 'destructive' and is what shreds a flat drum-tight cheap rig. Peak arrives just AFTER the southerly change, so the corners that were slack all storm are the ones that cop it.",
"_dirCurve_comment": "Radians in the XZ plane; the wind blows TOWARD (cos d, sin d). contracts.js puts north at -Z, so a southerly (blowing toward the north, into the house) needs sin(d) < 0. Starts ~0.85 = blowing toward the SE, i.e. the hot NW'er before a change; slews to ~-1.35 = blowing toward the NNE, i.e. a SSW buster off the open south side of the yard. 55->59s is the slew: ~110 deg in four seconds.",
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
"gusts": {
"firstAt": 3,
"minGap": 5.5,
"maxGap": 11,
"powBase": 3,
"powRand": 5,
"powRamp": 7
},
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
"dirWander": { "amp": 0.25, "rate": 0.13 },
"spatial": { "amp": 0.2, "scale": 11, "advect": 0.5 },
"events": [
{ "t": 38, "type": "debris", "model": "BlueCrate_v2", "lateral": -3.5, "mass": 9, "text": "a crate comes through the fence line" },
{ "t": 52, "type": "lightning", "power": 0.7 },
{ "t": 55, "type": "windchange", "telegraph": 6, "over": 6, "text": "the wind swings around" },
{ "t": 64, "type": "lightning", "power": 1.0 },
{ "t": 66, "type": "debris", "model": "BlackTub_v2", "lateral": 2.0, "mass": 5, "text": "someone's tub is airborne" },
{ "t": 74, "type": "debris", "model": "WoodenBin_v2", "lateral": -1.0, "mass": 14, "text": "the neighbour's bin lets go" },
{ "t": 79, "type": "lightning", "power": 0.5 }
],
"rain": { "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
"sky": { "darkness": 0.8, "cloudScroll": 0.09 }
}

203
web/world/dev_player.html Normal file
View File

@ -0,0 +1,203 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>SHADES — Lane D player harness</title>
<style>
html, body { margin: 0; height: 100%; background: #6f7f8c; overflow: hidden; font: 12px/1.5 ui-monospace, Menlo, monospace; }
canvas { display: block; }
#hud { position: fixed; top: 8px; left: 8px; color: #fff; text-shadow: 0 1px 2px #000; white-space: pre; pointer-events: none; }
#panel { position: fixed; top: 8px; right: 8px; color: #fff; text-shadow: 0 1px 2px #000; text-align: right; }
#panel button { font: inherit; margin: 1px; }
#panel input { vertical-align: middle; }
#prompt { position: fixed; left: 50%; bottom: 64px; transform: translateX(-50%); color: #fff;
text-shadow: 0 1px 3px #000; font-size: 15px; text-align: center; pointer-events: none; }
#bar { width: 160px; height: 5px; background: #0006; margin: 5px auto 0; border-radius: 3px; overflow: hidden; }
#fill { height: 100%; width: 0; background: #ffd54a; }
</style>
<!--
REQUIRED, don't delete: every vendored addon (GLTFLoader, SkeletonUtils, …) imports from the bare
specifier 'three', so any page that pulls in player.js needs this map. index.html has no importmap
yet because nothing there uses an addon — flagged for Lane A in THREADS.md, since the placeholder
swap will need it too.
-->
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
</script>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud"></div>
<div id="panel">
wind <input id="wind" type="range" min="0" max="40" step="0.5" value="4"> <span id="wv">4</span> m/s<br>
dir <input id="dir" type="range" min="0" max="6.28" step="0.01" value="0"><br>
<button id="gust">gust (+18, 1.6s)</button>
<button id="knock">knockdown</button>
<button id="stag">stagger</button>
</div>
<div id="prompt"></div>
<!--
Lane D dev harness. NOT the game — Lane A owns index.html, world.js, camera.js, hud.js.
Everything here that isn't player.js / interact.js is a throwaway mock standing in until M0 lands:
the ground, the wind, the camera and the prompt UI. PLAN3D §0 says lanes develop against contracts
+ mocks until Lane A's skeleton merges; this is that mock.
-->
<script type="module">
import * as THREE from 'three';
import { loadPlayer, KeyboardInput, STATES } from './js/player.js';
import { Interact, wireYardActions } from './js/interact.js';
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('c'), antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x8fa6b6);
scene.fog = new THREE.Fog(0x8fa6b6, 30, 90);
const cam = new THREE.PerspectiveCamera(55, 1, 0.1, 300);
const resize = () => {
renderer.setSize(innerWidth, innerHeight);
cam.aspect = innerWidth / innerHeight; cam.updateProjectionMatrix();
};
addEventListener('resize', resize); resize();
const sun = new THREE.DirectionalLight(0xfff3e0, 2.4);
sun.position.set(-8, 14, 6); sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -18, right: 18, top: 18, bottom: -18, near: 1, far: 50 });
scene.add(sun, new THREE.HemisphereLight(0xbfd8e8, 0x4a5a3a, 1.1));
// --- mock yard: 30x20 m, flat. Lane A's world.js replaces this (and gives real terrain height). ---
const ground = new THREE.Mesh(new THREE.PlaneGeometry(30, 20),
new THREE.MeshLambertMaterial({ color: 0x6f8f4e }));
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true;
scene.add(ground);
const grid = new THREE.GridHelper(30, 30, 0x33502a, 0x5d7a45);
grid.position.y = 0.01; scene.add(grid);
// scale references: a 4 m sail post and a 1.7 m capsule. PLAN3D §5-D.1 wants the player to read
// "small person" beside a 4 m post — this is how we check that by eye.
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.08, 4, 12),
new THREE.MeshLambertMaterial({ color: 0xcfd4d8 }));
post.position.set(-4, 2, -3); post.castShadow = true; scene.add(post);
const capsule = new THREE.Mesh(new THREE.CapsuleGeometry(0.28, 1.7 - 0.56, 6, 12),
new THREE.MeshLambertMaterial({ color: 0xd08a5a }));
capsule.position.set(-2.6, 0.85, -3); capsule.castShadow = true; scene.add(capsule);
// interact fixtures: a shed table (pick up a spare) and a broken sail corner (re-rig)
const table = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.06, 0.6),
new THREE.MeshLambertMaterial({ color: 0x8a6b45 }));
table.position.set(5, 0.8, 2); table.castShadow = true; scene.add(table);
const cornerPos = new THREE.Vector3(0, 2.6, -5);
const cornerDot = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 10),
new THREE.MeshBasicMaterial({ color: 0xff5252 }));
cornerDot.position.copy(cornerPos); scene.add(cornerDot);
// --- mock wind, standing in for Lane C's weather.js wind.sample(pos,t) ---
const windEl = document.getElementById('wind'), dirEl = document.getElementById('dir');
const wv = document.getElementById('wv');
let gustUntil = -1, gustAdd = 0;
const wind = {
sample(_pos, t) {
const base = +windEl.value;
const extra = t < gustUntil ? gustAdd : 0;
const a = +dirEl.value;
const s = base + extra;
return new THREE.Vector3(Math.cos(a) * s, 0, Math.sin(a) * s);
},
};
windEl.oninput = () => { wv.textContent = windEl.value; };
// --- player ---
const { sim, view, step } = await loadPlayer(scene, {
start: { x: 0, y: 0, z: 3 },
height: 1.72,
groundAt: () => 0, // Lane A's world.js supplies the real terrain height
});
const input = new KeyboardInput();
// --- interactions (the real thing: interact.js + wireYardActions) ---
const interact = new Interact();
const corner = { anchorId: 'post_nw', broken: true, load: 0, pos: cornerPos };
wireYardActions(interact, {
sailRig: {
corners: [corner],
repair: () => { corner.broken = false; cornerDot.material.color.set(0x4caf50); },
trim: () => { cornerDot.scale.setScalar(cornerDot.scale.x * 1.08); },
},
world: { shedTable: { pos: table.position } },
});
document.getElementById('gust').onclick = () => { gustAdd = 18; gustUntil = clock.t + 1.6; };
document.getElementById('knock').onclick = () => sim.knockdown(clock.t, +windEl.value ? 1 : 0, 0);
document.getElementById('stag').onclick = () => sim.staggerHit(clock.t);
// --- mock third-person camera. Lane A's camera.js replaces this; RMB-drag orbits. ---
const orbit = { yaw: Math.PI, pitch: 0.28, dist: 6 };
let drag = false;
addEventListener('contextmenu', (e) => e.preventDefault());
addEventListener('mousedown', (e) => { if (e.button === 2) drag = true; });
addEventListener('mouseup', () => { drag = false; });
addEventListener('mousemove', (e) => {
if (!drag) return;
orbit.yaw -= e.movementX * 0.005;
orbit.pitch = Math.max(-0.2, Math.min(1.1, orbit.pitch + e.movementY * 0.004));
});
addEventListener('wheel', (e) => { orbit.dist = Math.max(2.5, Math.min(14, orbit.dist + e.deltaY * 0.01)); });
const hud = document.getElementById('hud');
const promptEl = document.getElementById('prompt');
const DT = 1 / 60;
const clock = { t: 0, acc: 0, last: performance.now() };
function frame(now) {
requestAnimationFrame(frame);
// rAF drives the VIEW; the sim is stepped at a fixed dt so it matches selftest exactly
let elapsed = Math.min(0.25, (now - clock.last) / 1000);
clock.last = now;
clock.acc += elapsed;
while (clock.acc >= DT) {
clock.acc -= DT;
clock.t += DT;
step(DT, clock.t, input.read(orbit.yaw), wind);
interact.step(DT, clock.t, sim, input.holding);
}
// camera: shoulder-follow, orbits on RMB
const h = 1.5;
cam.position.set(
sim.pos.x + Math.sin(orbit.yaw) * Math.cos(orbit.pitch) * orbit.dist,
sim.pos.y + h + Math.sin(orbit.pitch) * orbit.dist,
sim.pos.z + Math.cos(orbit.yaw) * Math.cos(orbit.pitch) * orbit.dist);
cam.lookAt(sim.pos.x, sim.pos.y + 1.1, sim.pos.z);
const near = interact.nearest(sim);
promptEl.innerHTML = near
? `[E] ${interact.labelOf(near, sim)}<div id="bar"><div id="fill" style="width:${(interact.progress * 100).toFixed(0)}%"></div></div>`
: '';
hud.textContent =
`state ${sim.state}${sim.busy ? ' (busy)' : ''}\n` +
`clip ${STATES[sim.state].clip}\n` +
`speed ${sim.speed.toFixed(2)} m/s\n` +
`pos ${sim.pos.x.toFixed(1)}, ${sim.pos.z.toFixed(1)}\n` +
`wind ${sim.windSpeed.toFixed(1)} m/s (base ${sim.windBase.toFixed(1)}, gust ${sim.gust.toFixed(1)})\n` +
`shove ${Math.hypot(sim.shove.x, sim.shove.z).toFixed(2)} m/s\n` +
`exposure ${sim.exposure.toFixed(2)} / ${sim.tune.knockSustain}\n` +
`pitch ${sim.pitch.toFixed(2)}\n` +
`carrying ${sim.carrying || '—'}\n` +
`bound ${view.clipNames.join(' ')}\n` +
`\nWASD move · shift run · E hold · RMB orbit`;
renderer.render(scene, cam);
}
requestAnimationFrame(frame);
// expose for console poking / screenshot checks
Object.assign(window, { sim, view, interact, scene, cam, orbit, clock, THREE });
</script>
</body>
</html>

View File

@ -30,9 +30,19 @@
<div id="dev">booting…</div>
<div id="help">WASD move · shift run · RMB drag orbit · wheel zoom · Enter next phase</div>
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/" } }
</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>

View File

@ -38,16 +38,25 @@ export const SPARE_COST = 15;
/**
* Hardware tiers, ported from prototype/game.js.
*
* `rating` is nominal kN. The ABSOLUTE numbers are placeholders inherited from
* the 2D prototype's load scale Lane B owns retuning them against the 3D
* cloth's real load output. What must survive retuning is the SHAPE: three
* tiers, roughly 1x / 2x / 4.5x strength at 1x / 3x / 6x price, so a mixed rig
* is always the interesting choice and one dodgy corner is always affordable.
* `rating` is a working load limit in NEWTONS retuned by Lane B against the
* 3D cloth's real load output, per the standing note that Lane B owns these
* numbers. Costs are the prototype's, untouched.
*
* The 2D prototype's 9/19/40 were on an arbitrary scale. The 3D cloth reports
* real newtons (a 5x5 m sail pulls ~1-4 kN per corner in a 34 m/s storm), so
* these are real WLLs: a cheap carabiner really does let go around 1.2 kN, a
* rated 8 mm shackle really does hold 6.5 kN. That is the DESIGN.md "Kerbal
* trick" leave the game able to size real hardware.
*
* The SHAPE that had to survive retuning, and did: three tiers at 1x / 3x / 6x
* price, where $80 buys rated hardware on at most two of four corners. A mixed
* rig stays the interesting choice and you are always picking which corner to
* leave dodgy. Asserted in js/tests/b.test.js.
*/
export const HARDWARE = [
{ name: 'carabiner', cost: 5, rating: 9, color: 0xe2b04a },
{ name: 'shackle', cost: 15, rating: 19, color: 0xc8d2d8 },
{ name: 'rated shackle', cost: 30, rating: 40, color: 0x7ee0ff },
{ name: 'carabiner', cost: 5, rating: 1200, color: 0xe2b04a },
{ name: 'shackle', cost: 15, rating: 3200, color: 0xc8d2d8 },
{ name: 'rated shackle', cost: 30, rating: 6500, color: 0x7ee0ff },
];
/** Game phases, in loop order. */

254
web/world/js/debris.js Normal file
View File

@ -0,0 +1,254 @@
'use strict';
// SHADES — Lane C — debris: things that should have been tied down.
//
// Hand-rolled kinematic tumble (PLAN3D §5-C.4). No physics engine, no deps.
// Deterministic: step(dt, t), seeded RNG, no Date.now — so a storm replays.
//
// Spawns off `debris` events in the storm JSON, upwind of the yard, and lets the
// wind field carry it across. Drag goes with speed², same as the sail, so the
// same gust that spikes a corner is the one that launches the neighbour's bin.
import * as THREE from '../vendor/three.module.js';
import { rng } from './contracts.js';
const RHO = 1.2; // air density, kg/m³
const GRAVITY = -9.81;
// Deceleration while resting on the ground, m/s². Drag in a 19 m/s wind gives a
// 9 kg crate ~7 m/s², so it still skitters downwind — which is the whole point.
const GROUND_FRICTION = 3.5;
// Fallback specs for Lane E's debris set (3D-STORE crates/tubs). Radius is the
// collision sphere, not the render bounds — a crate is boxy, but a sphere is
// what you can afford to test 6 of per node per frame.
const MODEL_SPEC = {
BlueCrate_v2: { r: 0.30, mass: 9, cd: 1.05 },
BlackTub_v2: { r: 0.34, mass: 5, cd: 1.10 },
WhiteTub_v2: { r: 0.34, mass: 5, cd: 1.10 },
WoodenBin_v2: { r: 0.42, mass: 14, cd: 1.05 },
LibraryTrolley_v1: { r: 0.45, mass: 22, cd: 0.95 },
};
const DEFAULT_SPEC = { r: 0.35, mass: 8, cd: 1.05 };
/**
* @param {object} o
* @param {object} o.wind from weather.js
* @param {THREE.Object3D} o.scene
* @param {Object<string,THREE.Object3D>} [o.models] name -> template (Lane E's GLBs)
* @param {function} [o.onHitPlayer] (piece, impact) Lane D knocks the player down
* @param {function} [o.onEvent] (text) HUD ticker
* @param {object} [o.bounds] {x, z} half-extents before despawn
*/
export function createDebris(o = {}) {
const wind = o.wind;
const scene = o.scene || null;
const models = o.models || {};
const bounds = o.bounds || { x: 26, z: 20 };
// contracts.js documents world.heightAt as the thing Lane C bounces debris off.
// Flat fallback so this still runs against a graybox yard.
const groundAt = o.heightAt || (() => o.groundY ?? 0);
const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e);
const pieces = [];
const w = new THREE.Vector3();
const probe = new THREE.Vector3();
/** Graybox stand-in so a missing GLB can't break Lane A's merge. */
function placeholder(spec) {
const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8);
const m = new THREE.MeshStandardMaterial({ color: 0x8a6a3a, roughness: 0.9 });
return new THREE.Mesh(g, m);
}
function spawn(ev, t) {
const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) };
if (Number.isFinite(ev.mass)) spec.mass = ev.mass;
// upwind of the yard, offset sideways, so it crosses the whole thing
const d = wind.dirAt(t);
const dx = Math.cos(d), dz = Math.sin(d);
const lat = ev.lateral ?? 0;
const dist = ev.spawnDist ?? 18;
const x = -dx * dist - dz * lat;
const z = -dz * dist + dx * lat;
const y = groundAt(x, z) + spec.r + (ev.height ?? 0.2 + rand() * 1.2);
const tmpl = models[ev.model];
const mesh = tmpl ? tmpl.clone(true) : placeholder(spec);
mesh.castShadow = true;
if (scene) scene.add(mesh);
// already moving — it's been blowing across the neighbour's yard for a while
probe.set(x, y, z);
wind.sample(probe, t, w);
const piece = {
model: ev.model,
x, y, z,
vx: w.x * 0.8, vy: 0, vz: w.z * 0.8,
// spin axis is arbitrary but seeded; rate scales with airspeed in step()
sx: rand() * 2 - 1, sy: rand() * 2 - 1, sz: rand() * 2 - 1,
spin: 0,
phase: rand() * 6.283, // so two crates don't hop in lockstep
r: spec.r, mass: spec.mass, cd: spec.cd,
area: Math.PI * spec.r * spec.r,
hitPlayer: false,
mesh,
alive: true,
};
pieces.push(piece);
if (ev.text && o.onEvent) o.onEvent(ev.text);
return piece;
}
function despawn(p) {
p.alive = false;
if (scene && p.mesh) scene.remove(p.mesh);
}
const debris = {
get pieces() { return pieces; },
/** Lane E's GLBs, once they land. name -> Object3D template. */
setModels(map) { Object.assign(models, map); return debris; },
/** Manual spawn — handy for tuning and for Lane A's debug keys. */
spawn,
/**
* @param {number} dt fixed step
* @param {number} t storm time
* @param {object} [world] {player, sail} both optional, both duck-typed
*/
step(dt, t, world = {}) {
// storm JSON drives the spawns; poll the window so nothing is missed
if (wind) {
for (const ev of wind.eventsBetween(t - dt, t)) {
if (ev.type === 'debris') spawn(ev, t);
}
}
const player = world.player;
const sail = world.sail;
for (let i = pieces.length - 1; i >= 0; i--) {
const p = pieces[i];
probe.set(p.x, p.y, p.z);
wind.sample(probe, t, w);
// drag against the AIR, not the ground: F = ½ρ Cd A |w-v| (w-v)
const rx = w.x - p.vx, ry = w.y - p.vy, rz = w.z - p.vz;
const rel = Math.hypot(rx, ry, rz);
const k = 0.5 * RHO * p.cd * p.area * rel / p.mass;
p.vx += rx * k * dt;
p.vy += ry * k * dt + GRAVITY * dt;
p.vz += rz * k * dt;
// A tumbling bluff body doesn't just get shoved, it gets picked up: lift
// flips sign as it rolls, which is why a bin HOPS across a yard instead
// of sliding. Wind is horizontal (weather.js keeps y=0), so without this
// there is no vertical force at all once it's down and it just skates.
p.vy += (0.5 * rel * rel * Math.sin(p.spin * 1.7 + p.phase) / p.mass) * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
// ground
const floor = groundAt(p.x, p.z) + p.r;
if (p.y <= floor) {
p.y = floor;
if (p.vy < -0.5) {
// a real impact: bounce, and lose some tangential speed to the hit
p.vy = -p.vy * 0.32; // dead-ish, it's a plastic tub
p.vx *= 0.72; p.vz *= 0.72;
} else {
if (p.vy < 0) p.vy = 0;
// Resting: rolling friction as a dt-scaled DECELERATION, not a
// per-frame multiplier. `v *= 0.86` every frame is 0.86^60 per
// second — that isn't scrape, it's glue, and it pinned a 9 kg crate
// at 0.7 m/s in a 19 m/s wind.
const sp = Math.hypot(p.vx, p.vz);
if (sp > 1e-4) {
const drop = Math.min(sp, GROUND_FRICTION * dt);
p.vx -= (p.vx / sp) * drop;
p.vz -= (p.vz / sp) * drop;
}
}
}
// tumble rate follows airspeed — becalmed debris shouldn't keep spinning
p.spin += rel * 0.35 * dt;
if (p.mesh) {
p.mesh.position.set(p.x, p.y, p.z);
p.mesh.rotation.set(p.sx * p.spin, p.sy * p.spin, p.sz * p.spin);
}
// --- sphere vs player: knockdown ---
// Contract gives us player.pos; the knockdown itself is Lane D's (§5-D.3),
// so we just report the hit and let them run the state machine.
if (player && player.pos && !p.hitPlayer) {
const px = player.pos.x, pz = player.pos.z;
const py = player.pos.y + 0.9; // centre of mass, not feet
const dsq = (p.x - px) ** 2 + (p.y - py) ** 2 + (p.z - pz) ** 2;
const hit = p.r + 0.35;
if (dsq < hit * hit) {
const impact = Math.hypot(p.vx, p.vy, p.vz) * p.mass;
// a bin rolling gently past your ankles shouldn't floor you
if (impact > 25 && o.onHitPlayer) {
p.hitPlayer = true; // one knockdown per piece
o.onHitPlayer(p, impact);
p.vx *= 0.4; p.vz *= 0.4;
}
}
}
// --- sphere vs sail nodes: impulse ---
// Duck-typed: lights up the moment Lane B exposes nodes, silent until
// then. See THREADS — B owns sail.js, so this is the seam we agreed on.
if (sail && sail.nodes) applyToSail(p, sail);
if (p.y < groundAt(p.x, p.z) - 5 || Math.abs(p.x) > bounds.x || Math.abs(p.z) > bounds.z) {
despawn(p);
pieces.splice(i, 1);
}
}
},
/** Drop everything (phase change, restart). */
clear() {
for (const p of pieces) despawn(p);
pieces.length = 0;
},
};
/**
* Shove any cloth node the piece is intersecting, and lose some of the piece's
* own momentum doing it. Expects sail.nodes: [{x,y,z,px,py,pz}] (verlet, so we
* move position and let the integrator turn it into velocity).
*/
function applyToSail(p, sail) {
const nodes = sail.nodes;
const reach = p.r + 0.15;
const reachSq = reach * reach;
let hits = 0;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const dx = n.x - p.x, dy = n.y - p.y, dz = n.z - p.z;
const dsq = dx * dx + dy * dy + dz * dz;
if (dsq > reachSq || dsq < 1e-9) continue;
const d = Math.sqrt(dsq);
// push the node out to the sphere surface along the contact normal
const push = (reach - d) / d;
n.x += dx * push; n.y += dy * push; n.z += dz * push;
hits++;
}
if (hits) {
const drag = Math.min(0.5, (hits * p.mass) / 400);
p.vx *= 1 - drag; p.vy *= 1 - drag; p.vz *= 1 - drag;
if (sail.onDebrisHit) sail.onDebrisHit(p, hits);
}
}
return debris;
}

181
web/world/js/interact.js Normal file
View File

@ -0,0 +1,181 @@
/**
* interact.js hold-E actions with radial progress. (Lane D)
*
* contracts.js: interact.register({id, pos, radius, holdSecs, label, canUse()->bool, onDone()})
*
* Zero imports (same reason as player.sim.js): the whole thing is fixed-dt and headless-testable.
* `pos` is duck-typed {x,y,z}, so a THREE.Vector3 or a plain object both work.
*
* The busy handshake: this module both ENTERS and LEAVES the player's `busy` state. Nothing else
* writes it, so a dropped release cannot strand the player and if the world takes the player away
* mid-hold (a gust puts them down), we notice `player.state` is no longer 'busy' and abort without
* stomping on the state the world just set.
*/
export class Interact {
constructor() {
this.targets = new Map();
this.active = null; // the target currently being held
this.progress = 0; // 0..1 — the radial
this.latched = false; // a completed action re-arms only after E is released (see step)
this.events = []; // {type:'done'|'cancel', id, t} — drained by hud.js
}
/**
* @param {object} spec
* @param {string} spec.id
* @param {object} spec.pos {x,y,z} read live each step, so it may move (a sail corner does)
* @param {number} [spec.radius] metres
* @param {number} [spec.holdSecs]
* @param {string|function} [spec.label] string, or (player)->string for live text
* @param {function} [spec.canUse] (player) -> bool
* @param {function} [spec.onDone] (player, t) -> void
* @returns {function} unregister
*/
register(spec) {
if (!spec || !spec.id) throw new Error('interact.register: id required');
const target = {
radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, ...spec,
};
this.targets.set(target.id, target);
return () => this.unregister(target.id);
}
unregister(id) {
if (this.active && this.active.id === id) this.active = null, this.progress = 0;
return this.targets.delete(id);
}
labelOf(target, player) {
return typeof target.label === 'function' ? target.label(player) : target.label;
}
_usable(target, player) {
return !target.canUse || !!target.canUse(player);
}
/** Nearest registered target in range whose canUse() passes. */
nearest(player) {
let best = null, bestD = Infinity;
for (const target of this.targets.values()) {
const p = typeof target.pos === 'function' ? target.pos() : target.pos;
if (!p) continue;
const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z);
if (d <= target.radius && d < bestD && this._usable(target, player)) { best = target; bestD = d; }
}
return best;
}
cancel(t, player) {
if (!this.active) return;
// only hand the player back if they're still ours — a knockdown mid-hold already re-stated them
if (player.state === 'busy') player.setState('idle', t);
this.events.push({ type: 'cancel', id: this.active.id, t });
this.active = null;
this.progress = 0;
}
/**
* @param {number} dt @param {number} t
* @param {PlayerSim} player
* @param {boolean} holding is E held this frame
* @returns {{target, progress, label, holding}} for hud.js to draw the prompt + radial
*/
step(dt, t, player, holding) {
// One press, one action: a completed hold latches until E is released. Without this, a held key
// re-arms the instant the action finishes and the same action fires every holdSecs forever
// (leaning on the shed table would deal you a spare a second, indefinitely).
if (!holding) this.latched = false;
const near = this.nearest(player);
if (this.active) {
const stolen = player.state !== 'busy'; // something else claimed the player
if (!holding || near !== this.active || !this._usable(this.active, player) || stolen) {
this.cancel(t, player);
}
}
if (!this.active && holding && !this.latched && near && !player.busy) {
this.active = near;
this.progress = 0;
player.setState('busy', t);
}
if (this.active) {
this.progress += dt / Math.max(1e-6, this.active.holdSecs);
if (this.progress >= 1) {
const done = this.active;
this.active = null;
this.progress = 0;
this.latched = true;
player.setState('idle', t); // release busy FIRST — onDone may pickUp(), which refuses while busy
if (done.onDone) done.onDone(player, t);
this.events.push({ type: 'done', id: done.id, t });
}
}
const shown = this.active || near;
return {
target: shown,
progress: this.progress,
label: shown ? this.labelOf(shown, player) : '',
holding: !!this.active,
};
}
}
/**
* Register the standard yard actions (PLAN3D §5-D.4). Duck-typed against the contracts so Lane D
* never edits Lane B's or Lane A's files anything not yet landed is simply skipped.
*
* @param {Interact} interact
* @param {object} deps {sailRig, world, spares}
* sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B)
* sailRig.repair(i) -> void [PROPOSED see THREADS.md]
* sailRig.trim(i,d) -> void [PROPOSED per-corner turnbuckle, see THREADS.md]
* world.shedTable -> {pos} (Lane A/E)
*/
export function wireYardActions(interact, deps = {}) {
const { sailRig, world } = deps;
const wired = [];
if (sailRig && Array.isArray(sailRig.corners)) {
sailRig.corners.forEach((corner, i) => {
// re-rig a broken corner — costs the spare you're carrying
wired.push(interact.register({
id: `rerig_${i}`,
pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
radius: 1.8,
holdSecs: 2.5,
label: 're-rig corner',
canUse: (p) => corner.broken && p.carrying === 'spare',
onDone: (p) => { p.carrying = null; if (sailRig.repair) sailRig.repair(i); },
}));
// per-corner turnbuckle trim — new vs the prototype; makes corners individual
wired.push(interact.register({
id: `trim_${i}`,
pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
radius: 1.8,
holdSecs: 1.2,
label: 'tighten turnbuckle',
canUse: () => !corner.broken && !!sailRig.trim,
onDone: () => sailRig.trim && sailRig.trim(i, +0.1),
}));
});
}
if (world && world.shedTable) {
wired.push(interact.register({
id: 'spare_table',
pos: world.shedTable.pos,
radius: 1.5,
holdSecs: 0.6,
label: (p) => (p.carrying ? 'hands full' : 'take a spare'),
canUse: (p) => !p.carrying, // hands-full rule
onDone: (p, t) => p.pickUp('spare', t),
}));
}
return () => wired.forEach((un) => un());
}

View File

@ -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 70192 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;
}

267
web/world/js/player.js Normal file
View File

@ -0,0 +1,267 @@
/**
* player.js the small person: rig, clips, camera-relative control. (Lane D)
*
* The deterministic half lives in player.sim.js; this file is the view. It follows the 90sDJsim
* DEVMANUAL "Rigged animated characters" rules, which are law here:
* · SkeletonUtils.clone() for instances a plain .clone() breaks skinned meshes
* · height-normalise off the HEAD BONE and plant the feet Mixamo scale is unreliable, so the
* scale factor is always MEASURED, never a blind setScalar of a guessed constant
* · canonicalise the bone namespace so any clip binds to any character
*
* Assets (see models/MODELS.md): player_01.glb is an untouched ped (metre-scale, head bone 1.75 m);
* player_anims.glb is an anim-only carrier built by tools/character/build_player_anims.py. The clips
* are retargeted onto the ped at load see _rotOnly for why that is safe at any carrier scale.
*/
import * as THREE from '../vendor/three.module.js';
import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js';
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
import { PlayerSim, STATES, TUNE } from './player.sim.js';
export { PlayerSim, STATES, TUNE };
export const CHAR_URL = './models/player_01.glb';
export const ANIM_URL = './models/player_anims.glb';
/**
* Canonicalise the Mixamo skeleton namespace (mixamorig4: vs mixamorig12:) so any clip binds to any
* character. Every Mixamo auto-rig upload gets its own numbered namespace and three.js binds tracks
* BY NODE NAME a mismatched clip binds to nothing and plays a silent T-pose, with no error.
* (Straight from 90sDJsim index.html. The \d+ is deliberate: a bare "mixamorig:" is already canonical.)
*/
const _canon = (s) => s.replace(/mixamorig\d+/g, 'mixamorig');
const canonRig = (r) => {
if (!r) return r;
if (r.scene) r.scene.traverse((o) => { o.name = _canon(o.name); });
if (r.anims) r.anims.forEach((a) => a.tracks.forEach((t) => { t.name = _canon(t.name); }));
return r;
};
/**
* Shared-clip filter: keep limb/spine rotations only. Drop ALL position tracks (a different-scale
* source inflates or crumples the target) AND Hips.quaternion (a different-orientation source lays
* the target flat). Quaternions are scale-invariant, which is the whole reason a clip carrier at any
* scale retargets cleanly onto the metre-scale ped.
*
* The cost is that a clip can no longer lie the body down so the knockdown does NOT come from the
* Falling clip's root. player.sim.js pitches the root itself, which is also how the fall gets to go
* DOWNWIND of the gust that caused it. The clip only supplies the flail.
*/
const _rotOnly = (c) => new THREE.AnimationClip(c.name, c.duration,
c.tracks.filter((t) => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
const _loadGLTF = (loader, url) => new Promise((res, rej) =>
loader.load(url, (g) => res({ scene: g.scene, anims: g.animations }), undefined,
() => rej(new Error(`player: failed to load ${url}`))));
const UP = new THREE.Vector3(0, 1, 0);
export class PlayerView {
/**
* @param {object} rig {scene, anims} the character
* @param {Array} clips retargeted AnimationClips
* @param {number} height metres to the top of the head
*/
constructor(rig, clips, height = 1.72) {
// root: world position + facing + knockdown pitch. fig: the rig, lifted so its feet sit at y=0.
this.root = new THREE.Group();
this.root.name = 'player';
const fig = skeletonClone(rig.scene);
this.fig = fig;
fig.traverse((o) => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = true; } });
this.root.add(fig);
this.root.updateWorldMatrix(true, true);
// measure, then scale — never a blind setScalar (DEVMANUAL). /head/i matches both Head and
// HeadTop_End; max() takes the crown, which is what "height" means.
const wp = new THREE.Vector3();
let headY = 0;
fig.traverse((o) => {
if (!o.isBone) return;
o.getWorldPosition(wp);
if (/head/i.test(o.name)) headY = Math.max(headY, wp.y);
});
if (headY > 1e-4) { fig.scale.setScalar(height / headY); fig.updateWorldMatrix(true, true); }
this.height = height;
// plant the feet: lift the rig so its lowest bone sits on the root's origin
let minY = Infinity;
fig.traverse((o) => { if (o.isBone) { o.getWorldPosition(wp); minY = Math.min(minY, wp.y); } });
if (minY < Infinity) fig.position.y = -minY;
this.mixer = new THREE.AnimationMixer(fig);
this.actions = {};
// some characters lack bones a shared clip animates (thumb joints, say) — bind only what exists,
// else three.js spams "No target node found" for every missing bone
const nodes = new Set();
fig.traverse((o) => { if (o.name) nodes.add(o.name); });
for (const clip of clips) {
const bindable = clip.tracks.filter((t) => nodes.has(t.name.split('.')[0]));
if (!bindable.length) continue;
const use = bindable.length === clip.tracks.length
? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable);
this.actions[clip.name] = this.mixer.clipAction(use);
}
this.current = null;
this._axis = new THREE.Vector3();
this._qYaw = new THREE.Quaternion();
this._qPitch = new THREE.Quaternion();
}
/** @returns {string[]} clip names that bound to at least one bone */
get clipNames() { return Object.keys(this.actions); }
play(name, loop = true, fade = 0.18) {
const next = this.actions[name];
if (!next || next === this.current) return;
next.reset();
next.setEffectiveWeight(1);
next.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
next.clampWhenFinished = !loop;
if (this.current) next.crossFadeFrom(this.current, fade, false);
next.play();
this.current = next;
}
/** Push one sim frame onto the rig. dt drives the mixer only — the sim already stepped. */
sync(sim, dt) {
const st = STATES[sim.state];
this.play(st.clip, st.loop !== false);
this.root.position.set(sim.pos.x, sim.pos.y, sim.pos.z);
// yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the
// feet. At pitch 0 this is exactly the yaw, so upright play is untouched.
this._qYaw.setFromAxisAngle(UP, sim.facing);
if (sim.pitch > 1e-4) {
this._axis.set(sim.knockDir.z, 0, -sim.knockDir.x);
if (this._axis.lengthSq() < 1e-8) this._axis.set(1, 0, 0);
this._qPitch.setFromAxisAngle(this._axis.normalize(), sim.pitch * Math.PI * 0.5);
this.root.quaternion.copy(this._qPitch).multiply(this._qYaw);
} else {
this.root.quaternion.copy(this._qYaw);
}
this.mixer.update(dt);
}
dispose() {
this.mixer.stopAllAction();
this.root.removeFromParent();
}
}
/**
* Load the player and attach it to a scene.
* @param {THREE.Scene|THREE.Object3D} scene
* @param {object} [opts] {start, facing, height, groundAt, tune, charUrl, animUrl}
* @returns {Promise<{sim: PlayerSim, view: PlayerView, step: function}>}
*/
export async function loadPlayer(scene, opts = {}) {
const loader = new GLTFLoader();
const [rig, animPack] = await Promise.all([
_loadGLTF(loader, opts.charUrl || CHAR_URL),
_loadGLTF(loader, opts.animUrl || ANIM_URL),
]);
canonRig(rig);
canonRig(animPack);
const clips = animPack.anims.map(_rotOnly).filter((c) => c.tracks.length);
const view = new PlayerView(rig, clips, opts.height || 1.72);
scene.add(view.root);
const sim = new PlayerSim(opts);
const missing = Object.values(STATES).map((s) => s.clip).filter((c, i, a) =>
a.indexOf(c) === i && !view.actions[c]);
if (missing.length) console.warn('player: state machine wants clips that did not bind:', missing);
return {
sim,
view,
/** Convenience: step the sim then push it to the rig. */
step(dt, t, input, wind) {
sim.step(dt, t, input, wind);
view.sync(sim, dt);
return sim.state;
},
};
}
/**
* The `Player` contract factory (contracts.js) this is the one main.js calls.
* Drop-in for createPlaceholderPlayer(scene, world, cameraRig): same first three args, so boot()
* only changes which function it calls. Everything the player needs per frame it pulls itself, so
* `update(dt, t)` matches the contract's signature exactly.
*
* @param {THREE.Object3D} scene
* @param {object} world contracts World `heightAt(x,z)` clamps the player to the ground
* @param {object} cameraRig contracts Camera `yaw` is what WASD is relative to
* @param {object} [opts] {wind, interact, start, facing, height, tune, charUrl, animUrl}
* @returns {Promise<object>} satisfies checkContract('player', )
*/
export async function createPlayer(scene, world, cameraRig, opts = {}) {
const p = await loadPlayer(scene, {
...opts,
groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined,
start: opts.start || { x: 0, y: 0, z: 6 },
});
const keyboard = new KeyboardInput();
const { sim, view } = p;
return {
get pos() { return sim.pos; },
get carrying() { return sim.carrying; },
set carrying(v) { sim.carrying = v; },
get busy() { return sim.busy; },
/** @param {number} dt @param {number} t — main.js's fixed-dt loop drives this */
update(dt, t) {
const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
sim.step(dt, t, input, opts.wind);
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
view.sync(sim, dt);
},
/** The Object3D to follow/frame. Lane A's camera wants this, not the raw rig. */
get object() { return view.root; },
sim,
view,
keyboard,
dispose() { keyboard.dispose(); view.dispose(); },
};
}
/**
* Keyboard the sim's input shape. Camera yaw comes from Lane A's camera each frame.
* Kept out of PlayerSim so the sim stays headless.
*/
export class KeyboardInput {
constructor(target = window) {
this.keys = new Set();
this._down = (e) => {
this.keys.add(e.code);
if (/^(Arrow|Space)/.test(e.code)) e.preventDefault();
};
this._up = (e) => this.keys.delete(e.code);
target.addEventListener('keydown', this._down);
target.addEventListener('keyup', this._up);
this._target = target;
}
get holding() { return this.keys.has('KeyE'); }
/** @param {number} camYaw radians */
read(camYaw = 0) {
const k = this.keys;
const x = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0);
const z = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0);
return { x, z, run: k.has('ShiftLeft') || k.has('ShiftRight'), camYaw };
}
dispose() {
this._target.removeEventListener('keydown', this._down);
this._target.removeEventListener('keyup', this._up);
}
}

246
web/world/js/player.sim.js Normal file
View File

@ -0,0 +1,246 @@
/**
* player.sim.js the small person's deterministic core. (Lane D)
*
* Zero imports, on purpose. PLAN3D §0 requires the sim to be fast-forwardable in selftest with a
* fixed dt and no renderer, so nothing in here touches THREE, the DOM, rAF, Date.now() or
* Math.random(). `step(dt, t, …)` is the entire clock: same inputs same trace, every run.
*
* player.js owns the view (rig, clips, camera). This file owns the truth: where the person is,
* what state they're in, and what the wind is doing to them.
*/
/**
* The state machine, as a table (PLAN3D §5-D.5 asks for a table test).
* clip clip name in player_anims.glb
* locked movement input is ignored, and `player.busy` is true
* secs timed states auto-advance to `next` after this long
* Invariant the selftest enforces: every locked state either has a `next` (so it drains on its own)
* or is released by an external actor. `busy` is the only externally-released state interact.js
* both enters and leaves it, so a dropped release can't strand the player.
*/
export const STATES = {
idle: { clip: 'Idle', locked: false, loop: true },
walk: { clip: 'Walk', locked: false, loop: true },
run: { clip: 'Run', locked: false, loop: true },
busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' },
stagger: { clip: 'Reaction', locked: true, loop: false, secs: 0.9, next: 'idle' },
knocked: { clip: 'Falling', locked: true, loop: false, secs: 1.4, next: 'getup' },
getup: { clip: 'CrouchToStand', locked: true, loop: false, secs: 1.3, next: 'idle' },
};
/**
* Tuning. Ported from the 2D prototype's shape (prototype/game.js:250-252), retuned to metres and
* m/s per PLAN3D §1 ("port the behaviour, retune the constants").
*/
export const TUNE = {
walkSpeed: 1.5, // m/s — a person crossing a 30 m yard, unhurried
runSpeed: 4.4, // m/s — shift
accel: 16, // m/s² toward the wanted velocity
turnRate: 11, // rad/s — facing chases the movement direction
// prototype: slow = 1 - min(0.35, wind.speed / 160). Its wind ran ~4 (calm) to ~38 (gust peak),
// which is already m/s-shaped, so the curve ports across directly.
slowMax: 0.35,
slowRef: 160,
// prototype: push = ws*ws*0.55 — "wind pressure goes with speed², gusts have teeth"; and shove
// only applied while wind.gust > 8, never from the base wind.
shoveK: 0.0035, // shove accel (m/s²) = shoveK · ws² → ~3.2 m/s² in a 30 m/s gust
shoveGustMin: 8, // m/s of gust (over baseline) before the wind can push you at all
shoveDamp: 2.5, // 1/s foot-friction bleed → terminal drift ≈ shoveK·ws²/shoveDamp
// Baseline tracker: contracts.js exposes wind.sample() (total) and wind.gustTelegraph() (before
// the gust), but nothing reports gust magnitude DURING the hold. Rather than widen Lane C's
// contract, we recover it: a slow EMA of local wind speed is the base curve, and everything above
// it is gust. Self-calibrating to whatever storm JSON Lane C authors.
baseTrack: 0.25, // 1/s — ~4 s memory; gust holds are ~1.7 s, so they read as gust, not base
// Knockdown mirrors the sail's failure rule (PLAN3D §1: break after 0.4 s SUSTAINED overload) —
// same verb for cloth and for people, so the player reads one language.
knockWind: 30, // m/s sustained local wind → you go down
knockSustain: 0.5, // s above knockWind before it happens
knockBleed: 2, // exposure drains this many × faster than it fills
pitchSecs: 0.35, // s for the body to swing down / back up (view reads sim.pitch)
};
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/** Shortest-arc angle step from a toward b, at most `maxStep` radians. */
function turnToward(a, b, maxStep) {
let d = (b - a) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
return a + clamp(d, -maxStep, maxStep);
}
export class PlayerSim {
/**
* @param {object} [opts]
* @param {object} [opts.start] {x,y,z} spawn, metres
* @param {function} [opts.groundAt] (x,z) -> y. Lane A's world.js provides the real one.
* @param {object} [opts.tune] overrides for TUNE
*/
constructor(opts = {}) {
const s = opts.start || { x: 0, y: 0, z: 0 };
this.pos = { x: s.x, y: s.y || 0, z: s.z };
this.vel = { x: 0, z: 0 }; // intended (input-driven) velocity
this.shove = { x: 0, z: 0 }; // wind-driven velocity, decays through foot friction
this.facing = opts.facing || 0; // yaw; models face +Z at 0 (90sDJsim convention)
this.state = 'idle';
this.stateT = 0;
this.carrying = null; // contract: player.carrying — one item, hands-full rule
this.events = []; // {type:'state'|'drop'|'knockdown', …} drained by the view/HUD
this.exposure = 0; // s spent above knockWind
this.windBase = 0; // EMA baseline (see TUNE.baseTrack)
this.windSpeed = 0; // last sampled local speed, m/s — HUD/audio read this
this.gust = 0; // windSpeed - windBase, clamped ≥0
this.pitch = 0; // 0 upright … 1 flat on the ground
this.knockDir = { x: 0, z: 1 }; // which way the body went down
this.groundAt = opts.groundAt || (() => 0);
this.tune = { ...TUNE, ...(opts.tune || {}) };
}
/** contract: player.busy */
get busy() { return !!STATES[this.state].locked; }
get clip() { return STATES[this.state].clip; }
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
setState(s, t = 0) {
if (this.state === s) return false;
if (!STATES[s]) throw new Error(`player: unknown state ${s}`);
this.state = s;
this.stateT = 0;
this.events.push({ type: 'state', state: s, t });
return true;
}
/** Drop whatever's carried (knockdown does this per PLAN3D §5-D.3). */
drop(t = 0) {
if (!this.carrying) return null;
const item = this.carrying;
this.carrying = null;
this.events.push({ type: 'drop', item, t });
return item;
}
/** @returns {boolean} true if the pickup was accepted (hands-full rule). */
pickUp(item, t = 0) {
if (this.carrying || this.busy) return false;
this.carrying = item;
this.events.push({ type: 'pickup', item, t });
return true;
}
/** Light hit — a glancing debris clip. Ignored if already down. */
staggerHit(t = 0) {
if (this.state === 'knocked' || this.state === 'getup') return false;
return this.setState('stagger', t);
}
/**
* Put the player on the ground. debris.js (Lane C) calls this on a solid hit; the sim calls it
* itself on sustained extreme wind.
* @param {number} [dirX] @param {number} [dirZ] which way to fall defaults to downwind/facing.
*/
knockdown(t = 0, dirX, dirZ) {
if (this.state === 'knocked' || this.state === 'getup') return false;
let x = dirX, z = dirZ;
if (x === undefined || (x === 0 && z === 0)) { x = Math.sin(this.facing); z = Math.cos(this.facing); }
const m = Math.hypot(x, z) || 1;
this.knockDir = { x: x / m, z: z / m };
this.setState('knocked', t);
this.exposure = 0;
this.vel.x = this.vel.z = 0;
this.drop(t);
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir } });
return true;
}
/**
* @param {number} dt fixed step, seconds
* @param {number} t storm time, seconds
* @param {object} input {x,z} camera-relative axes in -1..1, {run}, {camYaw} radians
* @param {object} wind contracts wind ({sample(pos,t)->Vector3}) or a plain {x,z} vector
*/
step(dt, t, input = {}, wind = null) {
const T = this.tune;
this.stateT += dt;
// --- local wind, and how much of it is gust ---
let wx = 0, wz = 0;
if (wind) {
const v = typeof wind.sample === 'function' ? wind.sample(this.pos, t) : wind;
if (v) { wx = v.x || 0; wz = v.z || 0; }
}
const ws = Math.hypot(wx, wz);
this.windSpeed = ws;
this.windBase += (ws - this.windBase) * clamp(dt * T.baseTrack, 0, 1);
this.gust = Math.max(0, ws - this.windBase);
// --- sustained extreme wind puts you down (same rule as a sail corner letting go) ---
if (ws > T.knockWind) this.exposure += dt;
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
const st = STATES[this.state];
// --- movement ---
const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you
let wantX = 0, wantZ = 0;
if (!st.locked) {
const ix = input.x || 0, iz = input.z || 0;
const mag = Math.hypot(ix, iz);
if (mag > 1e-3) {
// camera-relative: at camYaw 0 three.js looks down -Z, so forward = (-sin, -cos).
const cy = input.camYaw || 0;
const sin = Math.sin(cy), cos = Math.cos(cy);
const nx = ix / mag, nz = iz / mag;
const dx = nx * cos - nz * sin;
const dz = -nx * sin - nz * cos;
const target = (input.run ? T.runSpeed : T.walkSpeed) * Math.min(1, mag) * slow;
wantX = dx * target; wantZ = dz * target;
this.facing = turnToward(this.facing, Math.atan2(dx, dz), T.turnRate * dt);
}
}
// approach the wanted velocity at a fixed accel (both directions — stopping is the same law)
const dvx = wantX - this.vel.x, dvz = wantZ - this.vel.z;
const dvm = Math.hypot(dvx, dvz);
const step = T.accel * dt;
if (dvm <= step || dvm < 1e-6) { this.vel.x = wantX; this.vel.z = wantZ; }
else { this.vel.x += dvx / dvm * step; this.vel.z += dvz / dvm * step; }
// --- gust shove: pressure ∝ speed², gust only, never while you're already on the ground ---
const grounded = this.state === 'knocked' || this.state === 'getup';
if (!grounded && this.gust > T.shoveGustMin && ws > 1e-3) {
const a = T.shoveK * ws * ws;
this.shove.x += (wx / ws) * a * dt;
this.shove.z += (wz / ws) * a * dt;
}
const bleed = Math.exp(-T.shoveDamp * dt);
this.shove.x *= bleed; this.shove.z *= bleed;
this.pos.x += (this.vel.x + this.shove.x) * dt;
this.pos.z += (this.vel.z + this.shove.z) * dt;
this.pos.y = this.groundAt(this.pos.x, this.pos.z);
// --- body pitch: the sim owns it so a knockdown is deterministic and falls DOWNWIND,
// which a canned clip can't do. player.js just reads sim.pitch + sim.knockDir. ---
const wantPitch = this.state === 'knocked' ? 1
: this.state === 'getup' ? Math.max(0, 1 - this.stateT / (st.secs || 1))
: 0;
const pstep = dt / T.pitchSecs;
this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);
// --- locomotion state from actual speed (so shove/slow can't desync the feet) ---
if (!st.locked) {
const sp = this.speed;
this.setState(sp < 0.15 ? 'idle' : sp > T.walkSpeed * 1.35 ? 'run' : 'walk', t);
} else if (st.secs && this.stateT >= st.secs && st.next) {
this.setState(st.next, t);
}
return this.state;
}
}

157
web/world/js/rigging.js Normal file
View File

@ -0,0 +1,157 @@
/**
* rigging.js prep-phase rig selection and hardware economy. [Lane B]
*
* The money half of the sail. Ports the prototype's economy verbatim ($80
* budget, $5/$15/$30 hardware, $15 spare) and adds the state machine around it:
* which anchors are rigged, what hangs at each corner, how tight, how many
* spares in the bag.
*
* Kept three-free and DOM-free like sail.js so it is testable headless. The
* picking/DOM layer is deliberately NOT here yet it needs Lane A's camera and
* anchor markers, which do not exist at time of writing; see createRiggingUI at
* the bottom for the seam it will plug into.
*/
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
import { orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
export { START_BUDGET, SPARE_COST };
export const MAX_CORNERS = 4;
export const DEFAULT_TENSION = 1.0;
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const OK = { ok: true };
const fail = (reason) => ({ ok: false, reason });
export class RiggingSession {
/**
* @param {object} opts
* @param {Array} opts.anchors world.anchors [{id, pos, type, sway?}]
* @param {number} opts.budget starting cash
*/
constructor({ anchors = [], budget = START_BUDGET } = {}) {
this.anchors = anchors;
this.budget = budget;
this.tension = DEFAULT_TENSION;
this.spares = 0;
/** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */
this.picks = [];
}
get spent() { return START_BUDGET - this.budget; }
get canStart() { return this.picks.length === MAX_CORNERS; }
isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); }
pickOf(anchorId) { return this.picks.find((p) => p.anchorId === anchorId) || null; }
/** Charge (or refund, when amount is negative) against the budget. */
_spend(amount) {
if (this.budget - amount < 0) return false;
this.budget -= amount;
return true;
}
/** Rig a corner at an anchor, starting on the cheapest hardware (prototype). */
rig(anchorId) {
const a = this.anchors.find((x) => x.id === anchorId);
if (!a) return fail('no such anchor');
if (this.isRigged(anchorId)) return fail('already rigged');
if (this.picks.length >= MAX_CORNERS) return fail('a sail has four corners');
if (!this._spend(HARDWARE[0].cost)) return fail('not enough budget');
this.picks.push({ anchorId, hw: HARDWARE[0] });
this._reorder();
return OK;
}
/**
* Unrig a corner and refund its hardware. Not in the prototype (which had no
* way back from a misclick) but it is a pure refund, so it costs the economy
* nothing and saves the player a restart.
*/
unrig(anchorId) {
const i = this.picks.findIndex((p) => p.anchorId === anchorId);
if (i < 0) return fail('not rigged');
this.budget += this.picks[i].hw.cost;
this.picks.splice(i, 1);
return OK;
}
/** Cycle a corner's hardware to the next tier, paying (or refunding) the difference. */
cycleHardware(anchorId) {
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length];
if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget');
p.hw = next;
return OK;
}
setHardware(anchorId, hw) {
const p = this.pickOf(anchorId);
if (!p) return fail('not rigged');
if (!HARDWARE.includes(hw)) return fail('unknown hardware');
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
p.hw = hw;
return OK;
}
/** 0.6 loose (soaks gusts, flogs) .. 1.4 drum tight (no flap, shock-loads). */
setTension(v) {
this.tension = clamp(v, TENSION_MIN, TENSION_MAX);
return this.tension;
}
/** Spares are what Lane D's hold-E re-rig consumes mid-storm. */
setSpares(n) {
n = Math.max(0, Math.floor(n));
const delta = (n - this.spares) * SPARE_COST;
if (!this._spend(delta)) return fail('not enough budget');
this.spares = n;
return OK;
}
/**
* Ring-order the picks by angle around their ground-plane centroid, so corner
* i of the cloth grid always maps to a neighbouring anchor. Without it,
* picking anchors in a silly order knots the sail through itself.
*/
_reorder() {
if (this.picks.length < MAX_CORNERS) return;
const byId = new Map(this.picks.map((p) => [p.anchorId, p]));
const ring = orderRing(this.picks.map((p) => this.anchors.find((a) => a.id === p.anchorId)));
this.picks = ring.map((a) => byId.get(a.id));
}
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
commit(rig) {
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
}
/** Everything the HUD needs to draw the prep panel, in one read. */
get summary() {
return {
budget: this.budget,
spent: this.spent,
tension: this.tension,
spares: this.spares,
canStart: this.canStart,
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
weakest: this.picks.length
? this.picks.reduce((w, p) => (p.hw.rating < w.hw.rating ? p : w)).anchorId
: null,
};
}
}
/**
* Prep-phase picking UI.
*
* Deliberately unimplemented: it needs Lane A's camera, renderer canvas and
* anchor markers to raycast against, none of which exist yet. RiggingSession
* above holds all the rules and is fully tested, so this stays a thin
* click-to-session adapter once M0 lands. See THREADS.md.
*/
export async function createRiggingUI() {
throw new Error('rigging UI lands once Lane A has a camera and anchor markers — see THREADS.md');
}

View File

@ -0,0 +1,195 @@
/**
* rigging.selftest.js assert suite for the prep-phase economy. [Lane B]
*
* Same shape as sail.selftest.js: exports RIGGING_TESTS as [name, fn] pairs so
* one set of asserts runs under both Lane A's selftest.html (via
* js/tests/b.test.js) and node.
*/
import { RiggingSession } from './rigging.js';
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
/** Lane A's real yard (THREADS: "yard layout is now FACT"), trimmed to what the economy needs. */
export const ANCHORS = [
{ id: 'h1', type: 'house', pos: { x: -5, y: 2.6, z: -9.9 } },
{ id: 'h2', type: 'house', pos: { x: 0, y: 2.6, z: -9.9 } },
{ id: 'h3', type: 'house', pos: { x: 5, y: 2.6, z: -9.9 } },
{ id: 't1', type: 'tree', pos: { x: -9, y: 3.2, z: 2 } },
{ id: 't2', type: 'tree', pos: { x: 8, y: 3.1, z: -2 } },
{ id: 'p1', type: 'post', pos: { x: -6.4, y: 3.9, z: 7.4 } },
{ id: 'p2', type: 'post', pos: { x: 5.3, y: 3.9, z: 8 } },
].map((a) => ({ ...a, sway: () => a.pos }));
const session = () => new RiggingSession({ anchors: ANCHORS });
const TESTS = [];
const test = (name, fn) => TESTS.push([name, fn]);
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
test('rigging four corners charges the cheapest hardware each', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) assert(s.rig(id).ok, `rig ${id} failed`);
assert(s.budget === START_BUDGET - 4 * CARABINER.cost, `budget $${s.budget}`);
assert(s.canStart, 'four corners should be startable');
return `$${START_BUDGET} -> $${s.budget} after four carabiners`;
});
test('a sail has four corners, not five', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
const r = s.rig('t1');
assert(!r.ok && r.reason === 'a sail has four corners', `fifth corner allowed: ${JSON.stringify(r)}`);
assert(s.budget === START_BUDGET - 4 * CARABINER.cost, 'refused corner should not be charged');
return 'fifth pick refused and not charged';
});
test('hardware cycles up, charging only the difference', () => {
const s = session();
s.rig('h1');
assert(s.cycleHardware('h1').ok, 'cycle to shackle failed');
assert(s.pickOf('h1').hw === SHACKLE, 'expected shackle');
assert(s.budget === START_BUDGET - SHACKLE.cost, `budget $${s.budget} should be $${START_BUDGET - SHACKLE.cost}`);
s.cycleHardware('h1');
assert(s.pickOf('h1').hw === RATED, 'expected rated shackle');
assert(s.budget === START_BUDGET - RATED.cost, `budget $${s.budget}`);
return `carabiner -> shackle -> rated, paid $${RATED.cost} total`;
});
test('cycling past the top tier wraps and refunds', () => {
const s = session();
s.rig('h1');
s.cycleHardware('h1'); s.cycleHardware('h1'); // -> rated
s.cycleHardware('h1'); // -> wraps to carabiner
assert(s.pickOf('h1').hw === CARABINER, 'expected wrap back to carabiner');
assert(s.budget === START_BUDGET - CARABINER.cost, `budget $${s.budget} — wrap should refund the difference`);
return `wrapped and refunded back to $${s.budget}`;
});
test('unrig refunds exactly what the corner cost', () => {
const s = session();
s.rig('h1');
s.cycleHardware('h1'); s.cycleHardware('h1'); // rated, $30
assert(s.unrig('h1').ok, 'unrig failed');
assert(s.budget === START_BUDGET, `budget $${s.budget} should be back to $${START_BUDGET}`);
assert(!s.isRigged('h1'), 'h1 should be free again');
return 'full refund, no leak';
});
test('spares cost real money and refund', () => {
const s = session();
assert(s.setSpares(1).ok, 'buying a spare failed');
assert(s.budget === START_BUDGET - SPARE_COST, `budget $${s.budget}`);
s.setSpares(0);
assert(s.budget === START_BUDGET && s.spares === 0, 'selling the spare back should restore budget');
return `spare costs $${SPARE_COST}, refunds clean`;
});
test('budget is a real wall', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); // $20, $60 left
s.cycleHardware('h1'); s.cycleHardware('h1'); // -> rated, $25 more, $35 left
s.cycleHardware('h3'); s.cycleHardware('h3'); // -> rated, $25 more, $10 left
s.cycleHardware('p1'); // -> shackle, $10, $0 left
const broke = s.cycleHardware('p2');
assert(!broke.ok && broke.reason === 'not enough budget', `overspend allowed: ${JSON.stringify(broke)}`);
assert(s.budget === 0, `budget $${s.budget}`);
assert(s.pickOf('p2').hw === CARABINER, 'refused upgrade should not have applied');
return 'refused the upgrade that would have gone negative';
});
// DESIGN.md: "good hardware everywhere is unaffordable. You *will* field one
// dodgy corner — the game is choosing which one." If this ever passes, the
// central economic tension of the game is gone and the budget is decoration.
// contracts.js's HARDWARE comment names this as the shape retuning had to keep.
test('you cannot afford good hardware on all four corners', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
let upgraded = 0;
for (const id of ['h1', 'h3', 'p1', 'p2']) if (s.setHardware(id, RATED).ok) upgraded++;
assert(upgraded < 4, `all four corners got rated shackles with $${START_BUDGET} — no compromise left to make`);
assert(upgraded >= 2, `only ${upgraded} rated corners affordable — budget may be too tight to be interesting`);
return `$${START_BUDGET} buys ${upgraded}/4 rated corners, then you are choosing your weak link`;
});
test('picks come back ring-ordered however you click them', () => {
const s = session();
// deliberately crossing order: two diagonals first
for (const id of ['h1', 'p2', 'h3', 'p1']) s.rig(id);
const ids = s.picks.map((p) => p.anchorId);
// a valid ring puts h1 opposite p2 (they are diagonal across the yard)
const opposite = ids[(ids.indexOf('h1') + 2) % 4];
assert(opposite === 'p2', `h1 should sit opposite p2 in the ring, got ${ids.join(',')}`);
return `clicked h1,p2,h3,p1 -> ring ${ids.join(' -> ')}`;
});
test('tension clamps to the rigging range', () => {
const s = session();
assert(s.setTension(99) === TENSION_MAX, 'over-tight should clamp');
assert(s.setTension(0) === TENSION_MIN, 'over-loose should clamp');
s.setTension(1.15);
assert(s.tension === 1.15, 'in-range tension should pass through');
return `clamped to ${TENSION_MIN}..${TENSION_MAX}`;
});
test('commit hands a working rig to the sim', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
s.setHardware('h1', RATED);
s.setTension(1.1);
const rig = s.commit(new SailRig({ anchors: ANCHORS }));
assert(rig.rigged, 'rig should be rigged');
assert(rig.corners.length === 4, 'rig should have four corners');
assert(rig.tension === 1.1, `rig tension ${rig.tension}`);
assert(rig.corners.find((c) => c.anchorId === 'h1').hw === RATED, 'h1 should have carried its rated shackle into the sim');
const wind = { sample: () => ({ x: 0, y: 0, z: 12 }) };
for (let i = 0; i < 240; i++) rig.step(1 / 60, wind, i / 60);
assert(rig.corners.every((c) => Number.isFinite(c.load)), 'committed rig went NaN');
return `committed and stepped 4 s clean over the real yard, max load ${(rig.maxLoad() / 1000).toFixed(2)} kN`;
});
test('commit refuses an unfinished rig', () => {
const s = session();
s.rig('h1'); s.rig('h3');
let threw = false;
try { s.commit(new SailRig({ anchors: ANCHORS })); } catch { threw = true; }
assert(threw, 'committing two corners should throw');
return 'two corners refused';
});
test('summary names the weak link for the HUD', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
s.setHardware('h1', RATED); s.setHardware('h3', SHACKLE); s.setHardware('p1', SHACKLE);
const sum = s.summary;
assert(sum.weakest === 'p2', `weakest should be the lone carabiner p2, got ${sum.weakest}`);
assert(sum.corners.length === 4, 'summary should list four corners');
return `weak link flagged: ${sum.weakest}, $${sum.budget} left`;
});
export const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() {
const results = TESTS.map(([name, fn]) => {
try { return { name, pass: true, detail: fn() || '' }; }
catch (e) { return { name, pass: false, detail: e.message }; }
});
return { pass: results.every((r) => r.pass), results };
}
function report(out) {
const lines = out.results.map(
(r) => `${r.pass ? 'PASS' : 'FAIL'} ${r.name}${r.detail ? `\n ${r.detail}` : ''}`
);
return `${lines.join('\n')}\n\n${out.pass ? 'ALL GREEN' : 'FAILURES'}${out.results.filter((r) => r.pass).length}/${out.results.length}`;
}
if (typeof process !== 'undefined' && process.versions?.node && import.meta.filename === process.argv[1]) {
const out = runRiggingSelftest();
console.log(report(out));
process.exit(out.pass ? 0 : 1);
}
export { report };

635
web/world/js/sail.js Normal file
View File

@ -0,0 +1,635 @@
/**
* sail.js shade sail cloth simulation, corner loads, hardware failure. [Lane B]
*
* A 3D verlet cloth on a bilinear patch between 4 anchors. Wind pressure is
* applied per FACE, not per node, which is the whole point: a twisted (hypar)
* sail turns most of its faces edge-on to the wind and sheds load, while a flat
* one presents every face square-on and catches everything. That difference is
* the game's thesis and it is asserted in sail.selftest.js.
*
* Units are SI throughout: metres, kilograms, seconds, newtons. Corner loads
* come out in real newtons and hardware ratings are real working load limits,
* so a 5x5 m sail in a 34 m/s storm genuinely puts ~1-4 kN on a corner which
* is genuinely why real shade sails use 3 kN+ shackles.
*
* The sim core holds no THREE types: nodes are plain Float64Arrays, so the hot
* loop allocates nothing, replays bit-for-bit, and runs headless under node
* (see sail.selftest.js) as well as in Lane A's selftest.html. three.js only
* appears in createSailView(), which is imported lazily.
*/
import { Emitter, FIXED_DT, HARDWARE } from './contracts.js';
export { HARDWARE };
// ---------- sim tunables ----------
const SIM_DT = FIXED_DT; // sim always steps at a fixed rate; step() accumulates
const MAX_SUBSTEPS = 5; // spiral-of-death guard when the frame hitches
const RELAX_ITERS = 5; // FABRIC_K is calibrated against this; changing it rescales loads
const GRAVITY = -9.81;
// ---------- aerodynamics ----------
// 0.5 * air density (1.225) * flat-plate drag coefficient (~1.4).
// Newtons per m^2 of face area per (m/s)^2 of normal-on airflow.
const PRESSURE_COEFF = 0.86;
const TANGENT_COEFF = 0.02; // skin friction dragging along the face
const MAX_NORMAL_SPEED = 45; // clamp on the normal-on component, m/s — stability in extreme gusts
// ---------- fabric ----------
const FABRIC_DENSITY = 0.32; // kg/m^2, typical knitted shade cloth
// Axial stiffness of one grid spring, N/m — roughly E*t*width/length for
// knitted HDPE mesh. Fed to the solver as a compliance (1/k), not used to
// convert stretch into force: see _measureLoads for why that distinction is
// the whole ballgame.
const FABRIC_K = 100000;
const K_COMPRESS = 0.08; // cloth resists stretch hard, compression barely (from prototype)
const K_BEND = 0.04;
const COMP_STRETCH = 1 / FABRIC_K;
const COMP_COMPRESS = 1 / (FABRIC_K * K_COMPRESS);
const COMP_BEND = 1 / (FABRIC_K * K_BEND);
const VEL_DAMP = 0.995; // light; relative-wind drag supplies the real damping
// ---------- failure ----------
const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
const LOAD_TAU = 0.11; // load meter smoothing time constant, s
export const TENSION_MIN = 0.6;
export const TENSION_MAX = 1.4;
/**
* How much pre-strain the tension dial actually commands, per unit of dial.
* Dial 1.0 is neutral (rest length = as-cut), 1.4 is drum tight, 0.6 is loose.
*
* The prototype used `rest = rest / tension`, which on its 2D arbitrary scale
* was harmless. In real newtons it is not: it asks for 17% pre-strain at dial
* 1.2 and 29% at 1.4 i.e. stretching an 18 m sail by three metres and it
* put 68 kN on a corner of the real yard's biggest quad before any wind blew.
*
* 0.10 puts dial 1.4 at 4% pre-strain. Measured: it swings a 5x5 m rig's peak
* load 2.1x from loose to tight, so the dial is a real decision; and it redlines
* the yard's 192 m2 quad at 8.3 kN drum-tight, which blows even a rated shackle
* correctly, because you cannot drum-tighten 192 m2 of cloth on $30 of
* hardware. The load bars show that during prep, which is where it should be
* learned.
*/
const PRE_STRAIN = 0.10;
const TRIM_MIN = 0.85;
const TRIM_MAX = 1.15;
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Order 4 anchors into a non-self-intersecting ring by angle around their
* centroid, projected onto the ground plane. Ported from the prototype's
* orderRing; without it, picking corners in a silly order knots the sail.
*/
export function orderRing(anchors) {
const n = anchors.length;
let cx = 0, cz = 0;
for (const a of anchors) { cx += a.pos.x; cz += a.pos.z; }
cx /= n; cz /= n;
return [...anchors].sort(
(a, b) => Math.atan2(a.pos.z - cz, a.pos.x - cx) - Math.atan2(b.pos.z - cz, b.pos.x - cx)
);
}
export class SailRig {
/**
* @param {object} opts
* @param {Array} opts.anchors world.anchors see contracts.js Anchor
* @param {number} opts.gridN nodes per side (default 10)
* @param {number} opts.porosity 0 = solid membrane, ~0.3 = knitted shade cloth (blows through, less load)
*/
constructor({ anchors = [], gridN = 10, porosity = 0 } = {}) {
this.anchors = anchors;
this.N = gridN;
this.porosity = porosity;
this.corners = [];
/** Emits 'break' and 'repair' as {type, corner} — contracts.js SailRig. */
this.events = new Emitter();
this.tension = 1.0;
this.t = 0;
this.rigged = false;
this._acc = 0;
// scratch, reused every face to keep the hot loop allocation-free
this._probe = { x: 0, y: 0, z: 0 };
}
/**
* Rig the sail across 4 anchors.
* @param {string[]} anchorIds 4 anchor ids; reordered into a ring internally
* @param {object[]} hwChoices hardware per anchor id, same order as anchorIds
* @param {number} tension 0.6 (loose, flogs) .. 1.4 (drum tight, shock-loads)
*/
attach(anchorIds, hwChoices, tension = 1.0) {
if (anchorIds.length !== 4) throw new Error(`sail needs exactly 4 corners, got ${anchorIds.length}`);
const picked = anchorIds.map((id) => {
const a = this.anchors.find((x) => x.id === id);
if (!a) throw new Error(`unknown anchor "${id}"`);
return a;
});
const hwById = new Map(anchorIds.map((id, i) => [id, hwChoices[i] || HARDWARE[0]]));
const ring = orderRing(picked);
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
this.corners = ring.map((a) => ({
anchorId: a.id,
anchor: a,
hw: hwById.get(a.id),
load: 0,
peakLoad: 0,
overload: 0,
broken: false,
trim: 1.0,
loadVec: { x: 0, y: 0, z: 0 }, // reaction direction, not just magnitude — see _measureLoads
}));
this._build(ring);
this.rigged = true;
return this;
}
_build(ring) {
const N = this.N;
const nodeCount = N * N;
this.pos = new Float64Array(nodeCount * 3);
this.prev = new Float64Array(nodeCount * 3);
this.force = new Float64Array(nodeCount * 3);
this.invMass = new Float64Array(nodeCount);
// Bilinear patch across the 4 corners. Because the anchors sit at different
// heights, this initial surface is already a hypar — the sim just relaxes it.
const [c0, c1, c2, c3] = ring.map((a) => a.pos);
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
const fu = u / (N - 1), fv = v / (N - 1);
const i = (v * N + u) * 3;
for (let k = 0; k < 3; k++) {
const ax = ['x', 'y', 'z'][k];
const top = (1 - fu) * c0[ax] + fu * c1[ax];
const bot = (1 - fu) * c3[ax] + fu * c2[ax];
this.pos[i + k] = this.prev[i + k] = (1 - fv) * top + fv * bot;
}
}
}
const idx = (u, v) => v * N + u;
this.cornerIdx = [idx(0, 0), idx(N - 1, 0), idx(N - 1, N - 1), idx(0, N - 1)];
// springs: structural + shear carry load; bend only resists folding
this.springs = [];
const link = (a, b, kind) => {
const ax = a * 3, bx = b * 3;
const dx = this.pos[bx] - this.pos[ax];
const dy = this.pos[bx + 1] - this.pos[ax + 1];
const dz = this.pos[bx + 2] - this.pos[ax + 2];
this.springs.push({ a, b, restBase: Math.hypot(dx, dy, dz), rest: 0, kind });
};
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
if (u < N - 1) link(idx(u, v), idx(u + 1, v), 'struct');
if (v < N - 1) link(idx(u, v), idx(u, v + 1), 'struct');
if (u < N - 1 && v < N - 1) {
link(idx(u, v), idx(u + 1, v + 1), 'shear');
link(idx(u + 1, v), idx(u, v + 1), 'shear');
}
if (u < N - 2) link(idx(u, v), idx(u + 2, v), 'bend');
if (v < N - 2) link(idx(u, v), idx(u, v + 2), 'bend');
}
}
// XPBD Lagrange multipliers, one per spring, reset every substep
this.lambda = new Float64Array(this.springs.length);
// Springs meeting each corner, kept as {spring, index} so the load meter can
// look up each one's multiplier. Bend springs are included: the hardware
// physically carries every element that touches it, and leaving them out
// under-reports the reaction and breaks the statics balance.
this.cornerSprings = this.cornerIdx.map((ci) =>
this.springs
.map((s, si) => ({ s, si }))
.filter(({ s }) => s.a === ci || s.b === ci)
);
// triangles: wind acts per face, and coverage raycasts against these
this.tris = new Uint16Array((N - 1) * (N - 1) * 6);
let ti = 0;
for (let v = 0; v < N - 1; v++) {
for (let u = 0; u < N - 1; u++) {
const a = idx(u, v), b = idx(u + 1, v), c = idx(u + 1, v + 1), d = idx(u, v + 1);
this.tris[ti++] = a; this.tris[ti++] = b; this.tris[ti++] = c;
this.tris[ti++] = a; this.tris[ti++] = c; this.tris[ti++] = d;
}
}
// grid-space proximity of every node to each corner, for per-corner trim
this._cornerWeight = [];
for (let k = 0; k < 4; k++) {
const cu = [0, N - 1, N - 1, 0][k], cv = [0, 0, N - 1, N - 1][k];
const w = new Float64Array(nodeCount);
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
const dist = Math.hypot(u - cu, v - cv) / (N - 1);
w[idx(u, v)] = Math.max(0, 1 - dist);
}
}
this._cornerWeight.push(w);
}
this.area = this._surfaceArea();
const mass = (FABRIC_DENSITY * this.area) / nodeCount;
this.nodeMass = mass;
this.invMass.fill(1 / mass);
this._applyRestLengths();
this._repin(0);
}
/** Rest lengths shrink as the tension dial rises, modulated per corner by trim. */
_applyRestLengths() {
for (const s of this.springs) {
let wsum = 0, tsum = 0;
for (let k = 0; k < 4; k++) {
const w = this._cornerWeight[k][s.a] + this._cornerWeight[k][s.b];
wsum += w;
tsum += w * this.corners[k].trim;
}
const trim = wsum > 1e-9 ? tsum / wsum : 1;
s.rest = s.restBase * (1 - PRE_STRAIN * (this.tension * trim - 1));
}
}
/** Pinned corners are infinite-mass so springs stretch honestly against them. */
_repin(t) {
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
const ci = this.cornerIdx[k];
if (c.broken) {
this.invMass[ci] = 1 / this.nodeMass; // freed node — flogging falls out of this
continue;
}
this.invMass[ci] = 0;
const p = this._anchorPos(c.anchor, t);
this.pos[ci * 3] = p.x; this.pos[ci * 3 + 1] = p.y; this.pos[ci * 3 + 2] = p.z;
this.prev[ci * 3] = p.x; this.prev[ci * 3 + 1] = p.y; this.prev[ci * 3 + 2] = p.z;
}
}
/**
* Where a corner is pinned right now. `sway(t)` is the ABSOLUTE world
* position, not an offset from `pos` (contracts.js Anchor; Lane A called this
* out in THREADS). House and post anchors return a constant; tree anchors
* wander, and that wander is dynamic load the reason a tree is the scary
* anchor. The returned vector is shared and reused between calls, so read it
* immediately and never store it.
*/
_anchorPos(a, t) {
return a.sway ? a.sway(t) : a.pos;
}
_surfaceArea() {
let total = 0;
for (let i = 0; i < this.tris.length; i += 3) {
const a = this.tris[i] * 3, b = this.tris[i + 1] * 3, c = this.tris[i + 2] * 3;
const e1x = this.pos[b] - this.pos[a], e1y = this.pos[b + 1] - this.pos[a + 1], e1z = this.pos[b + 2] - this.pos[a + 2];
const e2x = this.pos[c] - this.pos[a], e2y = this.pos[c + 1] - this.pos[a + 1], e2z = this.pos[c + 2] - this.pos[a + 2];
const nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
total += Math.hypot(nx, ny, nz) * 0.5;
}
return total;
}
/**
* Advance the sim. Accumulates real time and burns it in fixed SIM_DT chunks,
* so a variable-rate render loop and a fast-forwarded selftest produce
* identical traces. Never reads a clock.
*
* @param {number} dt seconds elapsed since last call
* @param {object} wind { sample(pos, t) -> {x,y,z} }
* @param {number} t world time, seconds
*/
step(dt, wind, t) {
if (!this.rigged) return;
this._acc += dt;
let n = 0;
while (this._acc >= SIM_DT && n < MAX_SUBSTEPS) {
this._substep(SIM_DT, wind, this.t);
this._acc -= SIM_DT;
this.t += SIM_DT;
n++;
}
if (n === MAX_SUBSTEPS) this._acc = 0; // dropped frames: don't try to catch up
}
_substep(dt, wind, t) {
this._accumulateWind(wind, t, dt);
this._integrate(dt);
this.lambda.fill(0); // XPBD multipliers are per-substep
for (let i = 0; i < RELAX_ITERS; i++) this._relax(dt * dt);
this._pinCorners(t);
this._measureLoads(dt);
this._checkFailure(dt);
}
/** Wind force per FACE — the hypar mechanic lives here. */
_accumulateWind(wind, t, dt) {
const pos = this.pos, prev = this.prev, F = this.force;
F.fill(0);
const coeff = PRESSURE_COEFF * (1 - this.porosity);
const tanCoeff = TANGENT_COEFF * (1 - this.porosity);
const invDt = 1 / dt;
const probe = this._probe;
for (let i = 0; i < this.tris.length; i += 3) {
const ia = this.tris[i] * 3, ib = this.tris[i + 1] * 3, ic = this.tris[i + 2] * 3;
const e1x = pos[ib] - pos[ia], e1y = pos[ib + 1] - pos[ia + 1], e1z = pos[ib + 2] - pos[ia + 2];
const e2x = pos[ic] - pos[ia], e2y = pos[ic + 1] - pos[ia + 1], e2z = pos[ic + 2] - pos[ia + 2];
// |cross| is twice the area and its direction is the face normal
let nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
const len = Math.hypot(nx, ny, nz);
if (len < 1e-9) continue;
const area = len * 0.5;
nx /= len; ny /= len; nz /= len;
probe.x = (pos[ia] + pos[ib] + pos[ic]) / 3;
probe.y = (pos[ia + 1] + pos[ib + 1] + pos[ic + 1]) / 3;
probe.z = (pos[ia + 2] + pos[ib + 2] + pos[ic + 2]) / 3;
const w = wind.sample(probe, t);
// Relative wind, not absolute: as the cloth accelerates downwind the load
// bleeds off by itself. This is what stops flogging from exploding.
const vx = (pos[ia] - prev[ia] + pos[ib] - prev[ib] + pos[ic] - prev[ic]) / 3 * invDt;
const vy = (pos[ia + 1] - prev[ia + 1] + pos[ib + 1] - prev[ib + 1] + pos[ic + 1] - prev[ic + 1]) / 3 * invDt;
const vz = (pos[ia + 2] - prev[ia + 2] + pos[ib + 2] - prev[ib + 2] + pos[ic + 2] - prev[ic + 2]) / 3 * invDt;
const rx = w.x - vx, ry = w.y - vy, rz = w.z - vz;
const d = clamp(rx * nx + ry * ny + rz * nz, -MAX_NORMAL_SPEED, MAX_NORMAL_SPEED);
// d*|d| rather than d^2: keeps the v^2 magnitude but points the force the
// way the wind is actually blowing. A sail is double-sided.
const p = coeff * area * d * Math.abs(d);
const tx = (rx - nx * d) * tanCoeff * area;
const ty = (ry - ny * d) * tanCoeff * area;
const tz = (rz - nz * d) * tanCoeff * area;
const fx = (nx * p + tx) / 3, fy = (ny * p + ty) / 3, fz = (nz * p + tz) / 3;
F[ia] += fx; F[ia + 1] += fy; F[ia + 2] += fz;
F[ib] += fx; F[ib + 1] += fy; F[ib + 2] += fz;
F[ic] += fx; F[ic + 1] += fy; F[ic + 2] += fz;
}
}
_integrate(dt) {
const pos = this.pos, prev = this.prev, F = this.force, im = this.invMass;
const dt2 = dt * dt;
for (let n = 0; n < im.length; n++) {
if (im[n] === 0) continue; // pinned
const i = n * 3;
for (let k = 0; k < 3; k++) {
const a = F[i + k] * im[n] + (k === 1 ? GRAVITY : 0);
const x = pos[i + k];
const nx = x + (x - prev[i + k]) * VEL_DAMP + a * dt2;
prev[i + k] = x;
pos[i + k] = nx;
}
}
}
/**
* XPBD constraint solve. The plain-PBD version of this is simpler, but its
* position corrections carry no force information the leftover stretch
* after a fixed iteration count is solver error, not fabric strain, so
* reading load off it measures the solver. XPBD's Lagrange multiplier lambda
* is the real constraint impulse, so lambda/dt^2 is a genuine newton value
* and the corner reactions balance the applied wind by construction.
*/
_relax(dt2) {
const pos = this.pos, im = this.invMass, lam = this.lambda;
for (let si = 0; si < this.springs.length; si++) {
const s = this.springs[si];
const wa = im[s.a], wb = im[s.b];
const w = wa + wb;
if (w === 0) continue; // both ends pinned
const ia = s.a * 3, ib = s.b * 3;
const dx = pos[ib] - pos[ia], dy = pos[ib + 1] - pos[ia + 1], dz = pos[ib + 2] - pos[ia + 2];
const d = Math.hypot(dx, dy, dz);
if (d < 1e-9) continue;
const C = d - s.rest;
const compliance = s.kind === 'bend' ? COMP_BEND : C > 0 ? COMP_STRETCH : COMP_COMPRESS;
const at = compliance / dt2;
const dLambda = (-C - at * lam[si]) / (w + at);
lam[si] += dLambda;
// grad C is -n for node a and +n for node b, with n = (b - a)/d
const nx = dx / d, ny = dy / d, nz = dz / d;
pos[ia] -= nx * dLambda * wa; pos[ia + 1] -= ny * dLambda * wa; pos[ia + 2] -= nz * dLambda * wa;
pos[ib] += nx * dLambda * wb; pos[ib + 1] += ny * dLambda * wb; pos[ib + 2] += nz * dLambda * wb;
}
}
_pinCorners(t) {
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) continue;
const ci = this.cornerIdx[k] * 3;
const p = this._anchorPos(c.anchor, t);
this.pos[ci] = p.x; this.pos[ci + 1] = p.y; this.pos[ci + 2] = p.z;
}
}
/**
* Corner load = magnitude of the VECTOR sum of the tensions in the springs
* meeting that corner, each read off its XPBD multiplier as |lambda|/dt^2.
*
* Reading tension as FABRIC_K * leftover-stretch instead looks equivalent and
* is not: after a fixed 5 iterations the leftover stretch is solver error, so
* that number measures the solver rather than the fabric and comes out ~50x
* hot. The multiplier is the actual constraint impulse, which is why the
* statics assert balances.
*
* The vector sum (rather than a scalar total) is what
* makes DESIGN.md's anchor-angle mechanic fall out for free: edges pulling in
* nearly the same direction add up, edges pulling apart partly cancel so a
* pinched corner really does multiply its own load.
*/
_measureLoads(dt) {
const pos = this.pos, lam = this.lambda;
const invDt2 = 1 / (dt * dt);
const alpha = 1 - Math.exp(-dt / LOAD_TAU);
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) { c.load = 0; c.loadVec.x = c.loadVec.y = c.loadVec.z = 0; continue; }
const ci = this.cornerIdx[k], cix = ci * 3;
let sx = 0, sy = 0, sz = 0;
for (const { s, si } of this.cornerSprings[k]) {
if (lam[si] >= 0) continue; // slack or compressed fabric pulls on nothing
const tension = -lam[si] * invDt2; // the multiplier IS the impulse; /dt^2 makes it newtons
const o = (s.a === ci ? s.b : s.a) * 3;
const dx = pos[o] - pos[cix], dy = pos[o + 1] - pos[cix + 1], dz = pos[o + 2] - pos[cix + 2];
const d = Math.hypot(dx, dy, dz);
if (d < 1e-9) continue;
sx += (dx / d) * tension; sy += (dy / d) * tension; sz += (dz / d) * tension;
}
c.loadVec.x += (sx - c.loadVec.x) * alpha;
c.loadVec.y += (sy - c.loadVec.y) * alpha;
c.loadVec.z += (sz - c.loadVec.z) * alpha;
const raw = Math.hypot(sx, sy, sz);
c.load += (raw - c.load) * alpha;
if (c.load > c.peakLoad) c.peakLoad = c.load;
}
}
/** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
_checkFailure(dt) {
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) continue;
if (c.load > c.hw.rating) c.overload += dt;
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
if (c.overload > OVERLOAD_SECS) {
c.broken = true;
c.overload = 0;
c.load = 0;
// Hand the node its mass back. Everything good about a failure comes
// from this one line: the freed corner stops being pinned, so it flies
// on the wind and the flogging is emergent rather than animated.
// Without it a "blown" corner stays welded in mid-air.
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
}
}
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
}
/** Re-rig a blown corner with fresh hardware. Lane D's hold-E repair calls this. */
repairCorner(index, hw = HARDWARE[1]) {
const c = this.corners[index];
if (!c || !c.broken) return false;
c.broken = false;
c.hw = hw;
c.load = 0;
c.overload = 0;
this._repin(this.t);
this.events.emit('repair', { type: 'repair', corner: c, anchorId: c.anchorId, hw: hw.name, t: this.t });
return true;
}
/** Turnbuckle trim at ONE corner (Lane D, 1.2 s hold). Tightens/eases locally. */
trimCorner(index, delta) {
const c = this.corners[index];
if (!c) return false;
c.trim = clamp(c.trim + delta, TRIM_MIN, TRIM_MAX);
this._dirtyRest = true;
return true;
}
setTension(tension) {
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
if (this.rigged) this._applyRestLengths();
}
/**
* Ground-projected shade over a rect: the fraction of sample points on the
* rect that the sail blocks from the sun. This IS the shade mechanic, so it
* raycasts toward the real sun rather than projecting straight down which
* is what lets DESIGN.md's moving and seasonal sun change the answer.
*
* @param {object} rect world.gardenBed shape: CENTRE (x,z), size (w,d), metres
* @param {object} sunDir world.sunDir unit vector from the ground TOWARD
* the sun. A hit means shaded. Defaults to overhead.
*/
coverageOver(rect, sunDir = { x: 0, y: 1, z: 0 }) {
if (!this.rigged) return 0;
const len = Math.hypot(sunDir.x, sunDir.y, sunDir.z) || 1;
const dx = sunDir.x / len, dy = sunDir.y / len, dz = sunDir.z / len;
if (dy <= 0.01) return 0; // sun at or below the horizon casts no useful shade
const COLS = 6, ROWS = 4; // prototype sampled 6x4 over the garden
let hit = 0;
for (let i = 0; i < COLS; i++) {
for (let j = 0; j < ROWS; j++) {
// rect is centre-and-size, so samples straddle (rect.x, rect.z)
const ox = rect.x + ((i + 0.5) / COLS - 0.5) * rect.w;
const oz = rect.z + ((j + 0.5) / ROWS - 0.5) * rect.d;
if (this._rayHitsSail(ox, 0, oz, dx, dy, dz)) hit++;
}
}
return hit / (COLS * ROWS);
}
/** Moller-Trumbore against every face; 162 tris, cheap enough to not bother accelerating. */
_rayHitsSail(ox, oy, oz, dx, dy, dz) {
const pos = this.pos;
for (let i = 0; i < this.tris.length; i += 3) {
const a = this.tris[i] * 3, b = this.tris[i + 1] * 3, c = this.tris[i + 2] * 3;
const e1x = pos[b] - pos[a], e1y = pos[b + 1] - pos[a + 1], e1z = pos[b + 2] - pos[a + 2];
const e2x = pos[c] - pos[a], e2y = pos[c + 1] - pos[a + 1], e2z = pos[c + 2] - pos[a + 2];
const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x;
const det = e1x * px + e1y * py + e1z * pz;
if (Math.abs(det) < 1e-9) continue; // ray parallel to the face
const inv = 1 / det;
const tx = ox - pos[a], ty = oy - pos[a + 1], tz = oz - pos[a + 2];
const u = (tx * px + ty * py + tz * pz) * inv;
if (u < 0 || u > 1) continue;
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
const v = (dx * qx + dy * qy + dz * qz) * inv;
if (v < 0 || u + v > 1) continue;
const hitT = (e2x * qx + e2y * qy + e2z * qz) * inv;
if (hitT > 1e-6) return true;
}
return false;
}
/** Sum of the aerodynamic + weight force on the whole sail, N. Used by the statics assert. */
netAppliedForce(wind, t) {
this._accumulateWind(wind, t, SIM_DT);
let fx = 0, fy = 0, fz = 0;
for (let n = 0; n < this.invMass.length; n++) {
fx += this.force[n * 3];
fy += this.force[n * 3 + 1] + GRAVITY * this.nodeMass;
fz += this.force[n * 3 + 2];
}
return { x: fx, y: fy, z: fz };
}
maxLoad() {
let m = 0;
for (const c of this.corners) if (c.load > m) m = c.load;
return m;
}
}
/**
* three.js view over a rig. Imported lazily so the sim core above stays
* headless-runnable; call this only from the browser, after Lane A's vendor/
* exists. Returns a THREE.Group to add to the scene, with update() per frame.
*/
export async function createSailView(rig, { color = 0xd8c48a } = {}) {
const THREE = await import('../vendor/three.module.js');
const geo = new THREE.BufferGeometry();
const verts = new Float32Array(rig.pos.length);
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
geo.setIndex(new THREE.BufferAttribute(new Uint16Array(rig.tris), 1));
const mat = new THREE.MeshStandardMaterial({
color, side: THREE.DoubleSide, roughness: 0.92, metalness: 0.0,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true; // the shadow IS the product
mesh.receiveShadow = true;
mesh.frustumCulled = false; // it flogs well outside its initial bounds
const group = new THREE.Group();
group.add(mesh);
group.update = () => {
verts.set(rig.pos);
geo.attributes.position.needsUpdate = true;
geo.computeVertexNormals();
geo.computeBoundingSphere();
};
group.update();
return group;
}

View File

@ -0,0 +1,370 @@
/**
* sail.selftest.js assert suite for the sail sim. [Lane B]
*
* Exports SAIL_TESTS as plain [name, fn] pairs so ONE set of asserts runs in
* two harnesses: Lane A's selftest.html (via js/tests/b.test.js) and node
* (`node web/world/js/sail.selftest.js`) for fast iteration without a browser.
* Drives time with fixed-dt loops only never rAF, never a clock.
*
* The headline assert is `hypar sheds load vs flat`: it is the game's thesis
* stated as a test. If it ever goes red, the sail has stopped being a sail.
*/
import { SailRig } from './sail.js';
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
const SIM_DT = FIXED_DT;
// ---------- deterministic stub wind ----------
// contracts.js ships createStubWind(), and the integration test below uses it.
// This local one exists only because the thesis needs the wind DIRECTION swept,
// which the shared stub does not expose. Lane C's weather.js replaces both.
function makeStubWind({ seed = 7, stormLen = 90, dir = { x: 0, y: 0, z: 1 }, calm = false } = {}) {
const rand = rng(seed);
const gusts = [];
for (let t = 3; t < stormLen; t += 5 + rand() * 7) {
gusts.push({ start: t, pow: 12 + rand() * 16 + 10 * (t / stormLen) });
}
const len = Math.hypot(dir.x, dir.y, dir.z) || 1;
const dx = dir.x / len, dy = dir.y / len, dz = dir.z / len;
const out = { x: 0, y: 0, z: 0 };
return {
speedAt(t) {
if (calm) return 0;
let speed = 8 + 26 * Math.min(1, (t / stormLen) * 1.6);
for (const g of gusts) {
const gt = t - g.start;
if (gt < 0 || gt >= 5) continue;
if (gt < 1.5) continue; // telegraph: seen, not felt
else if (gt < 2.3) speed += g.pow * (gt - 1.5) / 0.8; // ramp
else if (gt < 4.0) speed += g.pow; // hold
else speed += g.pow * (5.0 - gt); // fade
}
return speed;
},
sample(pos, t) {
const s = this.speedAt(t);
out.x = dx * s; out.y = dy * s; out.z = dz * s;
return out;
},
gustTelegraph: () => null,
};
}
const constantWind = (v) => ({ sample: () => v, speedAt: () => Math.hypot(v.x, v.y, v.z), gustTelegraph: () => null });
// ---------- test rigs ----------
// Same 5x5 m footprint, same multiset of corner heights {4.0, 4.0, 2.5, 2.5}.
// Only the ARRANGEMENT differs: coplanar (flat, pitched) vs permuted (twisted
// hypar). Any load difference is therefore purely geometry, nothing else.
const FOOT = [
{ x: -2.5, z: -2.5 }, { x: 2.5, z: -2.5 }, { x: 2.5, z: 2.5 }, { x: -2.5, z: 2.5 },
];
export const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5]; // y linear in z -> one plane
export const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle
/** Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position. */
export const makeAnchors = (heights) =>
FOOT.map((f, i) => {
const pos = { x: f.x, y: heights[i], z: f.z };
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
});
const ALL_IDS = ['a0', 'a1', 'a2', 'a3'];
const UNBREAKABLE = { name: 'test rig', cost: 0, rating: Infinity };
function rig(heights, { hw = UNBREAKABLE, tension = 1.0, porosity = 0 } = {}) {
return new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity })
.attach(ALL_IDS, [hw, hw, hw, hw], tension);
}
/** Fixed-dt fast-forward. Returns the peak corner load over the whole run, N. */
function runStorm(r, wind, secs, onStep) {
const steps = Math.round(secs / SIM_DT);
let peak = 0;
for (let i = 0; i < steps; i++) {
r.step(SIM_DT, wind, i * SIM_DT);
const m = r.maxLoad();
if (m > peak) peak = m;
if (onStep) onStep(r, i);
}
return peak;
}
const TESTS = [];
const test = (name, fn) => TESTS.push([name, fn]);
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
const kN = (n) => `${(n / 1000).toFixed(2)} kN`;
// ---------- the suite ----------
test('sim stays finite through a full storm', () => {
const r = rig(HEIGHTS_HYPAR);
runStorm(r, makeStubWind({ stormLen: 90 }), 90);
for (const v of r.pos) assert(Number.isFinite(v), 'node position went NaN/Infinity');
for (const c of r.corners) assert(Number.isFinite(c.load), 'corner load went NaN');
return `peak ${kN(r.corners.reduce((m, c) => Math.max(m, c.peakLoad), 0))}`;
});
test('sail sags under gravity when calm', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, makeStubWind({ calm: true }), 6);
const N = r.N, mid = (Math.floor(N / 2) * N + Math.floor(N / 2)) * 3;
const midY = r.pos[mid + 1];
const cornerMeanY = HEIGHTS_FLAT.reduce((a, b) => a + b) / 4;
assert(midY < cornerMeanY, `belly (${midY.toFixed(2)}m) should hang below corner mean (${cornerMeanY}m)`);
return `belly sags ${(cornerMeanY - midY).toFixed(2)} m below corner plane`;
});
// Newton's third law. This is what pins FABRIC_K to real newtons: if the corner
// reactions don't sum to the actual aerodynamic + weight force on the fabric,
// the load meter is lying and every kN rating on it is meaningless.
test('statics: corner reactions balance the applied force', () => {
const w = constantWind({ x: 0, y: 0, z: 18 });
const r = rig(HEIGHTS_FLAT);
runStorm(r, w, 12); // settle
// A membrane in steady wind never fully stops moving, so compare the
// TIME-AVERAGED reaction against the time-averaged applied force. That is the
// momentum balance that must hold; instant by instant it need not.
let n = 0, ax = 0, ay = 0, az = 0, rx = 0, ry = 0, rz = 0;
for (let i = 0; i < Math.round(4 / SIM_DT); i++) {
const t = 12 + i * SIM_DT;
r.step(SIM_DT, w, t);
const f = r.netAppliedForce(w, t);
ax += f.x; ay += f.y; az += f.z;
for (const c of r.corners) { rx += c.loadVec.x; ry += c.loadVec.y; rz += c.loadVec.z; }
n++;
}
ax /= n; ay /= n; az /= n; rx /= n; ry /= n; rz /= n;
const appliedMag = Math.hypot(ax, ay, az);
const err = Math.hypot(rx - ax, ry - ay, rz - az) / appliedMag;
assert(err < 0.2, `reactions ${kN(Math.hypot(rx, ry, rz))} vs applied ${kN(appliedMag)}${(err * 100).toFixed(0)}% out of balance`);
return `applied ${kN(appliedMag)}, reactions ${kN(Math.hypot(rx, ry, rz))}, residual ${(err * 100).toFixed(1)}%`;
});
// THE THESIS. A twisted sail resists bellying into one coherent pocket, so its
// worst moment is gentler than a flat sail's worst moment.
//
// Scored on WORST CASE over wind direction, not per-direction. Lane C's storms
// veer, so the player never gets to choose the wind, and worst-case is what the
// hardware actually has to survive. Per-direction would be a false assert: a
// flat sail sitting edge-on to the wind genuinely does have low drag, and from
// that one angle it beats the hypar. Demanding otherwise would mean tuning the
// sim into a lie.
test('hypar sheds load vs flat, worst case over wind direction (the thesis)', () => {
const DIRS = [
{ name: 'N', x: 0, z: 1 }, { name: 'NE', x: 0.707, z: 0.707 },
{ name: 'E', x: 1, z: 0 }, { name: 'SE', x: 0.707, z: -0.707 },
{ name: 'S', x: 0, z: -1 }, { name: 'SW', x: -0.707, z: -0.707 },
{ name: 'W', x: -1, z: 0 }, { name: 'NW', x: -0.707, z: 0.707 },
];
const sweep = (heights) => {
let worst = 0, at = '';
for (const d of DIRS) {
const storm = makeStubWind({ seed: 7, stormLen: 45, dir: { x: d.x, y: 0, z: d.z } });
const p = runStorm(rig(heights), storm, 45);
if (p > worst) { worst = p; at = d.name; }
}
return { worst, at };
};
const flat = sweep(HEIGHTS_FLAT);
const hypar = sweep(HEIGHTS_HYPAR);
assert(
hypar.worst < flat.worst * 0.8,
`hypar worst ${kN(hypar.worst)} (${hypar.at}) should be well under flat worst ${kN(flat.worst)} (${flat.at})`
);
return `flat worst ${kN(flat.worst)} from ${flat.at} -> hypar worst ${kN(hypar.worst)} from ${hypar.at} (sheds ${((1 - hypar.worst / flat.worst) * 100).toFixed(0)}%)`;
});
test('cascade: losing a corner spikes its neighbours', () => {
const w = constantWind({ x: 0, y: 0, z: 22 });
const r = rig(HEIGHTS_HYPAR);
runStorm(r, w, 6); // settle
const before = Math.max(r.corners[1].load, r.corners[3].load);
r.corners[0].broken = true;
r._repin(r.t);
runStorm(r, w, 2.5); // let the load redistribute
const after = Math.max(r.corners[1].load, r.corners[3].load);
assert(after >= before * 2, `neighbour went ${kN(before)} -> ${kN(after)}, wanted >= 2x`);
return `neighbour ${kN(before)} -> ${kN(after)} (${(after / before).toFixed(1)}x)`;
});
test('determinism: identical inputs give byte-equal load traces', () => {
const trace = () => {
const r = rig(HEIGHTS_HYPAR);
const w = makeStubWind({ seed: 3, stormLen: 30 });
const out = [];
runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); });
return out;
};
const a = trace(), b = trace();
assert(a.length === b.length, 'traces differ in length');
for (let i = 0; i < a.length; i++) assert(a[i] === b[i], `sample ${i} diverged: ${a[i]} vs ${b[i]}`);
return `${a.length} load samples identical`;
});
test('determinism: variable frame dt matches fixed dt', () => {
// Lane A's render loop delivers ragged dt. The internal accumulator has to
// absorb that, or nothing the selftest proves applies to the real game.
const w1 = makeStubWind({ seed: 5, stormLen: 20 });
const fixed = rig(HEIGHTS_HYPAR);
for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT);
const w2 = makeStubWind({ seed: 5, stormLen: 20 });
const ragged = rig(HEIGHTS_HYPAR);
const rand = rng(99);
let acc = 0;
while (acc < 20) {
const dt = 0.004 + rand() * 0.02; // 4-24 ms frames
ragged.step(dt, w2, acc);
acc += dt;
}
for (let k = 0; k < 4; k++) {
const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load);
assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N between fixed and ragged dt`);
}
return 'ragged frame times converge on the fixed-dt trace';
});
test('tension dial changes load (drum tight shock-loads)', () => {
const w = constantWind({ x: 0, y: 0, z: 20 });
const loosePeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 0.7 }), w, 8);
const tightPeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 1.35 }), w, 8);
assert(tightPeak > loosePeak, `tight ${kN(tightPeak)} should exceed loose ${kN(loosePeak)}`);
return `loose ${kN(loosePeak)} vs tight ${kN(tightPeak)}`;
});
test('porous shade cloth carries less load than solid membrane', () => {
const w = constantWind({ x: 0, y: 0, z: 20 });
const solid = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0 }), w, 8);
const porous = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0.35 }), w, 8);
assert(porous < solid, `porous ${kN(porous)} should be under solid ${kN(solid)}`);
return `solid ${kN(solid)} vs porous ${kN(porous)}`;
});
test('coverage: sail shades the ground under it, not beside it', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, makeStubWind({ calm: true }), 4);
// world.gardenBed rects are CENTRE + size, so this bed straddles the origin.
const under = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 });
const beside = r.coverageOver({ x: 14, z: 14, w: 4, d: 4 });
assert(under > 0.9, `ground under the sail only ${(under * 100).toFixed(0)}% shaded`);
assert(beside === 0, `ground 14 m away reported ${(beside * 100).toFixed(0)}% shaded`);
return `under sail ${(under * 100).toFixed(0)}%, off to the side ${(beside * 100).toFixed(0)}%`;
});
test('coverage tracks a low sun off to the side', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, makeStubWind({ calm: true }), 4);
const noon = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0, y: 1, z: 0 });
const lowSun = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0.9, y: 0.25, z: 0 });
assert(noon > lowSun, `shadow should slide off the bed as the sun drops (noon ${noon}, low ${lowSun})`);
return `noon ${(noon * 100).toFixed(0)}% -> low sun ${(lowSun * 100).toFixed(0)}%`;
});
// PLAN3D §7 definition of done, in miniature.
test('cheap flat rig cascades; twisted mixed rig survives', () => {
const storm = () => makeStubWind({ seed: 11, stormLen: 90 });
const cheap = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.35 });
runStorm(cheap, storm(), 90);
const cheapBroken = cheap.corners.filter((c) => c.broken).length;
const good = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2], tension: 0.95 });
runStorm(good, storm(), 90);
const goodBroken = good.corners.filter((c) => c.broken).length;
assert(cheapBroken >= 2, `flat drum-tight carabiner rig only lost ${cheapBroken} corners — should cascade`);
assert(goodBroken === 0, `twisted rated-shackle rig lost ${goodBroken} corners — should survive`);
return `cheap flat lost ${cheapBroken}/4, good hypar lost ${goodBroken}/4`;
});
// PLAN3D §5-B: "broken corner frees the node -> flogging is emergent". This
// drives a REAL overload failure rather than setting broken by hand, because
// hand-setting it was exactly what hid the bug where _checkFailure marked a
// corner broken but never gave its node its mass back — so a blown corner
// stayed welded in mid-air and the sail never flogged.
test('a blown corner is freed and flies (flogging is emergent)', () => {
const w = makeStubWind({ seed: 11, stormLen: 90 });
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // cheap and tight: this one lets go
const broke = [];
r.events.on('break', (e) => broke.push(e));
// step until the first corner lets go
let i = 0;
for (const end = Math.round(90 / SIM_DT); i < end && !broke.length; i++) r.step(SIM_DT, w, i * SIM_DT);
assert(broke.length > 0, 'a carabiner rig should have blown a corner somewhere in a 90 s storm');
const k = r.corners.indexOf(broke[0].corner);
const node = r.cornerIdx[k], ci = node * 3;
const anchor = r.corners[k].anchor.pos;
const before = [r.pos[ci], r.pos[ci + 1], r.pos[ci + 2]];
for (let j = 0; j < Math.round(3 / SIM_DT); j++) r.step(SIM_DT, w, (i + j) * SIM_DT);
const moved = Math.hypot(r.pos[ci] - before[0], r.pos[ci + 1] - before[1], r.pos[ci + 2] - before[2]);
const fromAnchor = Math.hypot(r.pos[ci] - anchor.x, r.pos[ci + 1] - anchor.y, r.pos[ci + 2] - anchor.z);
assert(r.invMass[node] > 0, 'blown corner still has infinite mass — it is welded in mid-air, not flogging');
assert(moved > 0.05, `blown corner only drifted ${moved.toFixed(3)} m in 3 s — it is not flogging`);
assert(fromAnchor > 0.2, `blown corner is still ${fromAnchor.toFixed(2)} m from its anchor — it never let go`);
return `corner ${broke[0].anchorId} blew at t=${broke[0].t.toFixed(1)}s, tore ${fromAnchor.toFixed(2)} m off its anchor and is flying`;
});
test('break and repair emit on the events Emitter', () => {
const w = constantWind({ x: 0, y: 0, z: 20 });
const r = rig(HEIGHTS_HYPAR, { hw: UNBREAKABLE });
const seen = [];
r.events.on('break', (e) => seen.push(e));
r.events.on('repair', (e) => seen.push(e));
runStorm(r, w, 4);
r.corners[0].broken = true;
r._repin(r.t);
runStorm(r, w, 1);
assert(r.corners[0].load === 0, 'broken corner should carry no load');
assert(r.repairCorner(0, UNBREAKABLE), 'repairCorner should report success');
runStorm(r, w, 3);
assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
assert(seen.some((e) => e.type === 'repair' && e.corner === r.corners[0]), 'no repair event with {type, corner}');
return `repaired corner back to ${kN(r.corners[0].load)}, ${seen.length} event(s) emitted`;
});
test('runs against the shared contracts.js stub wind', () => {
// Proves the rig eats the sanctioned Wind implementation, not just my local
// stub — so nothing surprises us when Lane C's weather.js drops in.
const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[1] });
const wind = createStubWind({ seed: 1, stormLen: 90 });
const peak = runStorm(r, wind, 90);
for (const v of r.pos) assert(Number.isFinite(v), 'went NaN on the shared stub wind');
assert(peak > 0, 'shared stub wind produced no load at all');
return `90 s on contracts.js stub wind, peak ${kN(peak)}, ${r.corners.filter((c) => c.broken).length}/4 corners lost`;
});
export const SAIL_TESTS = TESTS;
export function runSailSelftest() {
const results = TESTS.map(([name, fn]) => {
try { return { name, pass: true, detail: fn() || '' }; }
catch (e) { return { name, pass: false, detail: e.message }; }
});
return { pass: results.every((r) => r.pass), results };
}
export function report(out) {
const lines = out.results.map(
(r) => `${r.pass ? 'PASS' : 'FAIL'} ${r.name}${r.detail ? `\n ${r.detail}` : ''}`
);
return `${lines.join('\n')}\n\n${out.pass ? 'ALL GREEN' : 'FAILURES'}${out.results.filter((r) => r.pass).length}/${out.results.length}`;
}
// Run only when invoked directly; importing this module must not run the suite.
if (typeof process !== 'undefined' && process.versions?.node && import.meta.filename === process.argv[1]) {
const out = runSailSelftest();
console.log(report(out));
process.exit(out.pass ? 0 : 1);
}
export { makeStubWind };

470
web/world/js/skyfx.js Normal file
View File

@ -0,0 +1,470 @@
'use strict';
// SHADES — Lane C — skyfx: rain, storm sky, lightning, and the noise of it all.
//
// PLAN3D §5-C.2/§5-C.3. Lane A's world.js owns the calm sky and the base lights;
// this MODULATES them as the storm builds and hands them back on dispose(), so
// two lanes never fight over one scene.
//
// Everything is duck-typed and optional — no sun light, no audio, no sail? Then
// those layers just don't run. Lane A can wire the pieces as they land.
//
// Audio is synthesized, not sampled: web/world/audio/ is empty, we ship no CDN
// and no deps, and a filtered-noise bed tracks wind speed better than a loop.
import * as THREE from '../vendor/three.module.js';
import { rng } from './contracts.js';
import { valueNoise2 } from './weather.core.js';
const lerp = (a, b, k) => a + (b - a) * k;
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const CALM_SKY = new THREE.Color(0x9fc4e8);
const STORM_SKY = new THREE.Color(0x2a2f3a);
const NIGHT_SKY = new THREE.Color(0x11141c);
// ---------------------------------------------------------------- rain
function createRain(opts) {
const max = opts.maxDrops ?? 3000;
const half = opts.half ?? 18; // box half-extent around the camera
const height = opts.height ?? 24;
const groundY = opts.groundY ?? 0;
const rand = rng(0xd309);
// one thin quadish streak, instanced — cheap and reads as rain in motion
const geo = new THREE.BoxGeometry(0.015, 1, 0.015);
const mat = new THREE.MeshBasicMaterial({
color: 0xb4d2ff, transparent: true, opacity: 0.34,
depthWrite: false, fog: false,
});
const mesh = new THREE.InstancedMesh(geo, mat, max);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.frustumCulled = false;
mesh.renderOrder = 2;
mesh.count = 0;
const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max);
const jitter = new Float32Array(max);
for (let i = 0; i < max; i++) {
px[i] = (rand() * 2 - 1) * half;
py[i] = groundY + rand() * height;
pz[i] = (rand() * 2 - 1) * half;
jitter[i] = 0.75 + rand() * 0.5; // not every drop is the same drop
}
const m = new THREE.Matrix4();
const q = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0);
const vel = new THREE.Vector3();
const scale = new THREE.Vector3(1, 1, 1);
const zero = new THREE.Vector3();
return {
mesh,
/** @param {THREE.Vector3} camPos @param {THREE.Vector3} w local wind */
step(dt, camPos, w, intensity) {
const n = Math.floor(max * clamp01(intensity));
mesh.count = n;
if (n === 0) return;
const fall = 9 + intensity * 4;
// rain leans into the wind; that lean IS the readout of how hard it's blowing
vel.set(w.x * 0.55, -fall, w.z * 0.55);
const speed = vel.length() || 1;
q.setFromUnitVectors(up, vel.clone().divideScalar(speed));
// streak stretches with speed — drizzle is dots, a squall is lines
scale.set(1, Math.min(2.6, 0.35 + speed * 0.055), 1);
m.compose(zero, q, scale);
const top = groundY + height;
for (let i = 0; i < n; i++) {
const j = jitter[i];
px[i] += w.x * 0.55 * j * dt;
py[i] -= fall * j * dt;
pz[i] += w.z * 0.55 * j * dt;
// wrap the box around the camera instead of respawning — no bookkeeping,
// and the rain is always exactly where the player is looking
let d = px[i] - camPos.x;
if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2;
d = pz[i] - camPos.z;
if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2;
if (py[i] < groundY) py[i] += height;
else if (py[i] > top) py[i] -= height;
m.elements[12] = px[i];
m.elements[13] = py[i];
m.elements[14] = pz[i];
mesh.setMatrixAt(i, m);
}
mesh.instanceMatrix.needsUpdate = true;
},
dispose() { geo.dispose(); mat.dispose(); },
};
}
// ------------------------------------------------------------ cloud dome
function cloudTexture(size = 256, seed = 7) {
const cv = document.createElement('canvas');
cv.width = cv.height = size;
const ctx = cv.getContext('2d');
const img = ctx.createImageData(size, size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
// fbm at integer frequencies, each octave wrapped at its own period, so
// the texture tiles: it's set to repeat(3,2) and it scrolls forever, and
// an unwrapped octave puts a dead straight seam across the sky.
let n = 0, amp = 0.5, f = 4;
for (let o = 0; o < 4; o++) {
n += amp * valueNoise2((x / size) * f, (y / size) * f, seed + o * 977, f);
amp *= 0.5; f *= 2;
}
const v = clamp01((n - 0.28) * 2.2);
const i = (y * size + x) * 4;
const shade = 150 + v * 70;
img.data[i] = shade; img.data[i + 1] = shade; img.data[i + 2] = shade + 12;
img.data[i + 3] = v * 235;
}
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(3, 2);
return tex;
}
// ---------------------------------------------------------------- audio
// Synthesized layers. WebAudio won't start until a gesture (browser rule), so
// everything is built lazily on unlock() and silently absent before it.
function createAudio(seed = 1) {
let ctx = null, master = null;
let windGain, windFilter, windHowl, howlGain;
let rainGain, rainFilter;
let gustGain, gustFilter;
let noiseBuf = null;
let creakNext = 0, flogNext = 0;
let started = false;
function noiseBuffer(c) {
const len = c.sampleRate * 4;
const buf = c.createBuffer(1, len, c.sampleRate);
const d = buf.getChannelData(0);
const rand = rng(seed ^ 0x0157);
let last = 0;
for (let i = 0; i < len; i++) {
const white = rand() * 2 - 1;
last = (last + 0.02 * white) / 1.02; // brown-ish: weight to the low end
d[i] = last * 3.5;
}
return buf;
}
function loop(buf, dest, filter) {
const src = ctx.createBufferSource();
src.buffer = buf; src.loop = true;
src.connect(filter); filter.connect(dest);
src.start();
return src;
}
return {
get ready() { return started; },
/** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph
* got built a suspended context is still silent, so the HUD reports this. */
get state() { return ctx ? ctx.state : 'none'; },
/** Current layer gains — for the HUD and for asserting the bed tracks wind. */
levels() {
if (!started) return null;
return {
wind: +windGain.gain.value.toFixed(4),
howl: +howlGain.gain.value.toFixed(4),
rain: +rainGain.gain.value.toFixed(4),
cutoff: Math.round(windFilter.frequency.value),
};
},
/** Call from the first click/keydown. Safe to call repeatedly. */
unlock() {
if (started) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
ctx = new AC();
if (ctx.state === 'suspended') ctx.resume();
master = ctx.createGain();
master.gain.value = 0.55;
master.connect(ctx.destination);
noiseBuf = noiseBuffer(ctx);
// wind bed: brown noise through a lowpass that opens as it blows harder
windGain = ctx.createGain(); windGain.gain.value = 0;
windFilter = ctx.createBiquadFilter();
windFilter.type = 'lowpass'; windFilter.frequency.value = 400;
windGain.connect(master);
loop(noiseBuf, windGain, windFilter);
// howl: a resonant band on top — this is the bit that sounds like a gale
howlGain = ctx.createGain(); howlGain.gain.value = 0;
windHowl = ctx.createBiquadFilter();
windHowl.type = 'bandpass'; windHowl.frequency.value = 500; windHowl.Q.value = 6;
howlGain.connect(master);
loop(noiseBuf, howlGain, windHowl);
rainGain = ctx.createGain(); rainGain.gain.value = 0;
rainFilter = ctx.createBiquadFilter();
rainFilter.type = 'highpass'; rainFilter.frequency.value = 1800;
rainGain.connect(master);
loop(noiseBuf, rainGain, rainFilter);
gustGain = ctx.createGain(); gustGain.gain.value = 0;
gustFilter = ctx.createBiquadFilter();
gustFilter.type = 'bandpass'; gustFilter.frequency.value = 300; gustFilter.Q.value = 2.5;
gustGain.connect(master);
loop(noiseBuf, gustGain, gustFilter);
started = true;
},
/** One-shot filtered noise burst — the workhorse for creak/flog/thunder. */
burst({ freq, q, gain, attack, decay, type = 'bandpass' }) {
if (!started) return;
const now = ctx.currentTime;
const src = ctx.createBufferSource();
src.buffer = noiseBuf;
src.loop = true;
const f = ctx.createBiquadFilter();
f.type = type; f.frequency.value = freq; f.Q.value = q ?? 4;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, now);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), now + attack);
g.gain.exponentialRampToValueAtTime(0.0001, now + attack + decay);
src.connect(f); f.connect(g); g.connect(master);
src.start(now);
src.stop(now + attack + decay + 0.05);
},
/** @param {number} speed m/s @param {number} rain 0..1 */
setLevels(speed, rain) {
if (!started) return;
const now = ctx.currentTime;
const s = clamp01(speed / 32);
// gain and brightness both climb — a 30 m/s wind isn't just a louder 5 m/s one
windGain.gain.setTargetAtTime(0.05 + s * 0.5, now, 0.15);
windFilter.frequency.setTargetAtTime(220 + s * 900, now, 0.2);
howlGain.gain.setTargetAtTime(s * s * 0.28, now, 0.2);
windHowl.frequency.setTargetAtTime(320 + s * 700, now, 0.25);
rainGain.gain.setTargetAtTime(rain * 0.34, now, 0.3);
rainFilter.frequency.setTargetAtTime(1500 + rain * 900, now, 0.3);
},
/** Telegraph cue: you hear it coming before you feel it. */
whoosh(power, eta) {
if (!started) return;
const now = ctx.currentTime;
const p = clamp01(power / 18);
gustGain.gain.cancelScheduledValues(now);
gustGain.gain.setValueAtTime(gustGain.gain.value, now);
gustGain.gain.linearRampToValueAtTime(0.05 + p * 0.3, now + Math.max(0.05, eta));
gustGain.gain.linearRampToValueAtTime(0.0001, now + Math.max(0.05, eta) + 2.2);
gustFilter.frequency.cancelScheduledValues(now);
gustFilter.frequency.setValueAtTime(220, now);
gustFilter.frequency.linearRampToValueAtTime(240 + p * 700, now + Math.max(0.05, eta) + 0.8);
},
/** Rope creak — rate and pitch both ride the worst corner. */
creak(dt, loadFrac) {
if (!started || loadFrac < 0.35) return;
creakNext -= dt;
if (creakNext > 0) return;
creakNext = lerp(1.1, 0.16, clamp01((loadFrac - 0.35) / 0.65));
this.burst({
freq: 180 + loadFrac * 420, q: 9,
gain: 0.05 + loadFrac * 0.22, attack: 0.012, decay: 0.16,
});
},
/** Freed corner: canvas cracking itself to pieces. */
flog(dt, speed) {
if (!started) return;
flogNext -= dt;
if (flogNext > 0) return;
flogNext = Math.max(0.09, 0.5 - speed * 0.011);
this.burst({ freq: 900 + speed * 26, q: 1.2, gain: 0.1 + clamp01(speed / 30) * 0.3, attack: 0.005, decay: 0.1 });
},
thunder(power) {
if (!started) return;
this.burst({ type: 'lowpass', freq: 90 + power * 60, q: 0.7, gain: 0.25 + power * 0.5, attack: 0.06, decay: 2.6 + power * 1.6 });
},
dispose() { if (ctx) ctx.close(); started = false; },
};
}
// ---------------------------------------------------------------- skyfx
/**
* @param {object} o
* @param {THREE.Scene} o.scene
* @param {THREE.Camera} o.camera
* @param {object} o.wind from weather.js
* @param {THREE.Light} [o.sun] Lane A's directional light we dim it
* @param {THREE.Light} [o.hemi] Lane A's hemisphere light
* @param {boolean} [o.night] storm_02 is a wild NIGHT
* @param {function} [o.onEvent] (text) HUD ticker
*/
export function createSkyFx(o = {}) {
const { scene, camera, wind } = o;
const sun = o.sun || null;
const hemi = o.hemi || null;
const def = (wind && wind.def) || {};
const skyDef = def.sky || {};
const darkness = skyDef.darkness ?? 0.7;
const scroll = skyDef.cloudScroll ?? 0.06;
const target = (o.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
const rain = createRain({ groundY: o.groundY ?? 0 });
if (scene) scene.add(rain.mesh);
const audio = createAudio((wind && wind.seed) || 1);
// cloud dome rides the camera so it can't clip the far plane whatever Lane A set
const domeTex = cloudTexture(256, ((wind && wind.seed) || 7) & 0xffff);
const dome = new THREE.Mesh(
new THREE.SphereGeometry(180, 24, 16),
new THREE.MeshBasicMaterial({
map: domeTex, side: THREE.BackSide, transparent: true,
depthWrite: false, fog: false, opacity: 0,
}),
);
dome.renderOrder = -1;
if (scene) scene.add(dome);
// remember what world.js handed us, so dispose() puts it back exactly
const original = {
background: scene ? scene.background : null,
fog: scene ? scene.fog : null,
sun: sun ? sun.intensity : 0,
hemi: hemi ? hemi.intensity : 0,
};
const baseSky = (scene && scene.background && scene.background.isColor)
? scene.background.clone() : CALM_SKY.clone();
const skyCol = baseSky.clone();
if (scene) {
scene.background = skyCol;
if (!scene.fog) scene.fog = new THREE.Fog(skyCol.getHex(), 30, 140);
}
let flash = 0; // decaying lightning brightness
let flashQueue = []; // {at, power} — double-strike
let lastTelegraph = null;
const camPos = new THREE.Vector3();
const w = new THREE.Vector3();
const fx = {
rain, audio, dome,
get flash() { return flash; },
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
unlockAudio() { audio.unlock(); },
/**
* @param {number} dt
* @param {number} t storm time
* @param {object} [world] {sail} duck-typed, for creak/flog
*/
step(dt, t, world = {}) {
if (!camera) return;
camera.getWorldPosition(camPos);
wind.sample(camPos, t, w);
const speed = Math.hypot(w.x, w.z);
const intensity = wind.rainAt(t);
const storminess = clamp01(Math.max(intensity, speed / 26));
// --- events: lightning + the ticker ---
for (const ev of wind.eventsBetween(t - dt, t)) {
if (ev.type === 'lightning') {
const p = ev.power ?? 0.7;
flashQueue.push({ at: t, power: p });
flashQueue.push({ at: t + 0.09 + p * 0.07, power: p * 0.55 }); // the stutter
// thunder lags the flash — distance you can hear
const delay = (ev.distance ?? 1.2) * 0.9;
flashQueue.push({ at: t + delay, power: 0, thunder: p });
} else if (ev.type === 'windchange' && ev.text && o.onEvent) {
o.onEvent(ev.text);
}
}
for (let i = flashQueue.length - 1; i >= 0; i--) {
if (flashQueue[i].at <= t) {
const f = flashQueue[i];
if (f.thunder) audio.thunder(f.thunder);
else flash = Math.max(flash, f.power);
flashQueue.splice(i, 1);
}
}
flash *= Math.max(0, 1 - dt * 7);
if (flash < 0.004) flash = 0;
// --- sky ---
skyCol.copy(baseSky).lerp(target, storminess * darkness);
if (flash > 0) skyCol.lerp(new THREE.Color(0xdfe8ff), Math.min(0.85, flash));
if (scene) {
if (scene.fog) {
scene.fog.color.copy(skyCol);
scene.fog.near = lerp(40, 8, storminess);
scene.fog.far = lerp(160, 55, storminess);
}
}
if (sun) sun.intensity = lerp(original.sun, original.sun * 0.12, storminess * darkness) + flash * 2.2;
if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, storminess * darkness) + flash * 1.2;
dome.position.copy(camPos);
dome.material.opacity = storminess * 0.85;
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
// --- rain ---
rain.step(dt, camPos, w, intensity);
// --- audio ---
audio.setLevels(speed, intensity);
const tg = wind.gustTelegraph(t);
if (tg && tg !== lastTelegraph) {
// fires once per gust, right as the telegraph opens
if (!lastTelegraph || Math.abs(tg.eta - (lastTelegraph.eta - dt)) > 0.05) {
audio.whoosh(tg.power, tg.eta);
}
}
lastTelegraph = tg;
const sail = world.sail;
if (sail && sail.corners) {
let worst = 0, broken = false;
for (const c of sail.corners) {
if (c.broken) { broken = true; continue; }
const rating = c.hw && c.hw.rating ? c.hw.rating : 1;
worst = Math.max(worst, c.load / rating);
}
audio.creak(dt, worst);
if (broken) audio.flog(dt, speed);
}
},
/** Hand Lane A's scene back exactly as we found it. */
dispose() {
if (scene) {
scene.remove(rain.mesh);
scene.remove(dome);
scene.background = original.background;
scene.fog = original.fog;
}
if (sun) sun.intensity = original.sun;
if (hemi) hemi.intensity = original.hemi;
rain.dispose();
dome.geometry.dispose();
dome.material.dispose();
domeTex.dispose();
audio.dispose();
},
};
return fx;
}

View File

@ -1,26 +1,34 @@
/**
* Lane B selftests cloth, corner loads, failure cascade.
* Lane B selftests cloth, corner loads, failure cascade, prep economy.
*
* Lane B owns this file. Lane A pre-created it so that adding your suite never
* means editing selftest.html if all five lanes shared that file it would be
* the one guaranteed merge conflict in the repo.
* The asserts themselves live next to the code they test, in
* `js/sail.selftest.js` and `js/rigging.selftest.js`, exported as [name, fn]
* pairs. This file is only the adapter that hands them to Lane A's Suite.
*
* The asserts PLAN3D §5-B asks for, once sail.js lands:
* 1. hypar sheds load twisted rig's peak corner load < flat rig's peak,
* same storm, same hardware. This is the thesis of the whole game; if it
* doesn't hold, the wind force is being applied per-node instead of
* per-face.
* 2. cascade break one corner at a fixed t, a neighbour's load spikes 2×.
* 3. determinism two runs, same inputs, byte-equal load traces.
* The reason for the indirection: those two modules also run under plain
* `node web/world/js/sail.selftest.js` no browser, no server, no renderer,
* ~6 s which is how the cloth got proven before M0 landed. Keeping the
* asserts there means the browser suite and the headless suite can never drift,
* because they are literally the same array.
*
* Useful imports when you get there:
* import { FIXED_DT, STORM_LEN, HARDWARE, createStubWind } from '../contracts.js';
* import { assert, assertLess, fixedLoop } from '../testkit.js';
* Drive time with fixedLoop(), never rAF. Use createStubWind({seed}) until
* Lane C's weather.js lands — but don't tune against it, it's uniform in space.
* PLAN3D §5-B asked for three asserts. All three are in there, plus a statics
* balance that pins the load meter to real newtons:
* 1. hypar sheds load scored on WORST CASE over eight wind directions
* rather than one, because Lane C's storms veer and the player never gets
* to pick the wind. Per-direction would be a false assert: a flat sail
* sitting edge-on to the wind genuinely has low drag and beats the hypar
* from that one angle. Worst-case is what the hardware has to survive.
* 2. cascade break a corner at fixed t, a neighbour's load spikes >= 2x.
* 3. determinism byte-equal load traces, plus ragged frame dt converging on
* the fixed-dt trace (Lane A's render loop delivers ragged dt, so the
* accumulator has to absorb it or none of this applies to the real game).
*/
import { SAIL_TESTS } from '../sail.selftest.js';
import { RIGGING_TESTS } from '../rigging.selftest.js';
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('sail.js not landed yet — Lane B');
for (const [name, fn] of SAIL_TESTS) t.test(name, fn);
for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn);
}

View File

@ -1,25 +1,81 @@
/**
* Lane C selftests wind field, storm timelines, rain, debris.
*
* Lane C owns this file. Lane A pre-created it so adding your suite never means
* editing selftest.html.
* The asserts live in weather.selftest.js as a plain case list, because they
* import nothing but weather.core.js (no THREE, no DOM) and so also run under
* `node web/world/js/tests/run-node.mjs` a one-second loop for tuning a storm
* curve, instead of a browser round trip. This file is the browser half: it
* feeds them the fetched storms and adds the checks that need the real
* weather.js adapter (contract shape, THREE.Vector3 out).
*
* The asserts PLAN3D §5-C asks for, once weather.js lands:
* 1. gust telegraph lead 1.2 s the promise the storm rests on. Lane A's
* suite already asserts this against the stub wind (see a.test.js,
* 'gust telegraph always gives at least 1.2 s of warning'); lift that test
* onto the real wind.sample and delete the stub version's claim to it.
* 2. wind.sample continuity no frame-to-frame jump beyond a sane bound, or
* the cloth explodes and it looks like Lane B's bug.
* 3. storm JSON schema validation for everything in data/storms/.
*
* Useful imports:
* import { FIXED_DT, STORM_LEN } from '../contracts.js';
* import { assert, assertLess, fixedLoop } from '../testkit.js';
* wind.sample(pos, t) must be pure in (pos, t): selftest samples out of order.
* Lane A: a.test.js's 'gust telegraph always gives at least 1.2 s of warning'
* is lifted onto the real wind below, per your note the stub's claim to it is
* now redundant and yours to drop whenever suits. Logged in THREADS.
*/
import * as THREE from '../../vendor/three.module.js';
import { assert, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract } from '../contracts.js';
import { loadStorm, createWind } from '../weather.js';
import { weatherCases } from './weather.selftest.js';
const STORMS = ['storm_01_gentle', 'storm_02_wildnight'];
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('weather.js not landed yet — Lane C');
export default async function run(t) {
const storms = {};
for (const name of STORMS) storms[name] = await loadStorm(name);
// --- the shared case list (also runs headless in node) ---
const { cases } = weatherCases(storms);
for (const c of cases) t.test(c.name, c.fn);
// --- the bits that need the THREE adapter, not just the core ---
t.test('weather.js satisfies the wind contract', () => {
const wind = createWind(storms.storm_02_wildnight);
const problems = checkContract('wind', wind);
assert(problems.length === 0, problems.join('; '));
});
t.test('sample() returns a Vector3 and honours an out param', () => {
const wind = createWind(storms.storm_02_wildnight);
const pos = new THREE.Vector3(3, 0, -2);
const a = wind.sample(pos, 12.5);
assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3');
assert(a.y === 0, `wind should be horizontal, got y=${a.y}`);
// out param must not change the answer, only where it lands
const out = new THREE.Vector3();
const b = wind.sample(pos, 12.5, out);
assert(b === out, 'out param was ignored');
assert(a.x === b.x && a.z === b.z, 'out param changed the result');
});
// Lifted from a.test.js onto the real wind (Lane A's note in this file's
// header). This is the promise the whole storm rests on: the player can
// always react. Deliberately walks the public surface, not the core.
t.test('gust telegraph always gives at least 1.2 s of warning', () => {
const wind = createWind(storms.storm_02_wildnight);
let had = false, edges = 0;
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
const tel = wind.gustTelegraph(time);
if (tel && !had) {
edges++;
assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`);
assert(Number.isFinite(tel.power) && tel.power > 0, 'telegraph carried no power');
assert(Number.isFinite(tel.dir), 'telegraph carried no direction');
}
had = !!tel;
});
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`);
});
t.test('every storm in data/storms/ loads and validates', () => {
// loadStorm throws on invalid, so reaching here with all of them is the pass
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');
for (const [name, def] of Object.entries(storms)) {
assert(def.duration > 0, `${name} has no duration`);
assert(Array.isArray(def.baseCurve), `${name} has no baseCurve`);
}
});
}

View File

@ -1,26 +1,295 @@
/**
* Lane D selftests player state machine and interactions.
* Lane D selftests player state machine, weather effects, interactions. (PLAN3D §5-D.5)
*
* Lane D owns this file. Lane A pre-created it so adding your suite never means
* editing selftest.html.
* Everything asserted here is renderer-free: player.sim.js and interact.js import nothing, so this
* suite is pure logic driven at fixed dt, never rAF. (rAF being throttled in a hidden tab is not
* theoretical this lane's dev harness froze mid-knockdown at stateT=0.333 until it was driven by
* a fixed loop, which is exactly the trap Lane A called out.)
*
* The asserts PLAN3D §5-D asks for, once player.js lands:
* 1. anim state machine table test every state reachable, none stuck
* (idle/walk/run + one-shot interact + knockdown get-up).
* 2. interact radius respects the busy and carrying flags.
*
* Note for integration: main.js currently drives a placeholder capsule that
* satisfies the Player contract ({pos, carrying, busy, update}). When player.js
* lands, Lane A swaps the factory call in boot() and deletes the placeholder
* you shouldn't need to touch main.js yourself. Ping THREADS.md when you're
* ready and Lane A will do the swap.
*
* Useful imports:
* import { FIXED_DT, createStubWind } from '../contracts.js';
* import { assert, assertEq, fixedLoop } from '../testkit.js';
* The rig half of player.js bone binding, head-bone scale, clip retarget can't be asserted
* headlessly; it needs a GL context and two GLB fetches. That is verified by hand in
* web/world/dev_player.html and written up in THREADS.md: head bone 1.715 m at fig scale 0.983,
* all six clips bound, Hips tracks correctly absent.
*/
import { PlayerSim, STATES, TUNE } from '../player.sim.js';
import { Interact, wireYardActions } from '../interact.js';
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
import { FIXED_DT } from '../contracts.js';
const DT = FIXED_DT;
/** Steady wind blowing +X at `speed` m/s. */
const windX = (speed) => ({ x: speed, y: 0, z: 0 });
/** Drive the sim for `secs` at fixed dt. */
const drive = (sim, secs, input = {}, wind = null, t0 = 0) =>
fixedLoop(secs, DT, (dt, t) => sim.step(dt, t0 + t, input, wind));
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('player.js not landed yet — Lane D');
// ---------------------------------------------------------------- state machine table
t.test('state table: every state\'s clip exists in player_anims.glb', () => {
const clips = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction']);
for (const [name, st] of Object.entries(STATES)) {
assert(clips.has(st.clip), `state ${name} wants missing clip ${st.clip}`);
}
});
t.test('state table: no stuck states — every locked state drains to a free one', () => {
for (const [name, st] of Object.entries(STATES)) {
if (!st.locked) continue;
assert((st.next && st.secs > 0) || !!st.releasedBy,
`locked state ${name} has neither a timed exit nor an external releaser`);
if (st.next) assert(!!STATES[st.next], `state ${name}.next=${st.next} is not a state`);
}
for (const name of Object.keys(STATES)) {
let cur = name, hops = 0;
const seen = new Set();
while (STATES[cur].locked && STATES[cur].next && hops < 16 && !seen.has(cur)) {
seen.add(cur); cur = STATES[cur].next; hops++;
}
assert(!STATES[cur].locked || !!STATES[cur].releasedBy,
`state ${name} chains into a dead end at ${cur}`);
}
});
// ---------------------------------------------------------------- locomotion
t.test('locomotion: idle -> walk -> run -> idle at the tuned speeds', () => {
const sim = new PlayerSim();
assertEq(sim.state, 'idle', 'spawns idle');
assert(!sim.busy, 'idle is not busy');
drive(sim, 1.0, { x: 0, z: 1, camYaw: 0 });
assertEq(sim.state, 'walk', 'W walks');
assertClose(sim.speed, TUNE.walkSpeed, 0.05, 'walk speed');
drive(sim, 1.0, { x: 0, z: 1, run: true, camYaw: 0 });
assertEq(sim.state, 'run', 'shift runs');
assertClose(sim.speed, TUNE.runSpeed, 0.05, 'run speed');
drive(sim, 1.0, {});
assertEq(sim.state, 'idle', 'releasing input returns to idle');
assertLess(sim.speed, 0.15, 'and stops');
});
t.test('locomotion: movement is relative to camera.yaw', () => {
const a = new PlayerSim(); drive(a, 1.0, { x: 0, z: 1, camYaw: 0 });
assertLess(a.pos.z, -0.5, 'at yaw 0, W drives -Z (three.js camera-forward)');
assertClose(a.pos.x, 0, 1e-6, 'and not sideways');
const b = new PlayerSim(); drive(b, 1.0, { x: 0, z: 1, camYaw: Math.PI / 2 });
assertLess(b.pos.x, -0.5, 'at yaw 90 deg, the same key drives -X');
assertClose(b.pos.z, 0, 1e-6, 'and not forward');
assertClose(Math.abs(a.facing), Math.PI, 0.05, 'facing chases the movement direction');
});
// ---------------------------------------------------------------- weather on the player
t.test('weather: wind slows you, capped at TUNE.slowMax', () => {
const calm = new PlayerSim(); drive(calm, 2, { x: 0, z: 1, camYaw: 0 }, windX(0));
const blow = new PlayerSim(); drive(blow, 2, { x: 0, z: 1, camYaw: 0 }, windX(20));
assertLess(blow.speed, calm.speed, 'wind slows you');
// Isolate the slow curve from the knockdown — in a real 1e4 m/s you are flat on your back
// inside half a second (which is correct, and is what this assert used to accidentally measure).
const gale = new PlayerSim({ tune: { knockWind: Infinity } });
drive(gale, 2, { x: 0, z: 1, camYaw: 0 }, windX(1e4));
assertClose(gale.speed, TUNE.walkSpeed * (1 - TUNE.slowMax), 0.05,
'slow saturates at slowMax and never goes past it');
});
t.test('weather: steady wind reads as baseline, not as a gust', () => {
const s = new PlayerSim();
drive(s, 60, {}, windX(20));
assertLess(s.gust, 1.0, 'the EMA learns a constant 20 m/s as base');
assertLess(Math.hypot(s.shove.x, s.shove.z), 0.05, 'so it does not shove you forever');
});
t.test('weather: a gust over the baseline shoves you downwind, and scales with speed squared', () => {
const s = new PlayerSim();
drive(s, 30, {}, windX(6)); // learn a calm baseline
const x0 = s.pos.x;
drive(s, 1.6, {}, windX(22), 30); // 16 m/s over baseline -> past shoveGustMin
assert(s.pos.x - x0 > 0.05, `gust should shove downwind, dx=${(s.pos.x - x0).toFixed(3)}`);
const push = (ws) => {
const p = new PlayerSim();
drive(p, 30, {}, windX(2));
const p0 = p.pos.x;
drive(p, 1.0, {}, windX(ws), 30);
return p.pos.x - p0;
};
const p15 = push(15), p30 = push(30);
assert(p30 > p15 * 2.5,
`shove must grow faster than linearly with speed: ${p15.toFixed(3)} -> ${p30.toFixed(3)}`);
});
// ---------------------------------------------------------------- knockdown
t.test('knockdown: needs SUSTAINED overload, like a sail corner letting go', () => {
const brief = new PlayerSim();
drive(brief, 0.3, {}, windX(TUNE.knockWind + 5));
assert(brief.state !== 'knocked', 'a brief spike must not put you down');
const held = new PlayerSim();
held.carrying = 'spare';
drive(held, TUNE.knockSustain + 0.2, {}, windX(TUNE.knockWind + 5));
assertEq(held.state, 'knocked', 'sustained overload does');
assertEq(held.carrying, null, 'and drops what you were carrying');
assert(held.knockDir.x > 0.99, 'and you fall downwind');
});
t.test('knockdown: exposure bleeds off — flickering gusts never creep into one', () => {
const s = new PlayerSim();
fixedLoop(10, DT, (dt, t) => {
const on = Math.round(t / DT) % 40 < 10; // 0.17 s on, 0.5 s off
s.step(dt, t, {}, windX(on ? TUNE.knockWind + 5 : 5));
});
assert(s.state !== 'knocked',
`flickering gusts must not accumulate (exposure=${s.exposure.toFixed(3)})`);
});
t.test('knockdown: knocked -> getup -> idle, unaided, and upright again', () => {
const s = new PlayerSim();
s.knockdown(0);
assert(s.busy, 'knocked is locked');
drive(s, 0.5, { x: 0, z: 1, camYaw: 0 });
assertClose(s.pos.z, 0, 1e-9, 'knocked ignores movement input');
assert(s.pitch > 0.99, 'body goes flat');
drive(s, 1.1, {});
assertEq(s.state, 'getup', 'knocked drains to getup on its own');
drive(s, 1.4, {});
assertEq(s.state, 'idle', 'getup drains to idle on its own');
assertLess(s.pitch, 0.01, 'and the body is upright again');
assert(!s.busy, 'player is free');
});
// ---------------------------------------------------------------- determinism (PLAN3D §4)
t.test('determinism: two identical 50 s runs produce byte-equal traces', () => {
const trace = () => {
const s = new PlayerSim();
const out = [];
fixedLoop(50, DT, (dt, tt) => {
const w = { x: 6 + 24 * Math.max(0, Math.sin(tt * 0.7)), y: 0, z: 3 * Math.cos(tt * 0.31) };
s.step(dt, tt, { x: Math.sin(tt), z: Math.cos(tt * 0.5), run: tt % 4 < 2, camYaw: tt * 0.2 }, w);
out.push(`${s.state}|${s.pos.x.toFixed(9)}|${s.pos.z.toFixed(9)}|${s.pitch.toFixed(9)}`);
});
return out.join(';');
};
assertEq(trace(), trace(), 'same inputs must give the same trace');
});
// ---------------------------------------------------------------- interact
const mk = () => {
const sim = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
let done = 0;
it.register({ id: 'x', pos: { x: 0, y: 0, z: 0 }, radius: 1.5, holdSecs: 1, label: 'do it',
onDone: () => { done++; } });
return { sim, it, done: () => done };
};
t.test('interact: radius gates the action', () => {
const { sim, it, done } = mk();
sim.pos.x = 5;
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(done(), 0, 'out of radius never fires');
assert(!sim.busy, 'and never makes you busy');
});
t.test('interact: hold to completion fires once and hands the player back', () => {
const { sim, it, done } = mk();
fixedLoop(70 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(done(), 1, 'fires once');
assert(!sim.busy && sim.state === 'idle', 'player released');
assert(it.events.some((e) => e.type === 'done' && e.id === 'x'), 'emits a done event');
});
t.test('interact: player is busy mid-hold, with a partial radial', () => {
const { sim, it } = mk();
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
assert(sim.busy && sim.state === 'busy', 'busy while holding');
assert(it.progress > 0.4 && it.progress < 0.6, `radial partway, got ${it.progress.toFixed(2)}`);
});
t.test('interact: one press, one action — a held key must not re-arm itself', () => {
const { sim, it, done } = mk();
fixedLoop(6.7, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(done(), 1, 'holding E for 6.7 s over a 1 s action still fires once');
it.step(DT, 7, sim, false);
fixedLoop(70 * DT, DT, (dt, tt) => it.step(dt, 7 + tt, sim, true));
assertEq(done(), 2, 'releasing re-arms it');
});
t.test('interact: releasing or walking away cancels the hold', () => {
const a = mk();
fixedLoop(30 * DT, DT, (dt, tt) => a.it.step(dt, tt, a.sim, true));
a.it.step(DT, 0.5, a.sim, false);
assertEq(a.it.progress, 0, 'release cancels');
assert(!a.sim.busy && a.sim.state === 'idle', 'and frees the player');
const b = mk();
fixedLoop(30 * DT, DT, (dt, tt) => b.it.step(dt, tt, b.sim, true));
b.sim.pos.x = 9;
b.it.step(DT, 0.5, b.sim, true);
assertEq(b.it.progress, 0, 'leaving the radius cancels');
assertEq(b.done(), 0, 'without firing');
});
t.test('interact: a knockdown mid-hold aborts without stomping the knocked state', () => {
const { sim, it, done } = mk();
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
sim.knockdown(0.5);
it.step(DT, 0.51, sim, true);
assertEq(it.progress, 0, 'hold aborted');
assertEq(done(), 0, 'action did not fire');
assertEq(sim.state, 'knocked', 'and the abort did not overwrite the knocked state');
});
t.test('interact: canUse() gates on the carrying flag', () => {
const sim = new PlayerSim();
const it = new Interact();
let fired = 0;
it.register({ id: 'gated', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
canUse: (p) => !p.carrying, onDone: () => { fired++; } });
sim.carrying = 'spare';
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(fired, 0, 'hands full: gated');
assert(!sim.busy, 'and never went busy');
sim.carrying = null;
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(fired, 1, 'fires once the gate opens');
});
// ---------------------------------------------------------------- hands-full + wiring
t.test('hands-full rule: one item at a time', () => {
const sim = new PlayerSim();
assertEq(sim.pickUp('spare'), true, 'first item accepted');
assertEq(sim.pickUp('wrench'), false, 'second refused');
assertEq(sim.carrying, 'spare', 'still holding the first');
assertEq(sim.drop(), 'spare', 'drop returns it');
assertEq(sim.carrying, null, 'hands empty');
});
t.test('wireYardActions: duck-types against a half-landed world', () => {
const empty = new Interact();
wireYardActions(empty, {});
assertEq(empty.targets.size, 0, 'no sailRig yet (Lane B) -> no actions, no crash');
const it = new Interact();
const corners = [{ anchorId: 'p1', broken: true, pos: { x: 0, y: 2, z: 0 }, load: 0 }];
let repaired = -1;
wireYardActions(it, {
sailRig: { corners, repair: (i) => { repaired = i; }, trim: () => {} },
world: { shedTable: { pos: { x: 10, y: 0, z: 0 } } },
});
assertEq(it.targets.size, 3, 're-rig + trim + spare table');
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
assertEq(repaired, -1, 're-rig refuses without a spare');
p.carrying = 'spare';
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, 10 + tt, p, true));
assertEq(repaired, 0, 're-rig fires with a spare');
assertEq(p.carrying, null, 'and consumes it');
});
}

View File

@ -69,6 +69,10 @@ const ASSETS = [
{ name: 'carabiner', h: [0.06, 0.15], nodes: ['body', 'gate'] },
{ name: 'turnbuckle', h: [0.12, 0.25], nodes: ['body', 'eye_a', 'eye_b'] },
{ name: 'tramp_01', h: [0.6, 1.0], nodes: ['mat', 'rim', 'pad', 'legs'], sub: 'debris/' },
{ name: 'wheelie_bin_01', h: [1.0, 1.2],
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
];
function sizeOf(gltf) {
@ -148,6 +152,88 @@ export default async function run(t) {
assert(Number.isFinite(p.x) && Number.isFinite(p.z), 'anchor world position is not finite');
});
// A canopy that can't sway is just decoration, and the gust front the player
// reads a beat before it hits the sail (world.js) is the canopy leaning. Lane A
// rotates a `canopy` group; the blobs must swing about the TRUNK, not spin
// about their own centres — a sphere spinning in place is invisible, which is
// exactly the failure this catches.
t.test('canopy sways about the trunk when Lane A rotates it', () => {
const g = loaded.get('tree_gum_01');
assert(g, 'tree_gum_01 did not load');
const canopy = g.scene.getObjectByName('canopy');
assert(canopy, 'no `canopy` group node — world.js rotates this to sway the tree');
const blob = g.scene.getObjectByName('canopy_01');
assert(blob, 'canopy_01 missing');
g.scene.updateWorldMatrix(true, true);
const before = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
const restZ = canopy.rotation.z;
canopy.rotation.z = restZ + 0.20; // ≈ world.js's max lean of 0.22 rad
canopy.updateWorldMatrix(true, true);
const after = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
canopy.rotation.z = restZ;
canopy.updateWorldMatrix(true, true);
const moved = before.distanceTo(after);
assert(moved > 0.15,
`canopy_01 moved only ${moved.toFixed(3)} m under a 0.2 rad lean — the pivot is at ` +
'the blob centre, not the trunk top, so the tree cannot visibly sway');
});
// DESIGN.md: rake the post away from the load — so rake is a runtime rotation,
// not baked. Rotating rake_pivot must carry the post and its top_anchor over
// while the concrete footing stays level in the ground. A post whose footing
// tips out of the dirt with it isn't raked, it's falling.
t.test('sail_post rakes about rake_pivot with the footing left planted', () => {
const g = loaded.get('sail_post');
assert(g, 'sail_post did not load');
const rake = g.scene.getObjectByName('rake_pivot');
const anchor = g.scene.getObjectByName('top_anchor');
const footing = g.scene.getObjectByName('footing');
assert(rake && anchor && footing, 'sail_post needs rake_pivot, top_anchor and footing');
const at = () => {
g.scene.updateWorldMatrix(true, true);
return [new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld),
new THREE.Vector3().setFromMatrixPosition(footing.matrixWorld)];
};
const [a0, f0] = at();
rake.rotation.x += (8 * Math.PI) / 180; // Lane A rakes 8°
const [a1, f1] = at();
rake.rotation.x -= (8 * Math.PI) / 180;
g.scene.updateWorldMatrix(true, true);
const head = a0.distanceTo(a1), foot = f0.distanceTo(f1);
assert(head > 0.3,
`an 8° rake moved the head only ${head.toFixed(3)} m — rake_pivot has no children`);
assert(foot < 0.01,
`the footing moved ${foot.toFixed(3)} m — concrete should stay planted`);
});
// Same pivot class as the canopy: the Hills Hoist head freewheels, so spinning
// it has to carry the arms round. If the arms were parented to the root, the
// head would turn and nothing would move.
t.test('washing line head carries the arms round when spun', () => {
const g = loaded.get('washing_line_01');
assert(g, 'washing_line_01 did not load');
const head = g.scene.getObjectByName('head');
const arms = g.scene.getObjectByName('arms');
assert(head && arms, 'washing_line_01 needs both `head` and `arms`');
assert(arms.parent === head || arms.parent?.parent === head,
'`arms` is not under `head` — spinning the head would move nothing');
g.scene.updateWorldMatrix(true, true);
const before = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
head.rotation.y += Math.PI / 2;
head.updateWorldMatrix(true, true);
const after = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
head.rotation.y -= Math.PI / 2;
head.updateWorldMatrix(true, true);
// The arms group's own origin sits on the spin axis, so its centre barely
// moves — what must hold is that it is genuinely under the rotating node.
assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
});
// One GLB carries three wilt states as siblings; Lane A toggles .visible
// rather than reloading, so all three have to be present at once.
t.test('garden_bed carries all 3 damage states in one GLB', () => {

View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
'use strict';
// SHADES — Lane C — headless runner for the weather suite.
//
// node web/world/js/tests/run-node.mjs
//
// The same suite Lane A's selftest.html runs, but with no browser and no server,
// so tuning a storm curve is a one-second loop. Exits non-zero on failure.
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { runWeatherSuite } from './weather.selftest.js';
const here = dirname(fileURLToPath(import.meta.url));
const stormDir = join(here, '..', '..', 'data', 'storms');
const storms = {};
for (const f of readdirSync(stormDir).filter((f) => f.endsWith('.json')).sort()) {
storms[f.replace(/\.json$/, '')] = JSON.parse(readFileSync(join(stormDir, f), 'utf8'));
}
const r = runWeatherSuite(storms);
for (const res of r.results) {
console.log(res.ok ? ` ok ${res.name}` : ` FAIL ${res.name}\n ${res.err}`);
}
console.log('\n metrics (Lane B: tune cloth rho against these):');
for (const [k, v] of Object.entries(r.metrics)) console.log(` ${k.padEnd(32)} ${v}`);
console.log(`\n ${r.pass} passed, ${r.fail} failed\n`);
process.exit(r.fail ? 1 : 0);

View File

@ -0,0 +1,299 @@
'use strict';
// SHADES — Lane C — weather selftest.
//
// Imports the pure core only (no THREE, no DOM), so this runs in node OR from
// Lane A's selftest.html. Fixed-dt loops, never rAF (rAF pauses in hidden tabs
// — PLAN3D §0).
//
// node web/world/js/tests/run-node.mjs
//
// Lane A: `import { runWeatherSuite } from './js/tests/weather.selftest.js'` and
// call it with the fetched storm defs.
import {
createWindField, validateStorm, gustEnvelope, GUST,
} from '../weather.core.js';
const DT = 1 / 60;
// yard is ~30×20 m, origin at centre — probe the corners and the middle
const PROBES = [
{ x: 0, z: 0 }, { x: -14, z: -9 }, { x: 14, z: -9 },
{ x: -14, z: 9 }, { x: 14, z: 9 }, { x: 5, z: -3 },
];
// t1 and t2 from Lane A's landed yard (THREADS: "yard layout is now FACT")
const TREE_SHELTERS = [
{ x: -9, z: 2, radius: 3, strength: 0.45, length: 14 },
{ x: 8, z: -2, radius: 2.5, strength: 0.4, length: 12 },
];
/**
* The case list, defined once and run by two harnesses: run-node.mjs for a
* one-second tuning loop, and js/tests/c.test.js for Lane A's selftest.html at
* merge time. Cases are sync, matching testkit's Suite.test(label, fn).
*
* @param {Object<string, object>} storms parsed storm defs, keyed by name
* @returns {{cases: {name:string, fn:() => void}[], metrics: object}}
* `metrics` fills in as the cases run read it after, not before.
*/
export function weatherCases(storms) {
const cases = [];
const metrics = {};
const test = (name, fn) => cases.push({ name, fn });
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
const defs = Object.entries(storms);
// ---- 1. gust telegraph lead (PLAN3D §5-C.5: always >= 1.2 s before ramp) ----
// The whole gust read is "you SEE it coming, then it hits". If the lead ever
// collapses, the storm stops being fair and starts being a dice roll.
for (const [name, def] of defs) {
test(`${name}: gust telegraph lead >= 1.2s`, () => {
const field = createWindField(def);
assert(field.gusts.length > 0, 'storm scheduled no gusts at all');
const step = 1 / 240;
let worst = Infinity;
for (const g of field.gusts) {
// when does THIS gust first actually push?
let rise = null;
for (let t = g.t0; t < g.endAt; t += step) {
if (gustEnvelope(t - g.t0, g.pow) > 1e-6) { rise = t; break; }
}
assert(rise !== null, `gust at t=${g.t0.toFixed(2)} never rises`);
// when did the HUD first get told about it?
let announced = null;
for (let t = Math.max(0, g.t0 - 2); t < rise; t += step) {
const tg = field.telegraph(t);
if (tg && Math.abs(tg.eta - (g.rampAt - t)) < 1e-6) { announced = t; break; }
}
assert(announced !== null, `gust at t=${g.t0.toFixed(2)} was never telegraphed`);
const lead = rise - announced;
worst = Math.min(worst, lead);
assert(lead >= 1.2,
`gust at t=${g.t0.toFixed(2)}: telegraph lead ${lead.toFixed(3)}s < 1.2s`);
// and the telegraph window must be silent — no force before the ramp
for (let t = g.t0; t < g.rampAt; t += step) {
assert(gustEnvelope(t - g.t0, g.pow) === 0,
`gust at t=${g.t0.toFixed(2)} pushes during its telegraph window`);
}
}
metrics[`${name}.worstTelegraphLead`] = +worst.toFixed(3);
});
}
// ---- 2. wind.sample continuity ----
// Sail load goes with wind², so a discontinuity here is an impulse that can
// snap a corner out of nowhere. Both axes matter: time (a standing player)
// and space (a running one).
const MAX_JUMP = 1.5; // m/s per 1/60 frame
for (const [name, def] of defs) {
test(`${name}: wind continuity in time (< ${MAX_JUMP} m/s per frame)`, () => {
const field = createWindField(def).setShelters(TREE_SHELTERS);
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
let worst = 0, worstT = 0, worstP = null;
for (const p of PROBES) {
field.vecAt(p.x, p.z, 0, a);
for (let t = DT; t <= field.duration; t += DT) {
field.vecAt(p.x, p.z, t, b);
const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
if (d > worst) { worst = d; worstT = t; worstP = p; }
a.x = b.x; a.y = b.y; a.z = b.z;
}
}
metrics[`${name}.maxTemporalJump`] = +worst.toFixed(4);
assert(worst < MAX_JUMP,
`jump ${worst.toFixed(3)} m/s at t=${worstT.toFixed(2)} probe=(${worstP.x},${worstP.z})`);
});
test(`${name}: wind continuity in space (< ${MAX_JUMP} m/s per 0.1 m)`, () => {
const field = createWindField(def).setShelters(TREE_SHELTERS);
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
let worst = 0, worstAt = null;
// sweep the yard at the storm's angriest moments, straight through both trees
for (const t of [10, 30, 57, 64, 80]) {
for (let z = -10; z <= 10; z += 1) {
field.vecAt(-15, z, t, a);
for (let x = -15 + 0.1; x <= 15; x += 0.1) {
field.vecAt(x, z, t, b);
const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
if (d > worst) { worst = d; worstAt = { x: +x.toFixed(1), z, t }; }
a.x = b.x; a.y = b.y; a.z = b.z;
}
}
}
metrics[`${name}.maxSpatialJump`] = +worst.toFixed(4);
assert(worst < MAX_JUMP,
`jump ${worst.toFixed(3)} m/s at ${JSON.stringify(worstAt)}`);
});
}
// ---- 3. storm JSON validator ----
for (const [name, def] of defs) {
test(`${name}: validates`, () => {
const { ok, errors } = validateStorm(def, name);
assert(ok, errors.join('; '));
});
}
// A validator that only ever says yes isn't a validator.
test('validator rejects broken storms', () => {
const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight));
const cases = [
['duration missing', (d) => { delete d.duration; }],
['duration negative', (d) => { d.duration = -5; }],
['baseCurve absent', (d) => { delete d.baseCurve; }],
['baseCurve non-monotonic t', (d) => { d.baseCurve = [[0, 5], [50, 9], [20, 7], [90, 6]]; }],
['baseCurve negative speed', (d) => { d.baseCurve = [[0, 5], [90, -2]]; }],
['baseCurve ends before duration', (d) => { d.baseCurve = [[0, 5], [40, 9]]; }],
['dirCurve absent', (d) => { delete d.dirCurve; }],
['gusts absent', (d) => { delete d.gusts; }],
['gusts.minGap zero', (d) => { d.gusts.minGap = 0; }],
['gusts.maxGap < minGap', (d) => { d.gusts.minGap = 9; d.gusts.maxGap = 4; }],
['gusts overlap (minGap < gust length)', (d) => { d.gusts.minGap = 2; }],
['debris event with no model', (d) => { d.events = [{ t: 10, type: 'debris' }]; }],
['event with no type', (d) => { d.events = [{ t: 10 }]; }],
['windchange that dirCurve never delivers', (d) => {
d.dirCurve = [[0, 0.9], [90, 1.0]];
d.events = [{ t: 55, type: 'windchange', telegraph: 6 }];
}],
];
for (const [label, mutate] of cases) {
const d = base();
mutate(d);
const { ok } = validateStorm(d, 'broken');
assert(!ok, `validator ACCEPTED a storm with: ${label}`);
}
});
// ---- 4. determinism ----
// Everything downstream (selftest fast-forward, Lane B's byte-equal load
// traces) rests on this. Two builds of the same storm must be indiscernible.
test('same def + same seed => identical trace', () => {
const def = storms.storm_02_wildnight;
const a = createWindField(def).setShelters(TREE_SHELTERS);
const b = createWindField(def).setShelters(TREE_SHELTERS);
const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
for (let t = 0; t <= def.duration; t += DT) {
for (const p of PROBES) {
a.vecAt(p.x, p.z, t, va);
b.vecAt(p.x, p.z, t, vb);
assert(va.x === vb.x && va.z === vb.z,
`diverged at t=${t.toFixed(3)} probe=(${p.x},${p.z})`);
}
}
assert(a.gusts.length === b.gusts.length, 'gust timelines differ in length');
a.gusts.forEach((g, i) => {
assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `gust ${i} differs`);
});
});
test('different seed => different storm', () => {
const def = storms.storm_02_wildnight;
const a = createWindField(def, { seed: 1 });
const b = createWindField(def, { seed: 2 });
const same = a.gusts.length === b.gusts.length
&& a.gusts.every((g, i) => g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow);
assert(!same, 'seed is being ignored — every storm would be identical');
});
// ---- 5. sampling order must not matter ----
// sample() is called by sail/player/debris/rain in whatever order the frame
// happens to run. If it ever depends on call order, storms stop replaying.
test('sample order independent', () => {
const def = storms.storm_02_wildnight;
// same fixed t values, walked in two different orders, on two fields
const times = [0, 3.5, 17.3, 4.1, 55.0, 88.9, 63.2, 21.7, 39.9, 70.4];
const sorted = [...times].sort((a, b) => a - b);
const inOrder = createWindField(def).setShelters(TREE_SHELTERS);
const shuffled = createWindField(def).setShelters(TREE_SHELTERS);
const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
const seen = new Map();
for (const t of sorted) {
inOrder.vecAt(3, -2, t, va);
seen.set(t, { x: va.x, z: va.z });
}
for (const t of times) { // deliberately out of order, and jumping backwards
shuffled.vecAt(3, -2, t, vb);
const want = seen.get(t);
assert(vb.x === want.x && vb.z === want.z,
`sampling order changed the wind at t=${t}: ${vb.x},${vb.z} vs ${want.x},${want.z}`);
}
});
// ---- 6. the storms are what the design says they are (PLAN3D §7) ----
// storm_02 has to be able to destroy a flat cheap rig; storm_01 must not.
test('storm_02 is genuinely violent, storm_01 is not', () => {
const wild = createWindField(storms.storm_02_wildnight);
const gentle = createWindField(storms.storm_01_gentle);
const peak = (f) => {
let mx = 0, mxBase = 0;
for (let t = 0; t <= f.duration; t += DT) {
mx = Math.max(mx, f.uniformSpeed(t));
mxBase = Math.max(mxBase, f.uniformSpeed(t) - f.gustOnly(t));
}
return { mx, mxBase };
};
const w = peak(wild), g = peak(gentle);
metrics['storm_02.peakGustSpeed'] = +w.mx.toFixed(2);
metrics['storm_02.peakSustained'] = +w.mxBase.toFixed(2);
metrics['storm_01.peakGustSpeed'] = +g.mx.toFixed(2);
metrics['storm_01.peakSustained'] = +g.mxBase.toFixed(2);
assert(w.mx >= 30, `storm_02 peaks at only ${w.mx.toFixed(1)} m/s — won't break a cheap rig`);
assert(w.mxBase >= 18, `storm_02 sustained peaks at only ${w.mxBase.toFixed(1)} m/s`);
assert(g.mx <= 15, `storm_01 peaks at ${g.mx.toFixed(1)} m/s — too wild for the gentle storm`);
assert(w.mx > g.mx * 2, 'storm_02 should be far worse than storm_01');
});
// ---- 7. wind change actually swings the wind ----
test('storm_02 southerly change swings the wind', () => {
const f = createWindField(storms.storm_02_wildnight);
const ev = (storms.storm_02_wildnight.events || []).find((e) => e.type === 'windchange');
assert(ev, 'storm_02 has no windchange event');
const before = f.dirAt(ev.t - 5);
const after = f.dirAt(ev.t + 8);
const swing = Math.abs(after - before);
metrics['storm_02.changeSwingRad'] = +swing.toFixed(3);
assert(swing > 0.9, `change only swings ${swing.toFixed(2)} rad — should be a real slew`);
// and the player must be warned before it lands
assert((ev.telegraph ?? 0) >= 4, 'windchange telegraph is too short to react to');
});
// ---- 8. shelters ----
test('tree wind shadow bites downwind and nowhere else', () => {
const def = storms.storm_02_wildnight;
const bare = createWindField(def);
const shad = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
const t = 30;
const d = bare.dirAt(t);
const dx = Math.cos(d), dz = Math.sin(d);
const lee = { x: dx * 5, z: dz * 5 }; // 5 m downwind of the tree
const luv = { x: -dx * 5, z: -dz * 5 }; // 5 m upwind
const leeS = shad.speedAt(lee.x, lee.z, t), leeB = bare.speedAt(lee.x, lee.z, t);
const luvS = shad.speedAt(luv.x, luv.z, t), luvB = bare.speedAt(luv.x, luv.z, t);
metrics['shelter.leeDrop'] = +(1 - leeS / leeB).toFixed(3);
assert(leeS < leeB * 0.85, `lee side only dropped to ${(leeS / leeB).toFixed(2)}× — shadow too weak`);
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
});
return { cases, metrics };
}
/** Run every case and collect results. Used by run-node.mjs. */
export function runWeatherSuite(storms) {
const { cases, metrics } = weatherCases(storms);
const results = cases.map((c) => {
try {
c.fn();
return { name: c.name, ok: true };
} catch (e) {
return { name: c.name, ok: false, err: e.message };
}
});
const pass = results.filter((r) => r.ok).length;
return { suite: 'weather', pass, fail: results.length - pass, results, metrics };
}

View File

@ -0,0 +1,399 @@
'use strict';
// SHADES — Lane C — wind field core.
//
// Pure math. Zero imports: no THREE, no DOM, no Date.now, no rAF. Everything is
// a closed-form function of (pos, t) given a storm def + seed, which buys us:
// - selftest can fast-forward a 90 s storm and get identical numbers every run
// - consumers can sample any t, in any order, as often as they like
// - the determinism rule (PLAN3D §4) is structural, not a promise
//
// weather.js wraps this to expose the contracts.js surface (Vector3 in/out).
// The prototype scheduled gusts by INTEGRATING (wind.gustT += dt). We can't —
// sample(pos,t) is called by everyone at arbitrary t. So gusts are precomputed
// into a timeline from a seeded PRNG at storm load; the envelope shape below is
// a faithful port of prototype/game.js, just read from t instead of accumulated.
// ---------- gust envelope (ported from prototype/game.js windVec) ----------
// telegraph: wind hasn't risen yet, but you can SEE it coming (grass, band, audio)
export const GUST = Object.freeze({
TELEGRAPH: 1.5, // gt < 1.5 → 0 "it's coming"
RAMP: 0.8, // 1.5 .. 2.3 → 0 → pow
HOLD: 1.7, // 2.3 .. 4.0 → pow
FADE: 1.0, // 4.0 .. 5.0 → pow → 0
TOTAL: 5.0,
});
const RAMP_AT = GUST.TELEGRAPH; // 1.5
const HOLD_AT = RAMP_AT + GUST.RAMP; // 2.3
const FADE_AT = HOLD_AT + GUST.HOLD; // 4.0
const END_AT = FADE_AT + GUST.FADE; // 5.0
/** Gust strength at local gust time gt (seconds since telegraph began). */
export function gustEnvelope(gt, pow) {
if (gt <= 0 || gt >= END_AT) return 0;
if (gt < RAMP_AT) return 0; // telegraph window
if (gt < HOLD_AT) return pow * (gt - RAMP_AT) / GUST.RAMP;
if (gt < FADE_AT) return pow;
return pow * (END_AT - gt) / GUST.FADE;
}
// ---------- deterministic noise ----------
// mulberry32 — small, fast, good enough, and identical in every JS engine.
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// int32 hash — Math.imul keeps it exact (plain * would drift past 2^31 as a double)
function hash2(ix, iz, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iz, 668265263) + Math.imul(seed, 1274126177)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
const smooth = (f) => f * f * (3 - 2 * f);
/**
* Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps.
*
* @param {number} [period] Wrap the lattice at this many cells, making the noise
* tile seamlessly over [0, period). The wind doesn't want this (the yard would
* repeat); a scrolling cloud texture does, or every wrap boundary is a visible
* straight edge in the sky. Pass an integer that matches your frequency.
*/
export function valueNoise2(x, z, seed, period = 0) {
const ix = Math.floor(x), iz = Math.floor(z);
const ux = smooth(x - ix), uz = smooth(z - iz);
// branch, not a closure: this is the wind's hot path (the cloth alone samples
// it thousands of times a second) and a per-call allocation would show up.
let x0 = ix, x1 = ix + 1, z0 = iz, z1 = iz + 1;
if (period > 0) {
x0 = ((x0 % period) + period) % period;
x1 = ((x1 % period) + period) % period;
z0 = ((z0 % period) + period) % period;
z1 = ((z1 % period) + period) % period;
}
const a = hash2(x0, z0, seed), b = hash2(x1, z0, seed);
const c = hash2(x0, z1, seed), d = hash2(x1, z1, seed);
return (a + (b - a) * ux) * (1 - uz) + (c + (d - c) * ux) * uz;
}
export function smoothstep(e0, e1, x) {
if (e0 === e1) return x < e0 ? 0 : 1;
const f = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
return smooth(f);
}
// ---------- curves ----------
/** Piecewise-linear [[t,v],...] lookup, clamped at both ends. */
export function sampleCurve(curve, t) {
if (!curve || curve.length === 0) return 0;
if (t <= curve[0][0]) return curve[0][1];
const last = curve[curve.length - 1];
if (t >= last[0]) return last[1];
for (let i = 1; i < curve.length; i++) {
if (t <= curve[i][0]) {
const [ta, va] = curve[i - 1], [tb, vb] = curve[i];
const span = tb - ta;
return span <= 0 ? vb : va + (vb - va) * ((t - ta) / span);
}
}
return last[1];
}
/** Shortest-arc angle lerp — so a curve crossing ±π doesn't spin the long way. */
export function lerpAngle(a, b, k) {
const TAU = Math.PI * 2;
let d = ((b - a + Math.PI) % TAU + TAU) % TAU - Math.PI;
return a + d * k;
}
function sampleAngleCurve(curve, t) {
if (!curve || curve.length === 0) return 0;
if (t <= curve[0][0]) return curve[0][1];
const last = curve[curve.length - 1];
if (t >= last[0]) return last[1];
for (let i = 1; i < curve.length; i++) {
if (t <= curve[i][0]) {
const [ta, va] = curve[i - 1], [tb, vb] = curve[i];
const span = tb - ta;
return span <= 0 ? vb : lerpAngle(va, vb, (t - ta) / span);
}
}
return last[1];
}
// ---------- gust timeline ----------
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
export function buildGustTimeline(def, seed) {
const g = def.gusts || {};
const rng = mulberry32(seed >>> 0);
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
const out = [];
let t = g.firstAt ?? 3;
// hard cap: a malformed gap can't spin us forever
while (t < def.duration && out.length < 512) {
const p = def.duration > 0 ? t / def.duration : 0;
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
t += minGap + rng() * Math.max(0, maxGap - minGap);
}
return out;
}
// ---------- the field ----------
/**
* @param {object} def parsed storm JSON (see data/storms/*.json)
* @param {object} [opts] {seed}
*/
export function createWindField(def, opts = {}) {
const seed = (opts.seed ?? def.seed ?? 1) >>> 0;
const duration = def.duration ?? 90;
const gusts = buildGustTimeline(def, seed);
const sp = def.spatial || {};
const amp = sp.amp ?? 0.18; // ±18% speed across the yard
const scale = sp.scale ?? 12; // metres per noise cell — yard is 30×20
const advect = sp.advect ?? 0.5; // noise drifts downwind (frozen turbulence)
const wander = def.dirWander || {};
const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13;
const nSeed = (seed ^ 0x9e3779b9) | 0;
let shelters = [];
/** Spatially-uniform part: base curve + every gust envelope live at t. */
function uniformSpeed(t) {
let s = sampleCurve(def.baseCurve, t);
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t <= g.t0) break; // sorted — nothing later can be live
if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow);
}
return s;
}
function gustOnly(t) {
let s = 0;
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t <= g.t0) break;
if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow);
}
return s;
}
function dirAt(t) {
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
}
// ---- noise drift ----
// The noise pattern rides downwind with the mean flow (Taylor's frozen
// turbulence), so a gust visibly travels ACROSS the yard instead of blinking on
// everywhere at once. That displacement is an integral, D(t) = ∫ advect·U·dir dτ,
// and it has to be integrated as one: the obvious closed form `U(t)·advect·t`
// is not the integral, and it whips the whole accumulated field sideways the
// instant U or dir moves — a 6.8 m/s single-frame jump at the southerly change,
// which the continuity assert caught. So integrate once at build time into an
// immutable table; sampling stays a pure function of t.
// Mean flow only (base curve, no gusts): eddies are carried by the wind, they
// don't surf their own gust, and it keeps the drift rate smooth.
const DRIFT_DT = 0.25;
const driftX = [], driftZ = [];
{
let dx = 0, dz = 0;
const n = Math.ceil((duration + 2) / DRIFT_DT) + 2;
for (let i = 0; i < n; i++) {
driftX.push(dx); driftZ.push(dz);
const tt = i * DRIFT_DT;
const u = sampleCurve(def.baseCurve, tt) * advect;
const d = dirAt(tt);
dx += Math.cos(d) * u * DRIFT_DT;
dz += Math.sin(d) * u * DRIFT_DT;
}
}
const drift = { x: 0, z: 0 };
function driftAt(t) {
if (t <= 0) { drift.x = 0; drift.z = 0; return drift; }
const f = t / DRIFT_DT;
let i = Math.floor(f);
if (i > driftX.length - 2) i = driftX.length - 2; // past the end: extrapolate
const k = f - i;
drift.x = driftX[i] + (driftX[i + 1] - driftX[i]) * k;
drift.z = driftZ[i] + (driftZ[i + 1] - driftZ[i]) * k;
return drift;
}
/** Speed multiplier: smooth noise, carried downwind. */
function spatialFactor(x, z, t) {
if (amp <= 0) return 1;
const d = driftAt(t);
const nx = (x - d.x) / scale;
const nz = (z - d.z) / scale;
const n = 0.65 * valueNoise2(nx, nz, nSeed)
+ 0.35 * valueNoise2(nx * 2.2 + 31.7, nz * 2.2 + 11.3, nSeed ^ 0x51ed270b);
return 1 + (n - 0.5) * 2 * amp;
}
/** Trees knock a hole downwind of themselves. Cheap, and very juicy. */
function shelterFactor(x, z, dirX, dirZ) {
let f = 1;
for (let i = 0; i < shelters.length; i++) {
const s = shelters[i];
const rx = x - s.x, rz = z - s.z;
const along = rx * dirX + rz * dirZ; // >0 = downwind of the tree
if (along <= 0 || along >= s.length) continue;
const perp = Math.abs(rx * dirZ - rz * dirX);
if (perp >= s.radius) continue;
// ramp in over the first half-radius so the shadow can't snap on at along=0
const fAlong = smoothstep(0, s.radius * 0.5, along) * (1 - smoothstep(0, s.length, along));
const fPerp = 1 - smoothstep(0, s.radius, perp);
f *= 1 - s.strength * fAlong * fPerp;
}
return f;
}
const field = {
def,
seed,
gusts,
duration,
/**
* Trees/house register wind shadows. Lane A calls this after building the
* yard; unset = no shadows, so nothing breaks before world.js lands.
* @param {Array<{x,z,radius,strength,length}>} list
*/
setShelters(list) {
shelters = (list || []).map((s) => ({
x: s.x, z: s.z,
radius: s.radius ?? 2.5,
strength: Math.min(1, Math.max(0, s.strength ?? 0.45)),
length: s.length ?? (s.radius ?? 2.5) * 4,
}));
return field;
},
get shelters() { return shelters; },
/** Scalar wind speed (m/s) at a point. The cheap path — no allocation. */
speedAt(x, z, t) {
const uni = uniformSpeed(t);
const d = dirAt(t);
const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d));
return s > 0 ? s : 0;
},
dirAt,
uniformSpeed,
gustOnly,
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
vecAt(x, z, t, out) {
const uni = uniformSpeed(t);
const d = dirAt(t);
const dirX = Math.cos(d), dirZ = Math.sin(d);
let s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
if (s < 0) s = 0;
out.x = dirX * s;
out.y = 0; // wind is horizontal; lift is the sail's job (Lane B)
out.z = dirZ * s;
return out;
},
/**
* The next gust that has been telegraphed but hasn't started ramping.
* eta = seconds until the wind actually rises. Null when nothing's inbound.
*/
telegraph(t) {
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t < g.t0) return null; // sorted — next one hasn't telegraphed yet
if (t < g.rampAt) {
return { eta: g.rampAt - t, dir: dirAt(g.rampAt), power: g.pow };
}
}
return null;
},
/** Storm events (windchange/debris) fired in (a, b]. Pure — replayable. */
eventsBetween(a, b) {
const evs = def.events || [];
const out = [];
for (let i = 0; i < evs.length; i++) {
if (evs[i].t > a && evs[i].t <= b) out.push(evs[i]);
}
return out;
},
/** 0..1 rain intensity for skyfx. */
rainAt(t) {
const r = def.rain;
if (!r) return 0;
if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t)));
return Math.min(1, Math.max(0, r.intensity ?? 0));
},
};
return field;
}
// ---------- storm JSON validator ----------
// Storms are data so design can tune without code (PLAN3D §4) — which means a
// typo is a data bug, and data bugs should fail loud, not silently blow calm.
export function validateStorm(def, name = 'storm') {
const errors = [];
const bad = (m) => errors.push(`${name}: ${m}`);
const isCurve = (c) => Array.isArray(c) && c.length > 0
&& c.every((p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite));
const monotonic = (c) => c.every((p, i) => i === 0 || p[0] >= c[i - 1][0]);
if (!def || typeof def !== 'object') { bad('not an object'); return { ok: false, errors }; }
if (!Number.isFinite(def.duration) || def.duration <= 0) bad('duration must be a positive number');
if (!isCurve(def.baseCurve)) bad('baseCurve must be [[t,speed],...] of finite numbers');
else {
if (!monotonic(def.baseCurve)) bad('baseCurve t must be non-decreasing');
if (def.baseCurve.some((p) => p[1] < 0)) bad('baseCurve speed must be >= 0');
const end = def.baseCurve[def.baseCurve.length - 1][0];
if (Number.isFinite(def.duration) && end < def.duration) {
bad(`baseCurve ends at t=${end} but storm runs to ${def.duration} — tail would flatline`);
}
}
if (!isCurve(def.dirCurve)) bad('dirCurve must be [[t,radians],...] of finite numbers');
else if (!monotonic(def.dirCurve)) bad('dirCurve t must be non-decreasing');
const g = def.gusts;
if (!g || typeof g !== 'object') bad('gusts block missing');
else {
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
if (!(minGap > 0)) bad('gusts.minGap must be > 0 (else the timeline never advances)');
if (maxGap < minGap) bad('gusts.maxGap must be >= minGap');
// Overlapping gusts stack, and a stacked telegraph is unreadable to the player.
if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`);
if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0');
}
for (const e of def.events || []) {
if (!Number.isFinite(e.t)) bad(`event ${JSON.stringify(e)} has no finite t`);
if (!e.type) bad(`event at t=${e.t} has no type`);
if (e.type === 'debris' && !e.model) bad(`debris event at t=${e.t} has no model`);
// A windchange event is HUD metadata; dirCurve is the physics. If they drift
// apart the player gets warned about a swing that never comes.
if (e.type === 'windchange' && isCurve(def.dirCurve)) {
const before = sampleAngleCurve(def.dirCurve, e.t - 0.5);
const after = sampleAngleCurve(def.dirCurve, e.t + (e.over ?? 6));
const swing = Math.abs(lerpAngle(before, after, 1) - before);
if (swing < 0.5) {
bad(`windchange at t=${e.t} promises a swing but dirCurve only turns ${swing.toFixed(2)} rad by t=${e.t + (e.over ?? 6)}`);
}
}
}
if (def.rain && def.rain.curve && !isCurve(def.rain.curve)) bad('rain.curve must be [[t,intensity],...]');
return { ok: errors.length === 0, errors };
}

101
web/world/js/weather.js Normal file
View File

@ -0,0 +1,101 @@
'use strict';
// SHADES — Lane C — weather: the wind field everyone samples.
//
// Implements the contracts.js wind surface (PLAN3D §4):
// wind.sample(pos, t) -> Vector3 m/s, includes gusts & local effects
// wind.gustTelegraph(t) -> {eta, dir, power} | null
//
// All the maths lives in weather.core.js (pure, no imports). This file is just
// the THREE adapter + storm loading, so the sim stays node-testable and the
// determinism rule can't be broken by accident.
import * as THREE from '../vendor/three.module.js';
import { createWindField, validateStorm, GUST } from './weather.core.js';
export { GUST, validateStorm };
// Resolved against this module, not the server root: server.py serves the repo
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
// import.meta.url is right under both, and under whatever Lane A does next.
const STORM_DIR = new URL('../data/storms', import.meta.url).href;
/** Fetch + validate a storm def. Throws loud on bad data — storms are content. */
export async function loadStorm(name, dir = STORM_DIR) {
const url = `${dir}/${name}.json`;
const res = await fetch(url);
if (!res.ok) throw new Error(`weather: cannot load ${url} (${res.status})`);
const def = await res.json();
const { ok, errors } = validateStorm(def, name);
if (!ok) throw new Error(`weather: ${url} is invalid:\n ${errors.join('\n ')}`);
return def;
}
/**
* @param {object} def parsed storm JSON
* @param {object} [opts] {seed} same seed + same def = same storm, every run
* @returns the `wind` object from contracts.js
*/
export function createWind(def, opts = {}) {
const field = createWindField(def, opts);
const scratch = { x: 0, y: 0, z: 0 };
const wind = {
/**
* Wind velocity at a world position, m/s.
* @param {THREE.Vector3} pos
* @param {number} t storm time, seconds
* @param {THREE.Vector3} [out] pass one to avoid allocating sail.js
* samples per-face per-frame, so this matters
*/
sample(pos, t, out) {
const v = out || new THREE.Vector3();
field.vecAt(pos.x, pos.z, t, scratch);
return v.set(scratch.x, scratch.y, scratch.z);
},
/** Scalar speed — for HUD, rain, grass. Cheaper than sample(); no allocation. */
speedAt(pos, t) {
return field.speedAt(pos.x, pos.z, t);
},
/** {eta, dir, power} while a gust is inbound but hasn't risen yet, else null. */
gustTelegraph(t) {
return field.telegraph(t);
},
/**
* Register wind shadows (trees, house). Lane A: call after the yard is built.
* Until then there are simply no shadows nothing breaks.
* @param {Array<{x,z,radius,strength,length}>} list
*/
setShelters(list) { field.setShelters(list); return wind; },
/** Convenience: take shadows straight off world.anchors' tree entries. */
setSheltersFromTrees(trees, o = {}) {
return wind.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,
})));
},
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
eventsBetween(a, b) { return field.eventsBetween(a, b); },
/** 0..1 rain intensity. */
rainAt(t) { return field.rainAt(t); },
/** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
dirAt(t) { return field.dirAt(t); },
get duration() { return field.duration; },
get gusts() { return field.gusts; },
get def() { return field.def; },
get seed() { return field.seed; },
core: field,
};
return wind;
}

View File

@ -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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -35,6 +35,10 @@
<div id="summary">running…</div>
<div id="out"></div>
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/" } }
</script>
<script type="module">
import { runAll } from './js/testkit.js';

263
web/world/weather_demo.html Normal file
View File

@ -0,0 +1,263 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SHADES — Lane C — weather bench</title>
<style>
:root { --ink:#d8d8e0; --gold:#ffd23d; --neon:#3dff8b; }
* { box-sizing:border-box; }
body { margin:0; overflow:hidden; background:#000;
font:13px/1.45 "Courier New", ui-monospace, monospace; color:var(--ink); }
canvas { display:block; }
#hud { position:fixed; top:10px; left:10px; background:rgba(6,6,12,.75); padding:8px 12px;
border:1px solid #26263a; z-index:3; min-width:250px; }
#hud b { color:var(--gold); }
#hud .warn { color:#ff6; font-weight:bold; }
#hud .bad { color:#f66; font-weight:bold; }
#ctl { position:fixed; bottom:10px; left:10px; background:rgba(6,6,12,.8); padding:8px 12px;
border:1px solid #26263a; z-index:3; }
#ctl button { background:#1d1d2b; color:var(--ink); border:1px solid #666; font:inherit;
padding:4px 9px; cursor:pointer; }
#ctl button:hover { border-color:var(--neon); color:var(--neon); }
#ctl input[type=range] { width:220px; vertical-align:middle; }
#note { position:fixed; top:10px; right:10px; background:rgba(6,6,12,.75); padding:8px 12px;
border:1px solid #26263a; z-index:3; max-width:280px; color:#8a8a99; }
.bar { display:inline-block; width:90px; height:7px; border:1px solid #555; vertical-align:middle; }
.bar i { display:block; height:100%; background:var(--neon); }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud"></div>
<div id="note">
<b>Lane C bench.</b> Graybox stand-in for Lane A's yard — this exists to drive
weather.js / skyfx.js / debris.js before M0 lands. The sail here is a MOCK
(Lane B owns the real one); it's a bare node grid so debris impulse is visible.
<br><br>drag = orbit · click = start audio
</div>
<div id="ctl"></div>
<script type="module">
import * as THREE from './vendor/three.module.js';
import { loadStorm, createWind } from './js/weather.js';
import { createSkyFx } from './js/skyfx.js';
import { createDebris } from './js/debris.js';
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(2, devicePixelRatio));
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9fc4e8);
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 500);
// --- graybox yard: 30×20 m, origin centre (stands in for Lane A's world.js) ---
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(30, 20),
new THREE.MeshStandardMaterial({ color: 0x4a7c3f, roughness: 1 }),
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Lane A's landed yard (THREADS): t1 (-9,2), t2 (8,-2), house edge at z=-9.9
const TREES = [{ x: -9, z: 2 }, { x: 8, z: -2 }];
for (const tr of TREES) {
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.2, 0.28, 4, 8),
new THREE.MeshStandardMaterial({ color: 0x5a3d24 }),
);
trunk.position.set(tr.x, 2, tr.z);
trunk.castShadow = true;
scene.add(trunk);
const canopy = new THREE.Mesh(
new THREE.SphereGeometry(3, 12, 8),
new THREE.MeshStandardMaterial({ color: 0x285f23 }),
);
canopy.position.set(tr.x, 5, tr.z);
canopy.castShadow = true;
scene.add(canopy);
}
// house edge along north (-Z), for scale
const house = new THREE.Mesh(
new THREE.BoxGeometry(30, 3.2, 1),
new THREE.MeshStandardMaterial({ color: 0x8a8f96 }),
);
house.position.set(0, 1.6, -10.4);
scene.add(house);
// the thing you're protecting — Lane A's gardenBed rect
const bed = new THREE.Mesh(
new THREE.BoxGeometry(6, 0.25, 4),
new THREE.MeshStandardMaterial({ color: 0x6b4a2f }),
);
bed.position.set(1, 0.12, 2);
scene.add(bed);
// 1.7 m reference person
const ref = new THREE.Mesh(
new THREE.CapsuleGeometry(0.25, 1.2, 4, 8),
new THREE.MeshStandardMaterial({ color: 0xffd27a }),
);
ref.position.set(2, 0.85, 2);
ref.castShadow = true;
scene.add(ref);
const player = { pos: ref.position, carrying: null, busy: false };
const sun = new THREE.DirectionalLight(0xfff4e0, 2.2);
sun.position.set(-12, 18, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
scene.add(sun);
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 0.9);
scene.add(hemi);
// --- MOCK sail (Lane B owns the real cloth) — a bare node grid so we can see
// debris shove it and drive the creak/flog audio off corner loads.
const N = 9;
const nodes = [];
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
nodes.push({ x: -4 + (u / (N - 1)) * 8, y: 3.2, z: -3 + (v / (N - 1)) * 6 });
}
}
const sailGeo = new THREE.BufferGeometry();
sailGeo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(nodes.length * 3), 3));
const sailPts = new THREE.Points(sailGeo, new THREE.PointsMaterial({ color: 0xe8c46a, size: 0.14 }));
scene.add(sailPts);
const mockSail = {
nodes,
corners: [
{ anchorId: 'h1', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
{ anchorId: 'h3', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
{ anchorId: 'p1', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
{ anchorId: 'p2', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
],
};
// --- weather ---
const params = new URLSearchParams(location.search);
const stormName = params.get('storm') || 'storm_02_wildnight';
const def = await loadStorm(stormName);
const wind = createWind(def);
wind.setShelters(TREES.map((t) => ({ x: t.x, z: t.z, radius: 3, strength: 0.45, length: 14 })));
const ticker = [];
const sky = createSkyFx({ scene, camera, wind, sun, hemi, onEvent: (s) => ticker.unshift(s) });
const debris = createDebris({
wind, scene, player,
onEvent: (s) => ticker.unshift(s),
onHitPlayer: (p, impact) => ticker.unshift(`KNOCKED DOWN by ${p.model} (${impact.toFixed(0)})`),
});
addEventListener('pointerdown', () => sky.unlockAudio(), { once: true });
// --- controls ---
let t = 0, playing = true, rate = 1;
const ctl = document.getElementById('ctl');
ctl.innerHTML = `
<button id="play">pause</button>
<button id="r1">1×</button><button id="r4">4×</button><button id="r0">0.25×</button>
<button id="reset">reset</button>
<button id="break">break a corner</button>
<button id="crate">throw a crate</button>
<input id="scrub" type="range" min="0" max="${def.duration}" step="0.1" value="0">
`;
const $ = (id) => document.getElementById(id);
$('play').onclick = () => { playing = !playing; $('play').textContent = playing ? 'pause' : 'play'; };
$('r1').onclick = () => { rate = 1; };
$('r4').onclick = () => { rate = 4; };
$('r0').onclick = () => { rate = 0.25; };
$('reset').onclick = () => { t = 0; debris.clear(); ticker.length = 0; mockSail.corners.forEach((c) => { c.broken = false; }); };
$('break').onclick = () => { const c = mockSail.corners.find((x) => !x.broken); if (c) { c.broken = true; ticker.unshift(`${c.hw.name} BLOWS at ${c.anchorId.toUpperCase()}!`); } };
$('crate').onclick = () => debris.spawn({ model: 'BlueCrate_v2', lateral: (Math.random() * 6 - 3), text: 'crate!' }, t);
$('scrub').oninput = (e) => { t = parseFloat(e.target.value); debris.clear(); };
let yaw = 0.7, pitch = 0.28, dist = 26, dragging = false, lx = 0, ly = 0;
addEventListener('pointerdown', (e) => { dragging = true; lx = e.clientX; ly = e.clientY; });
addEventListener('pointerup', () => { dragging = false; });
addEventListener('pointermove', (e) => {
if (!dragging) return;
yaw -= (e.clientX - lx) * 0.005; pitch = Math.min(1.3, Math.max(0.05, pitch + (e.clientY - ly) * 0.004));
lx = e.clientX; ly = e.clientY;
});
addEventListener('wheel', (e) => { dist = Math.min(60, Math.max(8, dist + e.deltaY * 0.02)); });
function resize() {
const w = innerWidth, h = innerHeight;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
addEventListener('resize', resize); resize();
// --- loop: fixed-dt sim, rAF only drives the clock (PLAN3D §0) ---
const DT = 1 / 60;
let acc = 0, last = performance.now();
const hud = document.getElementById('hud');
const probe = new THREE.Vector3();
const w = new THREE.Vector3();
const posAttr = sailGeo.getAttribute('position');
function frame(now) {
const real = Math.min(0.1, (now - last) / 1000);
last = now;
if (playing) acc += real * rate;
while (acc >= DT) {
acc -= DT;
t += DT;
if (t > def.duration) t = 0;
// mock cloth: nodes just bob with local wind so debris has something to hit
for (const n of nodes) {
probe.set(n.x, n.y, n.z);
wind.sample(probe, t, w);
const sp = Math.hypot(w.x, w.z);
n.y += ((3.2 + Math.sin(t * 3 + n.x) * sp * 0.02) - n.y) * 0.08;
}
// mock loads so the creak layer has something to track
probe.set(0, 3.2, 0);
const sp = wind.speedAt(probe, t);
mockSail.corners.forEach((c, i) => {
c.load = c.broken ? 0 : sp * sp * 0.021 * (0.7 + i * 0.16);
});
debris.step(DT, t, { player, sail: mockSail });
sky.step(DT, t, { sail: mockSail });
}
for (let i = 0; i < nodes.length; i++) posAttr.setXYZ(i, nodes[i].x, nodes[i].y, nodes[i].z);
posAttr.needsUpdate = true;
camera.position.set(
Math.sin(yaw) * Math.cos(pitch) * dist,
Math.sin(pitch) * dist + 1.5,
Math.cos(yaw) * Math.cos(pitch) * dist,
);
camera.lookAt(0, 2, 0);
$('scrub').value = t.toFixed(1);
probe.set(0, 1.7, 0);
wind.sample(probe, t, w);
const speed = Math.hypot(w.x, w.z);
const tg = wind.gustTelegraph(t);
const worst = Math.max(...mockSail.corners.map((c) => (c.broken ? 0 : c.load / c.hw.rating)));
hud.innerHTML = `
<div><b>${def.name}</b> — ${stormName}</div>
<div>t <b>${t.toFixed(1)}</b> / ${def.duration}s (${rate}×)</div>
<div>wind <b>${speed.toFixed(1)}</b> m/s (${(speed * 3.6).toFixed(0)} km/h)</div>
<div>dir ${(wind.dirAt(t)).toFixed(2)} rad</div>
<div>rain <span class="bar"><i style="width:${wind.rainAt(t) * 100}%"></i></span></div>
<div>worst <span class="bar"><i style="width:${Math.min(100, worst * 100)}%;background:${worst > 0.8 ? '#f66' : '#3dff8b'}"></i></span></div>
<div>debris ${debris.pieces.length} audio ${sky.audio.ready ? sky.audio.state : '(click)'}</div>
<div>flash ${sky.flash.toFixed(2)}</div>
${tg ? `<div class="warn">GUST INBOUND ${tg.eta.toFixed(1)}s pow ${tg.power.toFixed(0)}</div>` : '<div>&nbsp;</div>'}
${ticker.slice(0, 3).map((s) => `<div class="bad">${s}</div>`).join('')}
`;
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
window.__bench = { wind, sky, debris, mockSail, def, get t() { return t; } };
</script>
</body>
</html>