Compare commits
20 Commits
43ab43d3fb
...
135b3dcda5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
135b3dcda5 | ||
|
|
5d1d0895f2 | ||
|
|
823dbb942b | ||
|
|
a5ed4fcdb6 | ||
|
|
624a72e458 | ||
|
|
1ace50da0f | ||
|
|
cd173d1ce0 | ||
|
|
3f6fc27d00 | ||
|
|
ac5021d279 | ||
|
|
af2694257a | ||
|
|
0f5f4d8368 | ||
|
|
d3e849fcc3 | ||
|
|
7cd25a5d2a | ||
|
|
fd6dfc77da | ||
|
|
2034593a30 | ||
|
|
cb557a1d6f | ||
|
|
d6aa124cb7 | ||
|
|
6971f31984 | ||
|
|
135511fb05 | ||
|
|
7e8ffa307c |
@ -158,3 +158,60 @@ re-deciding them.
|
||||
> 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.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# SPRINT 3 prompts (the game loop — fire all five; B+C pair on item 1)
|
||||
|
||||
Same rules: own clone, own branch, rebase onto latest main FIRST (Sprint 2 is
|
||||
merged; main.js passes debris into rig.step; downdraft data reverted to 0.3
|
||||
with B's assert self-skipping — THREADS' last [I] entry explains why). Read
|
||||
SPRINT3.md in full; decisions 7/8/9 are made.
|
||||
|
||||
## Lane A — Sprint 3
|
||||
> You are Lane A on SHADES 3D, Sprint 3. Rebase onto main, read SPRINT3.md
|
||||
> §Lane A. Priority 1 is world.shedTable (unblocks D, ~15 lines). Then the
|
||||
> decision-2 anchor rework with the quad-area assert, the playable prep phase
|
||||
> (wire B's picking adapter, take their preview-rig force-arrows offer), the
|
||||
> HUD (kN bars, telegraph, garden HP via skyfx.rainShadowOver per decision 7,
|
||||
> plant damage-state swaps), forecast card + aftermath screen (score the gnome),
|
||||
> and finish the yard dressing with E's GLBs and sway handles. Retitle the page.
|
||||
> Merge shepherd duties continue. Small commits, selftest green after each.
|
||||
|
||||
## Lane B — Sprint 3
|
||||
> You are Lane B on SHADES 3D, Sprint 3. Rebase onto main and read SPRINT3.md
|
||||
> §B+C and THREADS' last [I] entry — the integrator measured that gust-only
|
||||
> downdraft cannot satisfy your 60% bar and §7 together (0.45→42% + twisted
|
||||
> loses a corner; 0.58→48% + still loses one), so decision 8 adopts your
|
||||
> fraction-of-TOTAL semantics. Pair with C in THREADS: when their weather.core
|
||||
> change lands, re-run your 8-direction sweep, delete the <0.5 self-skip from
|
||||
> your decision-3 assert, and confirm all three §7 legs on the SAME storm JSON.
|
||||
> Log final constants. Then sail UVs with E's sail_weave.png per their recipe.
|
||||
> Also: A may take your preview-rig offer for prep force arrows — support them.
|
||||
|
||||
## Lane C — Sprint 3
|
||||
> You are Lane C on SHADES 3D, Sprint 3. Rebase onto main and read SPRINT3.md
|
||||
> §B+C and THREADS' last [I] entry. Decision 8: change weather.core downdraft
|
||||
> to a fraction of TOTAL wind speed (keep the own-RNG determinism guarantee,
|
||||
> keep speedAt() horizontal, update the validator; rename the JSON field if the
|
||||
> semantics warrant it), then pair with B to land values where their 60% bar
|
||||
> AND the §7 gates are green on the same data. Retune storm_01/storm_02, and
|
||||
> author storm_03 between them so the campaign has a ramp. Decision 7 landed
|
||||
> garden HP on your rainShadowOver — coordinate the API with A as they wire it.
|
||||
|
||||
## Lane D — Sprint 3
|
||||
> You are Lane D on SHADES 3D, Sprint 3. Rebase onto main, read SPRINT3.md
|
||||
> §Lane D. The moment A posts world.shedTable in THREADS: close the §7 loop BY
|
||||
> HAND — pickup → carry through gusts (brace when needed) → hold-E repair →
|
||||
> ≥3/4 corners at storm end — and RECORD the run (SHADES.step + screenshots);
|
||||
> that artifact is the sprint's definition of done. Retune stumble/knockdown
|
||||
> thresholds against real storm_02 gusts now that the downdraft is live.
|
||||
> Stretch: the ladder loop (carry, place, ClimbLadder to fascia anchors) —
|
||||
> flag early in THREADS if it's bigger than the sprint.
|
||||
|
||||
## Lane E — Sprint 3
|
||||
> You are Lane E on SHADES 3D, Sprint 3. Rebase onto main, read SPRINT3.md
|
||||
> §Lane E. Small juice pass: tear-decal hookup recipe for B (like your weave
|
||||
> recipe), broken-gnome + snapped-fence-panel variants for the aftermath
|
||||
> screen, and refresh the assembled-yard contact sheet once A's dressing lands.
|
||||
|
||||
115
SPRINT3.md
Normal file
115
SPRINT3.md
Normal file
@ -0,0 +1,115 @@
|
||||
# SPRINT 3 — THE GAME LOOP (instructions for Opus 4.8 lanes)
|
||||
|
||||
*Sprint 2 verdict: the storm is real. Merged main boots into a yard with live
|
||||
weather, a walking ped, a rendered sail that cascades believably, debris that
|
||||
dents both cloth and player, rain that stops at the cloth, and 169/0/0 green.
|
||||
What's missing is the GAME around it: you can't rig with the mouse in the shell,
|
||||
can't pick up a spare, can't see loads without the console, and nothing scores
|
||||
the aftermath. Sprint 3 closes the loop so a stranger could play it.*
|
||||
|
||||
Read THREADS.md from the last [I] entry down before starting. Two integrator
|
||||
facts you must absorb: (1) main.js now passes `debris` as rig.step's 4th arg;
|
||||
(2) the downdraft data is back at C's 0.3/0.18 and B's decision-3 assert
|
||||
self-skips below 0.5 — item 1 below is why.
|
||||
|
||||
## Decisions (made)
|
||||
|
||||
7. **Garden HP is driven by `skyfx.rainShadowOver(bed)` during storms.**
|
||||
B and C both recommended it and they're right — at night the sun shadow is a
|
||||
number about nothing. `rig.coverageOver(bed, sunDir)` stays as the daytime
|
||||
shade readout (and becomes the heatwave scorer later). Lane A wires it.
|
||||
8. **Downdraft semantics change to fraction-of-TOTAL wind speed** (B's
|
||||
preference, C's code). Gust-only semantics measurably cannot satisfy both
|
||||
the no-free-lunch bar and §7 survival (numbers in THREADS [I] 2026-07-17).
|
||||
9. **`world.shedTable` is Lane A's, this sprint, first.** It gates D's §7
|
||||
hand-play and it's ~15 lines of dressing.
|
||||
|
||||
## Lane A — finish the shell (the sprint's spine)
|
||||
|
||||
In priority order:
|
||||
1. **`world.shedTable`** — place `shed_01_v1.glb` + `shed_table_v1.glb`, expose
|
||||
`{pos}` (use E's `pickup_anchor` empty if present). Unblocks D immediately.
|
||||
2. **Decision 2 anchor rework** (carried from Sprint 2): posts in to ~(−4.5,5.5)
|
||||
and (4.0,6.0), add p3 near (0,7), register E's tree `branch_anchor_*`
|
||||
(they carry `rating_hint`). New a.test assert: ≥3 pickable quads in 18–45 m²
|
||||
covering the bed. This also fixes B's "cascade at t=0.4 s from pre-tension
|
||||
alone" finding — the yard currently teaches the wrong lesson.
|
||||
3. **Prep phase playable**: wire B's picking adapter (it exists on their branch
|
||||
contract — coordinate in THREADS) to anchor markers + hardware cycling +
|
||||
tension dial + spare purchase, with budget $80. Take B up on their offer of
|
||||
a **preview rig for live force arrows during prep** — DESIGN.md calls this
|
||||
the core teaching tool.
|
||||
4. **HUD**: per-corner load bars in kN vs rating (world-anchored), wind meter +
|
||||
gust telegraph banner, garden HP (decision 7) driving E's
|
||||
plants_full/tattered/dead swaps, phase banner.
|
||||
5. **Forecast card** (storm JSON summary: peak wind, gust character, change
|
||||
time — sell the dread) and **aftermath screen** (garden %, corners lost,
|
||||
hardware bill, verdict line). Enter-to-advance is fine.
|
||||
6. Yard dressing completion: swap graybox house for `house_yardside_v1.glb`
|
||||
(decision 6 — read fascia_anchor_* from the GLB), fence set, washing line
|
||||
(head spins in gusts — E gave you `sway_amp` handles too, use them for the
|
||||
canopy telegraph), wheelie bin into the debris pool, gnome placed in sail
|
||||
range (`collateral_value` 25 — score it in aftermath).
|
||||
7. Retitle the page — it still says M0.
|
||||
|
||||
## Lane B + Lane C — the downdraft semantic (JOINT, do it first, pair in THREADS)
|
||||
|
||||
1. **C**: `weather.core.js` — downdraft becomes a fraction of TOTAL wind speed
|
||||
(sustained + gust), keeping the own-RNG-stream determinism guarantee and the
|
||||
storm-JSON validator (rename the field if semantics change enough to warrant
|
||||
it — e.g. `downdraftOfTotal` — a silently re-meaning field is worse than a
|
||||
rename). Keep `speedAt()` horizontal.
|
||||
2. **B**: re-run the 8-direction sweep at C's proposed values, re-enable the
|
||||
decision-3 assert (delete the <0.5 self-skip — it documents *gust-only*
|
||||
semantics and dies with them), confirm §7 all three legs (cheap cascades /
|
||||
twisted survives / twisted+repair survives) against the SAME data. Log the
|
||||
final constants in THREADS. Both asserts green on the same storm JSON is
|
||||
this item's definition of done.
|
||||
3. **B**: sail UVs + E's `sail_weave.png` per their recipe (uv attribute,
|
||||
repeat 6×6, sRGB) — the membrane should read as fabric, and the tear decal
|
||||
strip is waiting for M3 tearing.
|
||||
4. **C**: storm_03 — author a third storm between gentle and wildnight so the
|
||||
campaign has a ramp (and the forecast card has range to sell).
|
||||
|
||||
## Lane D — close the §7 loop by hand (gate 3)
|
||||
|
||||
1. The moment A posts `world.shedTable`: verify the full loop live — pick up
|
||||
spare (PickUp → Carry/CarryIdle), run it through gusts (shelter on C when
|
||||
needed), hold-E repair at the blown corner (Crank/repair, spare consumed),
|
||||
rig finishes ≥3/4. **Record the run** (SHADES.step + screenshots, or a
|
||||
screen capture) — this artifact is the sprint's definition of done.
|
||||
2. Wire `StumbleBack` vs full knockdown thresholds against real storm_02 gusts
|
||||
with C's downdraft — the brace mechanic (hold C) changes the tuning you did
|
||||
pre-merge.
|
||||
3. Ladder: E shipped `ladder_01_v1.glb` with base/top nodes and you have
|
||||
ClimbLadder — if fascia repairs are meant to need it (DESIGN.md says yes),
|
||||
land carry-ladder + place + climb as the stretch goal; flag early if it's
|
||||
bigger than this sprint.
|
||||
|
||||
## Lane E — juice pass (small)
|
||||
|
||||
1. Sail tear decals hookup notes for B (you shipped the atlas; write the
|
||||
two-line recipe like you did for the weave).
|
||||
2. Aftermath props: broken-gnome variant (`garden_gnome_01_broken`), snapped
|
||||
fence panel — the aftermath screen wants wreckage to point at.
|
||||
3. Assembled-yard contact sheet refresh once A's dressing lands (your Sprint 2
|
||||
item 4, still open — the yard finally looks like the game this week).
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
gate 1: A-1 lands → D starts closing the loop
|
||||
gate 2: B+C downdraft semantic → both physics gates green on one storm JSON
|
||||
gate 3: prep→storm→aftermath playable start to finish with the mouse,
|
||||
§7 scenario ON RECORD, selftest green
|
||||
```
|
||||
|
||||
Definition of done = gate 3. That is also the moment SHADES stops being a tech
|
||||
demo — get there and the next conversation is about content (storms, sites,
|
||||
the landscaper campaign), not systems.
|
||||
|
||||
## For John
|
||||
|
||||
- Nothing blocks on you this sprint. The M1 stays an asset-build box.
|
||||
- When gate 3 lands you'll want to actually play it: `python3 server.py` in the
|
||||
repo, http://localhost:8801/ (or --port anything), WASD/shift/E/C/Enter.
|
||||
306
THREADS.md
306
THREADS.md
@ -506,6 +506,222 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
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.
|
||||
above was measured through them. (Confirmed on my side — every number below came through them too.)
|
||||
|
||||
[C] 2026-07-17 — **LANE C SPRINT 2 LANDED on `lane/c` — decisions 3 & 5, rain occlusion, + A's fog nit.**
|
||||
Selftest **134/0/0** (was 121; +13 Lane C asserts). Rebased on gate-1 main. Four pushes, small commits.
|
||||
Thanks A for doing my §C.5 for me with evidence — dispose() light restore verified, and you caught the
|
||||
fog leak (see below).
|
||||
|
||||
[C] 2026-07-17 — **DECISION 3 — gusts now descend; this is a real rebalance, LANE B read the numbers.**
|
||||
Cloth pressure ∝ dot(wind, normal); a flat panel's normal points at the sky, so in a purely horizontal
|
||||
wind the cheapest winning rig was "lie it flat and ignore the storm". Fixed: per-gust downdraft fraction
|
||||
in storm JSON (`gusts.downdraft`, 0..1, validated), storm_02 0.3 / storm_01 0.18 / default 0.25, each
|
||||
gust varying 0.6–1.4×. `wind.sample()` y is now negative during gusts; `speedAt()` stays horizontal (an
|
||||
anemometer doesn't read falling air). Measured on YOUR rig shape `['h1','h3','p2','p1']`, storm_02,
|
||||
90 s, controlled A/B on the downdraft alone:
|
||||
```
|
||||
downdraft hardware first break peak load
|
||||
0.0 rated shackle never 1929 N
|
||||
0.3 rated shackle never 4165 N ← +116% peak, still survives
|
||||
0.0 shackle never 1929 N
|
||||
0.3 shackle t=20.8 s 6038 N ← now blows; didn't before
|
||||
```
|
||||
So the downdraft **more than doubles peak corner load** and moves the shackle (3200 N) from "survives"
|
||||
to "blows". I did NOT touch a curve — this is decision 3 landing, and the §7 thesis still holds cleanly:
|
||||
a **well-twisted mixed rig** (`['h1','t2','p1','t1']`, rated+shackle mix, tension 0.85) peaks at 3379 N
|
||||
WITH the downdraft and keeps all four corners — twist sheds the descending air, flat catches it, exactly
|
||||
the game. The downdraft sharpens the choice, it doesn't break it. **B: your decision-3 assert** (flat-
|
||||
horizontal peak ≥ 60% of flat-pitched over 8 directions) should pass comfortably now; the wind-side
|
||||
asserts are in c.test ('gusts carry a downdraft…', 'downdraft does not re-time the storm').
|
||||
⚠️ **Determinism guarantee:** the vertical draws from its OWN rng stream so adding/tuning it can't shift
|
||||
(t0, pow). Your hand-verified cascade (carabiner t=45.4, p2 t=56) is untouched — asserted.
|
||||
|
||||
[C] 2026-07-17 — **DECISION 5 — `debris.pieces` FROZEN in contracts.js. B, this is your seam.** New
|
||||
`Debris` + `DebrisPiece` typedefs and `DEBRIS_PIECE_FIELDS`; `checkContract('debris', …)` now runs.
|
||||
Shape you can rely on inside `sail.step()`: `{x,y,z,vx,vy,vz,r,mass,model,hitPlayer,mesh}`, all SI so
|
||||
`mass*v` is a real momentum. Three things the typedef spells out because they'll bite otherwise:
|
||||
· Collision volume is a **sphere radius `r`** centred on (x,y,z) — a crate is boxy but a sphere is
|
||||
what you can afford to test per node per frame. `y` is the CENTRE, rests at `heightAt(x,z)+r`.
|
||||
· The array is **mutated in place** — pieces splice out on despawn. Read it fresh inside step(), don't
|
||||
cache it across frames, don't hold a piece past the step it left in. `clear()` empties the array
|
||||
rather than replacing it, so a reference you hold stays valid (asserted).
|
||||
· Don't move `piece.mesh` — Lane C drives it from the sim each step; you'd be fighting me.
|
||||
|
||||
[C] 2026-07-17 — **RAIN STOPS AT THE CLOTH (§C.3).** Garden visibly stays dry under the sail; verified in
|
||||
the real game — twisted rated rig at t=18, **497 grid cells covered, 96% of the bed, live drops culled
|
||||
under the cloth and falling in the open either side** (screenshot for DESIGN.md). Cost 0.041 ms/rebuild
|
||||
×10/s = **0.41 ms/s**, negligible vs your 0.63 ms frame. Reads `rig.pos`/`rig.tris` only — nothing new
|
||||
from B. It projects the sail down the RAIN direction, so the dry patch sits downwind and walks off the
|
||||
bed at the southerly change — free drama.
|
||||
❓ **LANE A — design call, not mine: which shadow drives garden HP?** `skyfx.rainShadowOver(bed)` (rain,
|
||||
down-wind, what actually keeps the bed dry in a night storm) vs `rig.coverageOver(bed, world.sunDir)`
|
||||
(sun, which at night is a number about nothing). I'd wire HP to the rain one and keep coverageOver for a
|
||||
daytime/aesthetic readout, but it's your HUD/scoring — say the word and I'll match whatever you pick.
|
||||
They agree when the sun is overhead and diverge exactly when the storm makes it interesting.
|
||||
|
||||
[C] 2026-07-17 — **FOG — fixed, thanks A.** `dispose()` captured `scene.fog` by reference and `step()`
|
||||
mutates that object in place, so handing it back restored nothing. Now captured by value (color/near/far)
|
||||
and restored field-by-field; fog that skyfx created itself is removed rather than left behind. Asserted
|
||||
in c.test with vacuity guards (the test first proves the storm actually moved sun + fog, THEN that
|
||||
dispose put them back — a restore test where nothing moved passes forever and checks nothing). Your
|
||||
rebuild-on-phase-change path is clean now in both directions.
|
||||
|
||||
[C] 2026-07-17 — **OPEN: the B+C tuning session (B-4/C-4) still needs both of us in a room.** I have the
|
||||
controlled harness above and the storms are in real m/s; what's left is your call on whether the *cheap
|
||||
flat* cascade lands at a satisfying beat and whether storm_02's curve wants a nudge for the by-hand §7
|
||||
run. My position: curves are good as-is, the downdraft did the balancing work — but if you want the
|
||||
carabiner rig to blow earlier/later for feel, that's a one-line data edit and I'll make it. Ping when
|
||||
sail-side tuning is settled and we lock constants together. (weather_demo.html retired candidate: the
|
||||
game IS the bench now — I'll delete it once we've used it for this session, not before.)
|
||||
[B] 2026-07-17 — **SPRINT 2 LANDED on `lane/b`: decisions 4 & 5, the picking UI, the real-wind §7 gate.**
|
||||
39 asserts green (26 sail + 13 rigging), `checkContract('sailRig')` still conforms.
|
||||
· **Decision 4** — conformed to Lane D's spelling, not the reverse: `repair(i)`, `trim(i, delta)`,
|
||||
`cornerPos(i)`. All three are contract entries now rather than PROPOSED comments, so the tripwire
|
||||
enforces the seam. D: `repair(i)` takes no hardware arg because prep sells exactly one kind of
|
||||
spare, so it re-rigs at SHACKLE grade — an upgrade on a blown carabiner, a downgrade on a blown
|
||||
rated shackle. `cornerPos(i)` is a fresh vector on the live node: measured 13 m off the anchor on
|
||||
a flogging corner, so your prompt chases it.
|
||||
· **Decision 5** — `sail.step(dt, wind, t, debris)` now applies sphere-vs-cloth impulses. Symmetric:
|
||||
every newton-second the cloth takes out of a crate, the crate loses. Conserves to 0.000% on an
|
||||
interior hit (asserted). Pinned corners are the deliberate exception — a crate off a corner dumps
|
||||
its momentum into the house, which is correct, it's bolted to a wall. **Lane A: this needs the
|
||||
4th arg — `rig.step(dt, wind, windT, debris)` in main.js, or the crates fly through the sail.**
|
||||
· **§7 gate now runs on the real storm JSON**, not my stub. Flat drum-tight carabiner rig cascades
|
||||
4/4; twisted mixed rig holds 4/4; twisted rig with one dodgy corner blows it and finishes 4/4
|
||||
after a single `repair()` — the sprint's DoD scenario, in an assert.
|
||||
|
||||
[B] 2026-07-17 — **⚠️ LANE C — decision 3 does NOT clear its own bar yet. Numbers, before you merge.**
|
||||
Your ask was: flat-horizontal peak ≥ 60% of flat-pitched over 8 directions. Measured against your
|
||||
branch, 8 headings, **full 90 s**: **flat-horizontal 0.56 kN vs flat-pitched 1.66 kN = 34%.** Still a
|
||||
free lunch. Why: `downdraft: 0.3` is 0.3 of the **gust component only**, and storm_02's strongest
|
||||
downdraft is **−4.5 m/s** against a **32.6 m/s** horizontal peak (t=75.3 s). Pressure goes as v², so
|
||||
4.5² / (32.6·sin 16.7°)² ≈ ⅓ — which is the 34% almost exactly. To reach 60% the downdraft needs to
|
||||
hit ~7.3 m/s, i.e. **downdraft ≈ 0.55–0.6 of gust power**, or make it a fraction of TOTAL speed rather
|
||||
than gust-only (I'd prefer total: a gust front descends whether or not it's also the peak).
|
||||
Your +116% A/B is real and I reproduced it (twisted rig 1.16 → 2.73 kN) — but it measured absolute
|
||||
load on one pitched 192 m² quad, which is a different question from the horizontal-vs-pitched RATIO,
|
||||
and I don't think a direction sweep was ever in it. Also worth knowing: the ratio is sensitive to what
|
||||
I call "flat-pitched" (mine is 16.7°), so if you'd rather move the bar than the data, say so and I'll
|
||||
make the geometry explicit in the assert.
|
||||
**My assert is written and SKIPS while main has no downdraft field, so main stays green — but it goes
|
||||
RED the moment your branch merges unless the downdraft rises.** You offered "a one-line data edit";
|
||||
this is me taking you up on it. Ping when it's in and I'll re-measure the same sweep.
|
||||
Two other things from your entries, both confirmed: `debris.pieces` matches what I built against
|
||||
(sphere r at (x,y,z), read fresh, mesh untouched — I never hold a piece past its step), and I'm now
|
||||
passing your `out` vector to `wind.sample`, which I'd been ignoring — that was ~9.7k throwaway
|
||||
Vector3s a second. My answer on your rain-vs-sun HP question is with Lane A, but for the record I
|
||||
agree with you: wire garden HP to `rainShadowOver`, keep `coverageOver` for the daytime readout. At
|
||||
night the sun shadow is a number about nothing.
|
||||
|
||||
[B] 2026-07-17 — **⚠️ LANE A — the §7 cheap-rig cascade currently fires at t=0.4 s, and it's the yard.**
|
||||
A flat drum-tight carabiner rig on the obvious quad `h1/h3/p2/p1` loses its first corner 0.4 s after
|
||||
the storm starts — not from the storm, from PRE-TENSION alone. 192 m² at tension 1.3 is ~6 kN per
|
||||
corner before any wind blows (measured per-corner peaks: h1 6.10 / h3 6.00 / p2 7.25 / p1 6.01 kN).
|
||||
It's physically right — you cannot drum-tighten 192 m² on $5 carabiners — but it reads as "the rig
|
||||
exploded before the storm did anything", which is a worse lesson than "the gust got it". **Decision 2
|
||||
fixes this**: once 18–45 m² quads exist, pre-tension drops off the cliff and the cascade lands
|
||||
mid-storm where it belongs. Not blocking; flagging so it isn't mistaken for a cloth bug when you play
|
||||
it. Related: the twisted quad `h1/t2/p1/t1` is 145 m² and survives comfortably (peak 2.73 kN with C's
|
||||
downdraft), so the yard is *playable* today, just not *teaching* today.
|
||||
Also: prep can't show live corner loads, because nothing is attached until commit. DESIGN.md wants
|
||||
"live force arrows during planning" — that needs a preview rig stepped during prep. Cheap to do from
|
||||
my side if you want it in the HUD; say the word.
|
||||
|
||||
[B] 2026-07-17 — **Lane A — wiring the prep phase (this is your step 8).** `createRiggingUI({scene,
|
||||
camera, domElement, world, onCommit, onMessage})` → `ui.setActive(phase === 'prep')` on phaseChange,
|
||||
`ui.update(dt, t)` each frame, `ui.commit()` when Enter leaves prep — it calls back through your
|
||||
`rigSail()` door exactly as you asked, so the single-door invariant holds. `ui.summary` gives the HUD
|
||||
`{budget, spent, tension, spares, canStart, corners:[{anchorId,hw,rating,cost}], weakest, area}`.
|
||||
It ships its own DOM panel; pass `panel:false` and render `summary` yourself if hud.js wants it.
|
||||
It renders its own anchor markers because the yard has none to raycast against — world.js builds
|
||||
posts and trunks, not pick targets. If you'd rather own them, take `world.anchorMarkers` and I'll
|
||||
consume it; otherwise leave it with me, marker styling is prep-phase UI.
|
||||
LMB rig / cycle, shift-LMB remove, `[`/`]` tension, S spare — RMB stays yours (camera orbit).
|
||||
Verified by hand in `dev_rigging.html` (new, follows C's weather_demo / D's dev_player pattern):
|
||||
clicked h2, cycled carabiner→shackle, budget $80→$65, weak link flagged, dashed quad preview, Enter →
|
||||
sail in scene (100 verts / 162 tris, casting a real shadow across the bed) → storm → corner loads
|
||||
reading 1.0–1.2 kN. **One thing worth stealing: the panel shows live sail AREA.** Picking the obvious
|
||||
quad says "191 m2" *before* you commit — which is the only way the 70–192 m² problem is visible to a
|
||||
player. Retire dev_rigging.html once index.html hosts prep.
|
||||
|
||||
[B] 2026-07-17 — a bug worth passing on, since it's the kind every lane can have: `_checkFailure` marked a
|
||||
corner broken but never gave its node its mass back, so a "blown" corner stayed pinned in mid-air and
|
||||
the sail quietly went dead instead of flogging. **The cascade test missed it completely because it
|
||||
forced the break by hand and called `_repin()` itself** — it set up the state the code was supposed to
|
||||
produce, and so it never executed the path that was broken. The replacement drives a real overload
|
||||
failure and asserts the corner tears free and keeps moving. If your suite hand-builds state before
|
||||
asserting on it, it may be green over a dead code path.
|
||||
[D] 2026-07-17 — **SPRINT 2 part 1 on `lane/d`** — the player is now a body in a storm, not a camera
|
||||
target. Selftest **35 Lane D asserts, 0 fail** (was 20). All verified in the real yard, not just
|
||||
in asserts:
|
||||
@ -608,3 +824,93 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
fine. `shed_01_v1.glb` + `shed_table_v1.glb` are on disk from E and currently unused; if E baked a
|
||||
`pickup_anchor` empty, read that, else the table top is fine. That plus B's three lines above and
|
||||
the §7 scenario is hand-playable.
|
||||
|
||||
[A] 2026-07-17 — 🚩 **GATE 1 (Sprint 3) — `world.shedTable` IS LIVE. LANE D: GO.** On main. Lane E's
|
||||
`shed_01_v1.glb` + `shed_table_v1.glb` are dressed into the yard on the east side, and the pickup
|
||||
point is **`world.shedTable.pos` = (9.00, 0.909, 6.00)** — read from E's baked `pickup_anchor`, which
|
||||
sat 5 cm off my guess at where a table top is, so it was worth reading rather than assuming. Your
|
||||
1.5 m radius off it is unchanged, and `spare_table` now registers in `wireYardActions`.
|
||||
**Verified by hand, not by a registration check** (SHADES.step, no rAF): walk up → hold E → `carrying`
|
||||
goes `null` → `"spare"`. A second hold reports "hands full" and deals nothing. Leaning on the table
|
||||
with E held for 6 s deals exactly ONE spare — your latch works. Nothing fires from across the yard.
|
||||
Selftest **169/0/0**.
|
||||
|
||||
[A] 2026-07-17 — 🔧 **Gotcha for anyone hand-driving the player — it nearly cost me a false bug report.**
|
||||
`KeyboardInput.holding` is a **getter with no setter** (`get holding() { return this.keys.has('KeyE') }`).
|
||||
Assigning `player.keyboard.holding = true` from a console probe silently does nothing, the pickup
|
||||
never fires, and it looks exactly like a broken interact wiring — I was about to report the spare
|
||||
pickup as still-blocked when the target list was already correct. Fake the key at the source instead:
|
||||
```js
|
||||
s.player.keyboard.keys.add('KeyE'); // hold
|
||||
s.player.keyboard.keys.delete('KeyE'); // release
|
||||
```
|
||||
Same for movement (`KeyW`/`ShiftLeft`) and brace (`KeyC`). Not asking for a change — the getter is
|
||||
right, my probe was wrong. Lane D, this is worth knowing for your on-record §7 run.
|
||||
|
||||
[A] 2026-07-17 — 📐 `createWorld()` stays **synchronous** and yard dressing moved to a new **`await
|
||||
world.dress()`**, called by main.js right after construction. Reason: a.test.js and selftest.html
|
||||
build a yard with no server, so a fetch in the constructor is either a break or a flake. Anything of
|
||||
mine you need at wiring time (like `shedTable.pos`) is published from constants at construction and
|
||||
only *refined* by dress(), never created by it — and dress() mutates that vector rather than
|
||||
reassigning it, so `pos:` references captured by `interact.register` stay live. Each GLB load is
|
||||
guarded on its own: a missing asset leaves its graybox standing rather than taking boot down.
|
||||
|
||||
[I] 2026-07-17 — **SPRINT 2 INTEGRATION (main).** Lanes b/c/d/e merged (keep-both THREADS). Wired B's
|
||||
4th arg in main.js (`rig.step(dt, wind, windT, debris)` — crates no longer fly through cloth).
|
||||
**The B↔C downdraft dispute is real and data-only cannot settle it:** measured at merge — gust-only
|
||||
downdraft 0.45 → ratio 42% AND the twisted rig loses a corner; 0.58 → 48%, still loses one. The 60%
|
||||
bar and the §7 survival gate pincer each other under gust-only semantics. Storm data reverted to C's
|
||||
landed 0.3/0.18; B's decision-3 assert now self-skips below downdraft 0.5 with the measurements in a
|
||||
comment. **SPRINT3 item 1 (joint B+C): downdraft as fraction of TOTAL wind speed** — loads a flat
|
||||
roof steadily without spiking the gust peak; then re-raise the bar and re-run both gates.
|
||||
Selftest on merged main: **169 pass / 0 fail**. Hand-driven check via SHADES.step: storm_02 with the
|
||||
default rig loses p1 (carabiner) + p2 by t=40 with downdraft live — cascade is earlier and meaner
|
||||
than A's pre-downdraft run, as C's numbers predicted. Screenshot of the merged storm going to DESIGN.md.
|
||||
|
||||
[D] 2026-07-17 — ✅ **GATE 3 §7 LOOP CLOSED BY HAND, ON RECORD — 4/4 survival, real storm-induced break.**
|
||||
Drove the merged game through `SHADES.step` (no rAF), real key input, real hold-E. Full trace:
|
||||
prep: rig 3×rated + 1×carabiner@p1, tn 1.0, settle → `oooo`
|
||||
t=3.9 walk to shed table (9,6) → hold E → **carrying=spare** (PickUp→CarryIdle)
|
||||
t≈3.4 storm: carry the spare across the yard (clip **Carry**) toward p1
|
||||
**p1's carabiner blows on its own** under storm load → `oooX`, sail flogs loose over the garden
|
||||
prompt **`rerig_3` tracks the FLOGGING corner** as it swings (live cornerPos — the feature working)
|
||||
hold E 2.5 s at the loose corner (clip **Crank**, state busy) → `oooo`, **spare consumed**,
|
||||
**p1 re-rigged carabiner→shackle (an UPGRADE**, exactly B's documented behaviour)
|
||||
ride out to t=90 → **ends 4/4, coverage 1.0, no further breaks** → aftermath, garden intact.
|
||||
4 screenshots (shed pickup · flogging sail + player w/ spare · re-tensioned sail · calm aftermath)
|
||||
in this session's transcript. **This is the §7 thesis working: mixed rig with a weak link → the
|
||||
weak link fails → one repair upgrades it → the now-uniform rig survives.** Definition of done met,
|
||||
and the break was the storm's, not induced.
|
||||
|
||||
[D] 2026-07-17 — 🔧 **RETUNED player thresholds against the REAL storm_02 (my pre-merge tune was against
|
||||
a mock).** Committed ahead of the run. In the actual storm, gusts-over-baseline peak at **12.1 m/s**,
|
||||
so my old `stumbleGust:17` was above every gust in the game — **StumbleBack was unreachable dead
|
||||
code**. And `shoveGustMin:8` (a low bar in the prototype's 12–38 units) had become a high bar in
|
||||
ours. Fixed: stumbleGust 17→9, shoveGustMin 8→4. A wild night now reads shoved (26 s of 90) →
|
||||
stumbling (4) → floored (2); a calm day stays 0/0. **d.test.js now loads the real storm JSON
|
||||
(`weather.loadStorm`) and asserts every threshold is REACHABLE and correctly ordered** — decision 8
|
||||
reweights the downdraft this sprint, and this guard fails loudly if that silently kills a mechanic
|
||||
again. 40 Lane D asserts, 0 fail.
|
||||
|
||||
[D] 2026-07-17 — 📊 **FOR B+C (decision 8) — measured the sail's failure envelope, and it's a CLIFF not a
|
||||
slope.** Driving `rig.step` against the real wild wind, 3×rated+1×carabiner, settled, per-corner peak
|
||||
loads:
|
||||
tn ≤ 1.03 → **4/4 survives**, peaks ~875–1240 N (carabiner rides just under its 1200 N rating)
|
||||
tn 1.04 → **instant double cascade**: h1 and p2 spike to **~10,300 N at t=1.1** and blow → 1/4
|
||||
A 0.01 tension step takes peak load from ~1.2 kN to ~10 kN — that's the cloth going **unstable**, not
|
||||
gradual overload. Consequence for §7: in a HIGH-tension rig the cascade is faster than any hold-E
|
||||
(neighbours gone <1 s after the first break), so a naturally-broken corner there is **not**
|
||||
hand-repairable. BUT a survivable mixed rig (my run) IS — the weak link blows while the rated corners
|
||||
keep margin and don't cascade, so the repair lands. **Net: good rigs survive, flat/drum-tight rigs
|
||||
cascade — the thesis holds.** The decision-8 downdraft-semantic (fraction of total vs gust) is really
|
||||
about loading a FLAT roof *steadily*; it won't fix the cliff, which is a cloth-stability ceiling near
|
||||
tn 1.04. Worth a stability clamp on peak per-face force if you want a gentler failure curve.
|
||||
|
||||
[D] 2026-07-17 — 🪜 **LADDER STRETCH GOAL — DEFERRED, flagging early as asked.** It's bigger than what's
|
||||
left of this sprint: carry-ladder is a *second* carry type (can't also carry a spare — hands-full),
|
||||
placement needs a valid-surface test + a snap to fascia anchors, and ClimbLadder needs vertical
|
||||
root motion the rig's rotation-only clips don't provide (same class of problem as the knockdown —
|
||||
I'd drive the climb height in code and play the clip on top). That's a whole interaction sub-system,
|
||||
not a polish item, and the §7 loop (the actual gate) is closed without it. Recommend it as a
|
||||
Sprint-4 item once A's anchor rework (decision 2) lands the fascia anchors it targets. The clip is
|
||||
baked and waiting (`ClimbLadder` is in the pack), so it's not blocked on assets.
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"branch_anchor_03",
|
||||
"canopy",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"canopy_03",
|
||||
@ -49,6 +50,7 @@
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"canopy",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"tree_gum_02",
|
||||
@ -285,6 +287,56 @@
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "wheelie_bin_01",
|
||||
"dims": [
|
||||
0.58,
|
||||
0.6808,
|
||||
1.1188
|
||||
],
|
||||
"tris": 120,
|
||||
"nodes": [
|
||||
"bin_body",
|
||||
"lid",
|
||||
"lid_plate",
|
||||
"wheelie_bin_01",
|
||||
"wheels"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "washing_line_01",
|
||||
"dims": [
|
||||
2.8441,
|
||||
2.8441,
|
||||
2.2777
|
||||
],
|
||||
"tris": 336,
|
||||
"nodes": [
|
||||
"arms",
|
||||
"head",
|
||||
"mast",
|
||||
"washing_line_01"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_gnome_01",
|
||||
"dims": [
|
||||
0.1427,
|
||||
0.15,
|
||||
0.365
|
||||
],
|
||||
"tris": 236,
|
||||
"nodes": [
|
||||
"garden_gnome_01",
|
||||
"gnome"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": [
|
||||
|
||||
@ -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"]),
|
||||
]
|
||||
|
||||
|
||||
@ -1340,7 +1626,12 @@ def make_contact_sheet(thumbs):
|
||||
cols = 4
|
||||
rows = (len(tiles) + cols - 1) // cols
|
||||
th, tw = tiles[0].shape[0], tiles[0].shape[1]
|
||||
sheet = np.zeros((rows * th, cols * tw, 4), dtype=np.float32)
|
||||
# Prefill with the render background, sampled from a tile's corner rather
|
||||
# than guessed — the PNG is sRGB-encoded and the scene colour is linear, so
|
||||
# reusing the world constant here would not match. Otherwise the unused
|
||||
# slots in a partly-filled last row read as black holes.
|
||||
sheet = np.empty((rows * th, cols * tw, 4), dtype=np.float32)
|
||||
sheet[:, :] = tiles[0][0, 0]
|
||||
sheet[:, :, 3] = 1.0
|
||||
for i, tile in enumerate(tiles):
|
||||
r, c = i // cols, i % cols
|
||||
@ -1382,6 +1673,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: 2.5 MiB |
@ -13,7 +13,8 @@
|
||||
"maxGap": 14,
|
||||
"powBase": 2,
|
||||
"powRand": 3,
|
||||
"powRamp": 2
|
||||
"powRamp": 2,
|
||||
"downdraft": 0.18
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],
|
||||
|
||||
@ -11,13 +11,16 @@
|
||||
|
||||
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
|
||||
|
||||
"_gusts_comment": "downdraft = fraction of gust power that blows DOWN, per gust (each gust varies 0.6-1.4x this). A gust front is descending air, not just faster air; without it a flat horizontal sail sheds everything and ignoring the storm is the winning move. 0.3 here because a wild night should punish a flat rig hard.",
|
||||
|
||||
"gusts": {
|
||||
"firstAt": 3,
|
||||
"minGap": 5.5,
|
||||
"maxGap": 11,
|
||||
"powBase": 3,
|
||||
"powRand": 5,
|
||||
"powRamp": 7
|
||||
"powRamp": 7,
|
||||
"downdraft": 0.3
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
||||
|
||||
160
web/world/dev_rigging.html
Normal file
160
web/world/dev_rigging.html
Normal file
@ -0,0 +1,160 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SHADES — Lane B rigging harness</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #6f7f8c; overflow: hidden;
|
||||
font: 12px/1.5 ui-monospace, Menlo, monospace; }
|
||||
canvas { display: block; }
|
||||
#dev { position: fixed; bottom: 8px; left: 8px; color: #fff; text-shadow: 0 1px 2px #000;
|
||||
white-space: pre; pointer-events: none; }
|
||||
#hint { position: fixed; bottom: 8px; right: 8px; color: #ffd27a; text-shadow: 0 1px 2px #000;
|
||||
text-align: right; white-space: pre; pointer-events: none; }
|
||||
</style>
|
||||
<!-- Required: world.js/sail.js pull addons that import the bare 'three' specifier. -->
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<div id="dev"></div>
|
||||
<div id="hint">Lane B harness — the prep phase only.
|
||||
ENTER commits the rig and starts a storm. R resets to prep.</div>
|
||||
|
||||
<script type="module">
|
||||
/**
|
||||
* Lane B harness — the prep-phase picking UI against Lane A's real yard.
|
||||
*
|
||||
* Same reason Lane C has weather_demo.html and Lane D has dev_player.html: the
|
||||
* picking UI can't be asserted headless (it is clicks, raycasts and materials),
|
||||
* and it can't be exercised in index.html until Lane A wires it in step 8. This
|
||||
* boots the real world.js, the real anchors and the real cloth so the thing
|
||||
* being verified is the thing that ships. Retire it once index.html hosts prep.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { createWorld } from './js/world.js';
|
||||
import { createCameraRig } from './js/camera.js';
|
||||
import { createWind, loadStorm } from './js/weather.js';
|
||||
import { SailRig, createSailView } from './js/sail.js';
|
||||
import { createRiggingUI } from './js/rigging.js';
|
||||
import { FIXED_DT } from './js/contracts.js';
|
||||
|
||||
const canvas = document.getElementById('c');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
const [calmDef, wildDef] = await Promise.all([
|
||||
loadStorm('storm_01_gentle'), loadStorm('storm_02_wildnight'),
|
||||
]);
|
||||
// main.js multiplexes these behind a private router; the harness only ever needs
|
||||
// one at a time, so it just swaps the reference when the storm starts.
|
||||
const calmWind = createWind(calmDef);
|
||||
const wildWind = createWind(wildDef);
|
||||
let wind = calmWind;
|
||||
const windProxy = {
|
||||
sample: (p, t, out) => wind.sample(p, t, out),
|
||||
gustTelegraph: (t) => wind.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => wind.eventsBetween?.(a, b) ?? [],
|
||||
setSheltersFromTrees: (trees) => { calmWind.setSheltersFromTrees?.(trees); wildWind.setSheltersFromTrees?.(trees); },
|
||||
};
|
||||
|
||||
const world = createWorld(scene, { wind: windProxy });
|
||||
const cameraRig = createCameraRig(canvas);
|
||||
cameraRig.setSolids(world.solids);
|
||||
cameraRig.setGround(world.heightAt);
|
||||
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
||||
|
||||
const rig = new SailRig({ anchors: world.anchors });
|
||||
let sailView = null;
|
||||
|
||||
async function rigSail(anchorIds, hwChoices, tension) {
|
||||
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);
|
||||
}
|
||||
|
||||
let msg = '';
|
||||
let msgT = 0;
|
||||
const ui = await createRiggingUI({
|
||||
scene, camera: cameraRig.object, domElement: canvas, world,
|
||||
onCommit: (ids, hw, tension) => { rigSail(ids, hw, tension); wind = wildWind; t = 0; phase = 'storm'; },
|
||||
onMessage: (m) => { msg = m; msgT = 2; },
|
||||
});
|
||||
|
||||
let phase = 'prep';
|
||||
ui.setActive(true);
|
||||
|
||||
// harness-only handle, so a debugger (or an agent driving this page) can pick
|
||||
// anchors without hunting for pixels. Never imported by the game.
|
||||
window.__laneB = {
|
||||
ui, rig, world, cameraRig, scene,
|
||||
get phase() { return phase; },
|
||||
/** rig by id, exactly as if you'd clicked it */
|
||||
pick: (id) => ui.session.rig(id),
|
||||
cycle: (id) => ui.session.cycleHardware(id),
|
||||
};
|
||||
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && phase === 'prep') { if (ui.commit()) ui.setActive(false); }
|
||||
if (e.key.toLowerCase() === 'r') {
|
||||
phase = 'prep';
|
||||
wind = calmWind;
|
||||
ui.setActive(true);
|
||||
if (sailView) { scene.remove(sailView); sailView = null; }
|
||||
}
|
||||
});
|
||||
|
||||
// follow-cam target: the yard centre, so prep reads as a site walk-around
|
||||
const focus = new THREE.Vector3(0, 1.2, 0);
|
||||
const dev = document.getElementById('dev');
|
||||
const clock = new THREE.Clock();
|
||||
let t = 0, acc = 0;
|
||||
|
||||
renderer.setAnimationLoop(() => {
|
||||
const raw = Math.min(0.25, clock.getDelta());
|
||||
acc += raw;
|
||||
while (acc >= FIXED_DT) {
|
||||
t += FIXED_DT;
|
||||
world.update(FIXED_DT, t);
|
||||
if (phase === 'storm') rig.step(FIXED_DT, windProxy, t);
|
||||
acc -= FIXED_DT;
|
||||
if (msgT > 0) msgT -= FIXED_DT;
|
||||
}
|
||||
ui.update(raw, t);
|
||||
sailView?.update();
|
||||
cameraRig.update(raw, focus);
|
||||
|
||||
const s = ui.summary;
|
||||
const loads = rig.rigged
|
||||
? rig.corners.map((c) => `${c.anchorId}:${c.broken ? 'BLOWN' : (c.load / 1000).toFixed(1) + 'kN'}`).join(' ')
|
||||
: '(not rigged)';
|
||||
dev.textContent =
|
||||
`${phase.toUpperCase()} t ${t.toFixed(1)}s wind ${windProxy.sample(focus, t).length().toFixed(1)} m/s\n` +
|
||||
`budget $${s.budget} corners ${s.corners.length}/4 area ${s.area ? s.area.toFixed(0) + ' m2' : '—'}\n` +
|
||||
`${loads}` + (msgT > 0 ? `\n!! ${msg}` : '');
|
||||
|
||||
renderer.render(scene, cameraRig.object);
|
||||
});
|
||||
|
||||
addEventListener('resize', () => {
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
cameraRig.object.aspect = innerWidth / innerHeight;
|
||||
cameraRig.object.updateProjectionMatrix();
|
||||
});
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
cameraRig.object.aspect = innerWidth / innerHeight;
|
||||
cameraRig.object.updateProjectionMatrix();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -177,9 +177,20 @@ export class Emitter {
|
||||
* angle around their centroid. tension scales spring rest lengths, 0.6–1.4
|
||||
* (low = loose and floggy, high = drum tight and shock-loaded).
|
||||
* @property {(dt:number, wind:Wind, t:number) => void} step Fixed dt. Deterministic.
|
||||
* @property {(rect: {x:number,z:number,w:number,d:number}) => number} coverageOver
|
||||
* Ground-projected shade over a rect, 0..1.
|
||||
* @property {(rect: {x:number,z:number,w:number,d:number}, sunDir?: THREE.Vector3, heightAt?: (x:number,z:number)=>number) => number} coverageOver
|
||||
* Ground-projected shade over a rect, 0..1. Pass world.sunDir and
|
||||
* world.heightAt so the rays start at the real ground and point at the real
|
||||
* sun; the defaults (overhead sun, flat y=0) are only for tests.
|
||||
* @property {Emitter} events Emits 'break' and 'repair' as {type, corner}.
|
||||
* @property {(i: number) => void} repair
|
||||
* Re-rig corner i with the carried spare (shackle grade — the only kind prep
|
||||
* sells). No-op if the corner isn't broken. Lane D's 2.5 s hold-E.
|
||||
* @property {(i: number, delta: number) => void} trim
|
||||
* Per-corner turnbuckle; delta is ±, clamped to 0.85–1.15. Lane D's 1.2 s hold.
|
||||
* @property {(i: number) => (THREE.Vector3|null)} cornerPos
|
||||
* LIVE world position of corner i, as a fresh vector safe to keep. A blown
|
||||
* corner's node is flying, so an interaction prompt anchored to this chases
|
||||
* the flogging corner instead of sitting on the dead anchor. null if unrigged.
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -190,6 +201,48 @@ export class Emitter {
|
||||
* @property {boolean} broken
|
||||
*/
|
||||
|
||||
/**
|
||||
* DEBRIS — Lane C implements. Lane B consumes `pieces` inside sail.step().
|
||||
*
|
||||
* SPRINT2 decision 5: the sail reads the pieces and applies its own impulses,
|
||||
* rather than debris.js reaching into the cloth. Momentum bookkeeping stays in
|
||||
* the one integrator that owns the nodes. That makes `pieces` a real contract
|
||||
* surface, so it is **frozen** here: fields below are what Lane B may rely on.
|
||||
*
|
||||
* @typedef {object} Debris
|
||||
* @property {DebrisPiece[]} pieces
|
||||
* Live pieces, newest last. The ARRAY IS MUTATED IN PLACE each step — pieces
|
||||
* are spliced out when they leave the yard, so don't hold a reference to it
|
||||
* across frames, and don't hold a piece past the step it despawned in. Read it
|
||||
* fresh inside step(). Order is not stable.
|
||||
* @property {(dt:number, t:number, world?:object) => void} step Fixed dt. Deterministic.
|
||||
* @property {(ev:object, t:number) => DebrisPiece} spawn
|
||||
* @property {(map:Object<string,THREE.Object3D>) => Debris} setModels
|
||||
* @property {() => void} clear
|
||||
*/
|
||||
|
||||
/**
|
||||
* One airborne object. Frozen shape — Lane C will not remove or repurpose these.
|
||||
*
|
||||
* The collision volume is a SPHERE of radius `r` centred on (x,y,z): a crate is
|
||||
* boxy, but a sphere is what you can afford to test against every cloth node,
|
||||
* every frame. Everything is SI — metres, m/s, kg — so `mass * v` is a real
|
||||
* momentum you can subtract from.
|
||||
*
|
||||
* @typedef {object} DebrisPiece
|
||||
* @property {number} x
|
||||
* @property {number} y Centre, not base. Rests at heightAt(x,z) + r.
|
||||
* @property {number} z
|
||||
* @property {number} vx
|
||||
* @property {number} vy
|
||||
* @property {number} vz
|
||||
* @property {number} r Collision sphere radius, m.
|
||||
* @property {number} mass kg. Crate 9, tub 5, bin 14.
|
||||
* @property {string} model Key into models/debris/, e.g. 'BlueCrate_v2'.
|
||||
* @property {boolean} hitPlayer Already knocked the player down once.
|
||||
* @property {THREE.Object3D|null} mesh Render instance. Lane C drives it; don't move it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* PLAYER — Lane D implements.
|
||||
*
|
||||
@ -253,11 +306,25 @@ export class Emitter {
|
||||
export const CONTRACT = {
|
||||
wind: { sample: 'function', gustTelegraph: 'function' },
|
||||
world: { anchors: 'object', heightAt: 'function', gardenBed: 'object', sunDir: 'object', solids: 'object', update: 'function' },
|
||||
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object' },
|
||||
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object', repair: 'function', trim: 'function', cornerPos: 'function' },
|
||||
player: { pos: 'object', carrying: '*', busy: '*', update: 'function' },
|
||||
interact: { register: 'function' },
|
||||
camera: { object: 'object', yaw: 'number', update: 'function' },
|
||||
game: { phase: 'string', on: 'function' },
|
||||
debris: { pieces: 'object', step: 'function', spawn: 'function', setModels: 'function', clear: 'function' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The frozen DebrisPiece fields (SPRINT2 decision 5). Lane B's sail.step() reads
|
||||
* these off `debris.pieces` and applies impulses from them, so renaming one is a
|
||||
* breaking change to someone else's integrator, not a local tidy-up. Asserted
|
||||
* against live pieces in c.test.js — if this table and debris.js disagree, the
|
||||
* selftest says so before Lane B's cloth does.
|
||||
*/
|
||||
export const DEBRIS_PIECE_FIELDS = {
|
||||
x: 'number', y: 'number', z: 'number',
|
||||
vx: 'number', vy: 'number', vz: 'number',
|
||||
r: 'number', mass: 'number', model: 'string',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -189,6 +189,10 @@ export async function boot(opts = {}) {
|
||||
|
||||
// --- world & camera -----------------------------------------------------
|
||||
const world = createWorld(scene, { wind });
|
||||
// Lane E's GLBs land over the graybox. Awaited here because it resolves
|
||||
// world.shedTable onto E's baked pickup_anchor, and wireYardActions (below,
|
||||
// inside rigSail) reads that position when it registers the spare pickup.
|
||||
await world.dress();
|
||||
const cameraRig = createCameraRig(canvas);
|
||||
cameraRig.setSolids(world.solids);
|
||||
cameraRig.setGround(world.heightAt);
|
||||
@ -346,7 +350,7 @@ export async function boot(opts = {}) {
|
||||
windT = windTime();
|
||||
world.update(dt, windT);
|
||||
player.update(dt, windT);
|
||||
rig.step(dt, wind, windT);
|
||||
rig.step(dt, wind, windT, debris);
|
||||
debris.step(dt, windT, { player: player.sim, sail: rig });
|
||||
sky?.step(dt, windT, { sail: rig });
|
||||
}
|
||||
|
||||
@ -62,7 +62,11 @@ export const TUNE = {
|
||||
// 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
|
||||
// Gate RETUNED against the real storm_02 (see the header note): the prototype's literal 8 was a
|
||||
// low bar in ITS units (gusts ran 12–38, so 8 was "almost always"); ported straight across it
|
||||
// became a high bar in ours (gusts peak at 12.1) and threw away most of the shove. 4 restores the
|
||||
// prototype's intent — you're being pushed for ~26 s of a 90 s storm, and a calm day is still calm.
|
||||
shoveGustMin: 4, // 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
|
||||
@ -80,7 +84,12 @@ export const TUNE = {
|
||||
|
||||
// A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a
|
||||
// storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back.
|
||||
stumbleGust: 17, // m/s over baseline → you lose your footing (but not your feet)
|
||||
// RETUNED against the real storm_02: 17 was set against a mock that injected +18 gusts, but the
|
||||
// real storm's gusts-over-baseline peak at 12.1, so StumbleBack was unreachable — dead code in the
|
||||
// shipped game. 9 catches the top third of the 12 gusts in a wild night: 4 stumbles to 2
|
||||
// knockdowns, which is the intended rhythm (stumble is the beat, knockdown the punchline).
|
||||
// d.test.js pins this to the actual storm JSON so a reweighted downdraft can't quietly kill it again.
|
||||
stumbleGust: 9, // m/s over baseline → you lose your footing (but not your feet)
|
||||
stumbleCooldown: 3, // s — punctuation, not a stutter: one gust hold must not stumble you twice
|
||||
|
||||
// Shelter (hold C): brace and the wind stops owning you. This is the storm's real answer to "the
|
||||
|
||||
@ -147,11 +147,262 @@ export class RiggingSession {
|
||||
/**
|
||||
* 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.
|
||||
* Everything above is the rules; this is only the hands. It renders its own
|
||||
* anchor markers because the yard has none to raycast against — world.js builds
|
||||
* posts and trunks, not pick targets — and marker styling is prep-phase UI, so
|
||||
* it belongs to this lane rather than to the terrain.
|
||||
*
|
||||
* Controls: LMB an anchor to rig it, LMB again to cycle its hardware,
|
||||
* shift-LMB to pull it off for a full refund, [ and ] for tension, S for the
|
||||
* spare. RMB is left alone — that's the camera's orbit. Enter belongs to Lane
|
||||
* A's phase machine, which calls commit() on the way out of prep.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {object} o.scene THREE.Scene to hang markers in
|
||||
* @param {object} o.camera cameraRig.object — what we raycast from
|
||||
* @param {Element} o.domElement renderer.domElement — where clicks land
|
||||
* @param {object} o.world needs world.anchors
|
||||
* @param {function} o.onCommit (anchorIds, hwChoices, tension) => void — Lane A's rigSail
|
||||
* @param {function} [o.onMessage] (text) => void — refusals, for the event ticker
|
||||
* @param {boolean} [o.panel=true] draw the built-in prep panel; false if hud.js takes it over
|
||||
*/
|
||||
export async function createRiggingUI() {
|
||||
throw new Error('rigging UI lands once Lane A has a camera and anchor markers — see THREADS.md');
|
||||
export async function createRiggingUI({
|
||||
scene, camera, domElement, world,
|
||||
onCommit, onMessage = () => {}, panel = true,
|
||||
} = {}) {
|
||||
const THREE = await import('../vendor/three.module.js');
|
||||
const session = new RiggingSession({ anchors: world.anchors });
|
||||
|
||||
// --- markers -----------------------------------------------------------
|
||||
const group = new THREE.Group();
|
||||
group.visible = false;
|
||||
scene.add(group);
|
||||
|
||||
const DIM = 0x33424c;
|
||||
const ringGeo = new THREE.TorusGeometry(0.28, 0.05, 8, 20);
|
||||
const dotGeo = new THREE.SphereGeometry(0.1, 10, 8);
|
||||
// What you click is NOT what you see: the ring's tube is 5 cm, which at yard
|
||||
// distance is a couple of pixels and unhittable. Pick against an invisible
|
||||
// sphere big enough to mean "that anchor" and let the ring just be the read.
|
||||
const pickGeo = new THREE.SphereGeometry(0.45, 8, 6);
|
||||
const pickMat = new THREE.MeshBasicMaterial({ visible: false });
|
||||
|
||||
const markers = world.anchors.map((a) => {
|
||||
const mat = new THREE.MeshBasicMaterial({ color: DIM, transparent: true, opacity: 0.9 });
|
||||
const ring = new THREE.Mesh(ringGeo, mat);
|
||||
const dot = new THREE.Mesh(dotGeo, mat);
|
||||
const hit = new THREE.Mesh(pickGeo, pickMat);
|
||||
hit.userData.anchorId = a.id;
|
||||
const holder = new THREE.Group();
|
||||
holder.add(ring, dot, hit, makeLabel(THREE, a.id.toUpperCase()));
|
||||
group.add(holder);
|
||||
return { anchor: a, holder, ring, dot, hit, mat, label: holder.children[3] };
|
||||
});
|
||||
const pickTargets = markers.map((m) => m.hit);
|
||||
|
||||
// --- quad preview ------------------------------------------------------
|
||||
// A closed loop through the ring-ordered picks: this is the shape you are
|
||||
// about to build, drawn before you commit to it.
|
||||
const previewGeo = new THREE.BufferGeometry();
|
||||
previewGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(5 * 3), 3));
|
||||
const preview = new THREE.Line(
|
||||
previewGeo,
|
||||
new THREE.LineDashedMaterial({ color: 0xffd27a, dashSize: 0.35, gapSize: 0.25 }),
|
||||
);
|
||||
preview.frustumCulled = false;
|
||||
group.add(preview);
|
||||
|
||||
// --- panel -------------------------------------------------------------
|
||||
const el = panel ? document.createElement('div') : null;
|
||||
if (el) {
|
||||
el.id = 'rigging-panel';
|
||||
el.style.cssText = `position:fixed;top:12px;left:12px;z-index:20;display:none;
|
||||
background:#0d1418e0;border:1px solid #2c3a44;border-radius:6px;padding:10px 12px;
|
||||
font:12px/1.65 ui-monospace,Menlo,monospace;color:#dde5ea;min-width:280px;
|
||||
white-space:pre;pointer-events:none`;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
let active = false;
|
||||
let hovered = null;
|
||||
|
||||
const ndc = new THREE.Vector2();
|
||||
const ray = new THREE.Raycaster();
|
||||
const scratch = new THREE.Vector3();
|
||||
|
||||
function pickAt(ev) {
|
||||
const r = domElement.getBoundingClientRect();
|
||||
ndc.x = ((ev.clientX - r.left) / r.width) * 2 - 1;
|
||||
ndc.y = -((ev.clientY - r.top) / r.height) * 2 + 1;
|
||||
ray.setFromCamera(ndc, camera);
|
||||
return ray.intersectObjects(pickTargets, false)[0]?.object.userData.anchorId ?? null;
|
||||
}
|
||||
|
||||
function say(result) {
|
||||
if (result && result.ok === false) onMessage(result.reason);
|
||||
return result;
|
||||
}
|
||||
|
||||
function onPointerDown(ev) {
|
||||
if (!active || ev.button !== 0) return; // RMB is the camera's
|
||||
const id = pickAt(ev);
|
||||
if (!id) return;
|
||||
ev.preventDefault();
|
||||
if (!session.isRigged(id)) say(session.rig(id));
|
||||
else if (ev.shiftKey) say(session.unrig(id));
|
||||
else say(session.cycleHardware(id));
|
||||
refresh();
|
||||
}
|
||||
|
||||
function onPointerMove(ev) {
|
||||
if (!active) return;
|
||||
hovered = pickAt(ev);
|
||||
domElement.style.cursor = hovered ? 'pointer' : '';
|
||||
}
|
||||
|
||||
function onKeyDown(ev) {
|
||||
if (!active) return;
|
||||
if (ev.key === '[') session.setTension(session.tension - 0.05);
|
||||
else if (ev.key === ']') session.setTension(session.tension + 0.05);
|
||||
else if (ev.key.toLowerCase() === 's') say(session.setSpares(session.spares ? 0 : 1));
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
refresh();
|
||||
}
|
||||
|
||||
domElement.addEventListener('pointerdown', onPointerDown);
|
||||
domElement.addEventListener('pointermove', onPointerMove);
|
||||
addEventListener('keydown', onKeyDown);
|
||||
|
||||
/** Ground-plane area of the quad as picked, m² — the 70-192 m² problem, visible. */
|
||||
function quadArea() {
|
||||
if (session.picks.length !== MAX_CORNERS) return 0;
|
||||
const p = session.picks.map((k) => world.anchors.find((a) => a.id === k.anchorId).pos);
|
||||
const tri = (a, b, c) =>
|
||||
new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)).length() * 0.5;
|
||||
return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!el) return;
|
||||
const s = session.summary;
|
||||
const rows = world.anchors.map((a) => {
|
||||
const pick = session.pickOf(a.id);
|
||||
if (!pick) return ` ${a.id.padEnd(3)} ${a.type.padEnd(6)} —`;
|
||||
const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
|
||||
return ` ${a.id.padEnd(3)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
|
||||
});
|
||||
const area = quadArea();
|
||||
el.textContent = [
|
||||
`PREP — rig four corners $${s.budget} left`,
|
||||
`tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
|
||||
'',
|
||||
...rows,
|
||||
'',
|
||||
s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
|
||||
'click anchor: rig / cycle hw shift-click: remove',
|
||||
'[ ] tension S spare RMB orbit',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const ui = {
|
||||
session,
|
||||
get summary() { return { ...session.summary, area: quadArea() }; },
|
||||
get canStart() { return session.canStart; },
|
||||
get active() { return active; },
|
||||
|
||||
/** Lane A: call on phaseChange — markers and clicks are prep-only. */
|
||||
setActive(on) {
|
||||
active = !!on;
|
||||
group.visible = active;
|
||||
if (el) el.style.display = active ? 'block' : 'none';
|
||||
if (!active) domElement.style.cursor = '';
|
||||
if (active) refresh();
|
||||
return ui;
|
||||
},
|
||||
|
||||
/** Markers ride the anchors, so a tree corner wanders before you even rig it. */
|
||||
update(dt, t) {
|
||||
if (!active) return;
|
||||
for (const m of markers) {
|
||||
const p = m.anchor.sway ? m.anchor.sway(t) : m.anchor.pos;
|
||||
m.holder.position.set(p.x, p.y, p.z);
|
||||
m.holder.quaternion.copy(camera.quaternion); // rings face the player
|
||||
const pick = session.pickOf(m.anchor.id);
|
||||
m.mat.color.setHex(pick ? pick.hw.color : DIM);
|
||||
const s = (hovered === m.anchor.id ? 1.35 : 1) * (pick ? 1.15 : 1);
|
||||
m.ring.scale.setScalar(s);
|
||||
m.label.visible = !!pick || hovered === m.anchor.id;
|
||||
}
|
||||
|
||||
const pos = previewGeo.attributes.position;
|
||||
if (session.picks.length >= 2) {
|
||||
preview.visible = true;
|
||||
const n = session.picks.length;
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const k = session.picks[i % n];
|
||||
const a = world.anchors.find((x) => x.id === k.anchorId);
|
||||
const p = a.sway ? a.sway(t) : a.pos;
|
||||
scratch.set(p.x, p.y, p.z);
|
||||
pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
|
||||
}
|
||||
// degenerate tail so a partial pick doesn't draw a stale segment
|
||||
for (let i = session.picks.length + 1; i < 5; i++) pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
|
||||
pos.needsUpdate = true;
|
||||
previewGeo.setDrawRange(0, session.picks.length + 1);
|
||||
preview.computeLineDistances();
|
||||
} else {
|
||||
preview.visible = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Hand the finished rig to Lane A's rigSail. Returns false if it isn't four corners. */
|
||||
commit() {
|
||||
if (!session.canStart) {
|
||||
onMessage(`rig ${MAX_CORNERS - session.picks.length} more corner(s) first`);
|
||||
return false;
|
||||
}
|
||||
onCommit(
|
||||
session.picks.map((p) => p.anchorId),
|
||||
session.picks.map((p) => p.hw),
|
||||
session.tension,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
domElement.removeEventListener('pointerdown', onPointerDown);
|
||||
domElement.removeEventListener('pointermove', onPointerMove);
|
||||
removeEventListener('keydown', onKeyDown);
|
||||
scene.remove(group);
|
||||
ringGeo.dispose(); dotGeo.dispose(); previewGeo.dispose();
|
||||
preview.material.dispose();
|
||||
for (const m of markers) { m.mat.dispose(); m.label.material.map?.dispose(); m.label.material.dispose(); }
|
||||
el?.remove();
|
||||
},
|
||||
};
|
||||
|
||||
refresh();
|
||||
return ui;
|
||||
}
|
||||
|
||||
/** A cheap canvas-texture nameplate, so anchors read as h1/t2/p1 rather than dots. */
|
||||
function makeLabel(THREE, text) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 128; c.height = 64;
|
||||
const g = c.getContext('2d');
|
||||
g.font = 'bold 40px ui-monospace, Menlo, monospace';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.lineWidth = 6;
|
||||
g.strokeStyle = '#0d1418';
|
||||
g.strokeText(text, 64, 32);
|
||||
g.fillStyle = '#dde5ea';
|
||||
g.fillText(text, 64, 32);
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: new THREE.CanvasTexture(c), depthTest: false, transparent: true,
|
||||
}));
|
||||
sprite.position.set(0, 0.55, 0);
|
||||
sprite.scale.set(0.8, 0.4, 1);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
@ -18,10 +18,17 @@
|
||||
* appears in createSailView(), which is imported lazily.
|
||||
*/
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { Emitter, FIXED_DT, HARDWARE } from './contracts.js';
|
||||
|
||||
export { HARDWARE };
|
||||
|
||||
/**
|
||||
* What a carried spare re-rigs a corner with. The prep phase sells exactly one
|
||||
* kind ("spare shackle, $15"), so repair() has no hardware argument to take.
|
||||
*/
|
||||
const SPARE_HW = HARDWARE[1];
|
||||
|
||||
// ---------- 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
|
||||
@ -49,6 +56,10 @@ 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
|
||||
|
||||
// ---------- debris (SPRINT2 decision 5) ----------
|
||||
const DEBRIS_RESTITUTION = 0.1; // a wheelie bin into shade cloth barely bounces
|
||||
const DEBRIS_SKIN = 0.06; // contact margin, ~cloth thickness
|
||||
|
||||
// ---------- 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
|
||||
@ -114,6 +125,11 @@ export class SailRig {
|
||||
this._acc = 0;
|
||||
// scratch, reused every face to keep the hot loop allocation-free
|
||||
this._probe = { x: 0, y: 0, z: 0 };
|
||||
// Lane C's wind.sample(pos, t, out) takes an out-vector so we don't allocate
|
||||
// one per face per substep — 162 faces at 60 Hz is ~9.7k throwaway Vector3s
|
||||
// a second otherwise. A stub wind that ignores `out` still works: we read
|
||||
// the RETURN value, not this.
|
||||
this._windOut = new THREE.Vector3();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -306,16 +322,20 @@ export class SailRig {
|
||||
* 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
|
||||
* @param {number} dt seconds elapsed since last call
|
||||
* @param {object} wind { sample(pos, t) -> {x,y,z} }
|
||||
* @param {number} t world time, seconds
|
||||
* @param {object} [debris] Lane C's debris module, or anything with `.pieces`.
|
||||
* Optional — the cloth runs fine without a storm's
|
||||
* worth of crates in it.
|
||||
*/
|
||||
step(dt, wind, t) {
|
||||
step(dt, wind, t, debris = null) {
|
||||
if (!this.rigged) return;
|
||||
const pieces = debris ? (debris.pieces ?? debris) : null;
|
||||
this._acc += dt;
|
||||
let n = 0;
|
||||
while (this._acc >= SIM_DT && n < MAX_SUBSTEPS) {
|
||||
this._substep(SIM_DT, wind, this.t);
|
||||
this._substep(SIM_DT, wind, this.t, pieces);
|
||||
this._acc -= SIM_DT;
|
||||
this.t += SIM_DT;
|
||||
n++;
|
||||
@ -323,8 +343,9 @@ export class SailRig {
|
||||
if (n === MAX_SUBSTEPS) this._acc = 0; // dropped frames: don't try to catch up
|
||||
}
|
||||
|
||||
_substep(dt, wind, t) {
|
||||
_substep(dt, wind, t, pieces) {
|
||||
this._accumulateWind(wind, t, dt);
|
||||
if (pieces && pieces.length) this._applyDebris(pieces, 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);
|
||||
@ -357,7 +378,7 @@ export class SailRig {
|
||||
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);
|
||||
const w = wind.sample(probe, t, this._windOut);
|
||||
|
||||
// Relative wind, not absolute: as the cloth accelerates downwind the load
|
||||
// bleeds off by itself. This is what stops flogging from exploding.
|
||||
@ -382,6 +403,88 @@ export class SailRig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sphere-vs-cloth impulses for Lane C's debris (SPRINT2 decision 5, option b).
|
||||
*
|
||||
* The exchange is symmetric: every newton-second the cloth takes out of a
|
||||
* crate, the crate loses. That's the point of the decision — one integrator
|
||||
* does the momentum bookkeeping, so a crate punching through a sail slows
|
||||
* down by exactly as much as it speeds the cloth up. Asserted in
|
||||
* sail.selftest.js.
|
||||
*
|
||||
* Pinned corners are the deliberate exception: they have invMass 0, so a
|
||||
* crate that hits one bounces off and the momentum goes into the house. That
|
||||
* is correct — the anchor is bolted to a wall — and it's why the momentum
|
||||
* assert uses an interior hit.
|
||||
*
|
||||
* @param {Array} pieces debris.pieces — {x,y,z,vx,vy,vz,r,mass}
|
||||
*/
|
||||
_applyDebris(pieces, dt) {
|
||||
const pos = this.pos, prev = this.prev, im = this.invMass;
|
||||
for (const p of pieces) {
|
||||
if (p.alive === false || !Number.isFinite(p.mass) || p.mass <= 0) continue;
|
||||
|
||||
// Swept: main.js steps the sail BEFORE the debris, so these positions are
|
||||
// a frame stale, and a 0.3 m crate at 25 m/s covers 0.42 m in a frame —
|
||||
// enough to pass clean between cloth nodes. Growing the contact radius by
|
||||
// the piece's travel catches both the lag and the tunnelling.
|
||||
const speed = Math.hypot(p.vx, p.vy, p.vz);
|
||||
const solid = p.r + DEBRIS_SKIN;
|
||||
const reach = solid + speed * dt;
|
||||
const reachSq = reach * reach;
|
||||
const wPiece = 1 / p.mass;
|
||||
|
||||
let jx = 0, jy = 0, jz = 0, hits = 0;
|
||||
for (let n = 0; n < im.length; n++) {
|
||||
const i = n * 3;
|
||||
const dx = pos[i] - p.x, dy = pos[i + 1] - p.y, dz = pos[i + 2] - p.z;
|
||||
const dsq = dx * dx + dy * dy + dz * dz;
|
||||
if (dsq > reachSq || dsq < 1e-12) continue;
|
||||
const d = Math.sqrt(dsq);
|
||||
const nx = dx / d, ny = dy / d, nz = dz / d; // piece centre -> node
|
||||
|
||||
// node velocity, read out of verlet
|
||||
const vnx = (pos[i] - prev[i]) / dt;
|
||||
const vny = (pos[i + 1] - prev[i + 1]) / dt;
|
||||
const vnz = (pos[i + 2] - prev[i + 2]) / dt;
|
||||
const vrel = (vnx - p.vx) * nx + (vny - p.vy) * ny + (vnz - p.vz) * nz;
|
||||
if (vrel > 0) continue; // already separating — don't glue them together
|
||||
|
||||
const wNode = im[n];
|
||||
const denom = wNode + wPiece;
|
||||
if (denom < 1e-12) continue;
|
||||
const j = (-(1 + DEBRIS_RESTITUTION) * vrel) / denom;
|
||||
hits++;
|
||||
|
||||
// node takes +j along the contact normal; verlet stores velocity as a
|
||||
// position difference, so the impulse goes in by moving `prev`
|
||||
prev[i] -= nx * j * wNode * dt;
|
||||
prev[i + 1] -= ny * j * wNode * dt;
|
||||
prev[i + 2] -= nz * j * wNode * dt;
|
||||
|
||||
// ...and the piece takes exactly -j. This is the conservation.
|
||||
jx -= nx * j; jy -= ny * j; jz -= nz * j;
|
||||
|
||||
// Depenetrate free nodes by moving pos AND prev together, so pushing
|
||||
// the cloth off the crate doesn't secretly inject velocity.
|
||||
if (wNode > 0 && d < solid) {
|
||||
const push = solid - d;
|
||||
pos[i] += nx * push; prev[i] += nx * push;
|
||||
pos[i + 1] += ny * push; prev[i + 1] += ny * push;
|
||||
pos[i + 2] += nz * push; prev[i + 2] += nz * push;
|
||||
}
|
||||
}
|
||||
|
||||
if (hits) {
|
||||
p.vx += jx * wPiece; p.vy += jy * wPiece; p.vz += jz * wPiece;
|
||||
this.events.emit('debrisHit', {
|
||||
type: 'debrisHit', piece: p, nodes: hits,
|
||||
impulse: Math.hypot(jx, jy, jz), t: this.t,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_integrate(dt) {
|
||||
const pos = this.pos, prev = this.prev, F = this.force, im = this.invMass;
|
||||
const dt2 = dt * dt;
|
||||
@ -503,8 +606,43 @@ export class SailRig {
|
||||
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
|
||||
}
|
||||
|
||||
// --- Lane D's seam (SPRINT2 decision 4) --------------------------------
|
||||
// D landed first and duck-typed these against the rig, so B conforms to D's
|
||||
// spelling rather than the other way round. Thin aliases on purpose: the
|
||||
// behaviour lives in repairCorner/trimCorner, these just match the call sites
|
||||
// in interact.js and are what contracts.js promises.
|
||||
|
||||
/**
|
||||
* Re-rig corner `i` with the spare the player was carrying. The spare is the
|
||||
* "$15 spare shackle" the prep phase sells, so it re-rigs at shackle grade —
|
||||
* which can be an UPGRADE on a corner that blew a carabiner, and a downgrade
|
||||
* on one that blew a rated shackle. That's the prototype's behaviour and it's
|
||||
* a real decision about which corner you run back to.
|
||||
* @param {number} i
|
||||
*/
|
||||
repair(i) { this.repairCorner(i, SPARE_HW); }
|
||||
|
||||
/**
|
||||
* Per-corner turnbuckle. @param {number} i @param {number} delta ±, clamped 0.85–1.15.
|
||||
*/
|
||||
trim(i, delta) { this.trimCorner(i, delta); }
|
||||
|
||||
/**
|
||||
* Live world position of corner `i`, as a FRESH vector — a blown corner's node
|
||||
* is flying, so Lane D's prompt has to chase it rather than sit on the anchor.
|
||||
* Fresh (not shared scratch) because interact.js holds the result across the
|
||||
* frame and two corners are read back to back.
|
||||
* @param {number} i
|
||||
* @returns {THREE.Vector3|null}
|
||||
*/
|
||||
cornerPos(i) {
|
||||
if (!this.rigged || !this.corners[i]) return null;
|
||||
const n = this.cornerIdx[i] * 3;
|
||||
return new THREE.Vector3(this.pos[n], this.pos[n + 1], this.pos[n + 2]);
|
||||
}
|
||||
|
||||
/** Re-rig a blown corner with fresh hardware. Lane D's hold-E repair calls this. */
|
||||
repairCorner(index, hw = HARDWARE[1]) {
|
||||
repairCorner(index, hw = SPARE_HW) {
|
||||
const c = this.corners[index];
|
||||
if (!c || !c.broken) return false;
|
||||
c.broken = false;
|
||||
@ -539,8 +677,10 @@ export class SailRig {
|
||||
* @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.
|
||||
* @param {function} heightAt world.heightAt — rays start at the real ground.
|
||||
* Defaults to a flat y=0, which is only right for tests.
|
||||
*/
|
||||
coverageOver(rect, sunDir = { x: 0, y: 1, z: 0 }) {
|
||||
coverageOver(rect, sunDir = { x: 0, y: 1, z: 0 }, heightAt = null) {
|
||||
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;
|
||||
@ -553,7 +693,8 @@ export class SailRig {
|
||||
// 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++;
|
||||
const oy = heightAt ? heightAt(ox, oz) : 0;
|
||||
if (this._rayHitsSail(ox, oy, oz, dx, dy, dz)) hit++;
|
||||
}
|
||||
}
|
||||
return hit / (COLS * ROWS);
|
||||
|
||||
@ -12,9 +12,57 @@
|
||||
|
||||
import { SailRig } from './sail.js';
|
||||
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
|
||||
import { createWindField } from './weather.core.js';
|
||||
|
||||
const SIM_DT = FIXED_DT;
|
||||
|
||||
// ---------- real storm wind (SPRINT2 B-4) ----------
|
||||
// The §7 gate used to run on the local stub, which is uniform, horizontal and
|
||||
// tuned by nobody. These load the storms design actually ships and drive the
|
||||
// cloth with them. weather.core.js is pure and import-free, so the same code
|
||||
// path works in node and in Lane A's selftest.html; only reading the JSON off
|
||||
// disk differs, and weather.js's own loadStorm can't help there (its STORM_DIR
|
||||
// is a file:// URL under node, which fetch won't open).
|
||||
|
||||
async function loadStormDef(name) {
|
||||
const url = new URL(`../data/storms/${name}.json`, import.meta.url);
|
||||
if (typeof process !== 'undefined' && process.versions?.node) {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
return JSON.parse(await readFile(url, 'utf8'));
|
||||
}
|
||||
return (await fetch(url)).json();
|
||||
}
|
||||
|
||||
const STORM_02 = await loadStormDef('storm_02_wildnight');
|
||||
|
||||
/** A Wind over a real storm def. Same field the game flies. */
|
||||
function realWind(def = STORM_02, opts = {}) {
|
||||
const field = createWindField(def, opts);
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
return {
|
||||
sample(pos, t) { return field.vecAt(pos.x, pos.z, t, out); },
|
||||
speedAt(t) { field.vecAt(0, 0, t, out); return Math.hypot(out.x, out.z); },
|
||||
gustTelegraph: (t) => field.gustTelegraph?.(t) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
|
||||
const YARD = [
|
||||
['h1', 'house', -5, 2.6, -9.9], ['h2', 'house', 0, 2.6, -9.9], ['h3', 'house', 5, 2.6, -9.9],
|
||||
['t1', 'tree', -9, 3.2, 2], ['t2', 'tree', 8, 3.1, -2],
|
||||
['p1', 'post', -6.4, 3.9, 7.4], ['p2', 'post', 5.3, 3.9, 8],
|
||||
].map(([id, type, x, y, z]) => {
|
||||
const pos = { x, y, z };
|
||||
// Static on purpose: tree sway is world.js's, and mixing it in here would make
|
||||
// a cloth assert fail for a reason that isn't the cloth. Sway is exercised in
|
||||
// the game and in a.test.
|
||||
return { id, type, pos, sway: () => pos };
|
||||
});
|
||||
|
||||
const yardRig = (ids, hw, tension) =>
|
||||
new SailRig({ anchors: YARD, gridN: 10 })
|
||||
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
|
||||
|
||||
// ---------- 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,
|
||||
@ -66,10 +114,17 @@ const FOOT = [
|
||||
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) =>
|
||||
/**
|
||||
* Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position.
|
||||
* `theta` spins the footprint about the yard's Y axis — which is how you sweep
|
||||
* wind direction against a real storm, whose direction curve you don't get to
|
||||
* choose. Rotating the rig under the wind and rotating the wind over the rig are
|
||||
* the same experiment; only one of them is available with authored storm JSON.
|
||||
*/
|
||||
export const makeAnchors = (heights, theta = 0) =>
|
||||
FOOT.map((f, i) => {
|
||||
const pos = { x: f.x, y: heights[i], z: f.z };
|
||||
const c = Math.cos(theta), s = Math.sin(theta);
|
||||
const pos = { x: f.x * c - f.z * s, y: heights[i], z: f.x * s + f.z * c };
|
||||
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
|
||||
});
|
||||
|
||||
@ -332,6 +387,272 @@ test('break and repair emit on the events Emitter', () => {
|
||||
return `repaired corner back to ${kN(r.corners[0].load)}, ${seen.length} event(s) emitted`;
|
||||
});
|
||||
|
||||
// --- SPRINT2 decision 4: the seam Lane D already calls ---------------------
|
||||
|
||||
test('decision 4: repair(i) re-rigs a blown corner with the spare', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[0] });
|
||||
runStorm(r, w, 4);
|
||||
r.corners[0].broken = true;
|
||||
r._repin(r.t);
|
||||
|
||||
// exactly Lane D's interact.js call: no hardware argument, return ignored
|
||||
r.repair(0);
|
||||
assert(!r.corners[0].broken, 'repair(0) should have re-rigged the corner');
|
||||
assert(r.corners[0].hw === HARDWARE[1], `spare should re-rig at shackle grade, got ${r.corners[0].hw.name}`);
|
||||
assert(r.invMass[r.cornerIdx[0]] === 0, 'repaired corner should be pinned again');
|
||||
runStorm(r, w, 3);
|
||||
assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
|
||||
return `repair(0) -> ${r.corners[0].hw.name}, back to ${kN(r.corners[0].load)}`;
|
||||
});
|
||||
|
||||
test('decision 4: repair(i) on an intact corner is a no-op', () => {
|
||||
const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2] });
|
||||
runStorm(r, constantWind({ x: 0, y: 0, z: 12 }), 2);
|
||||
const hw = r.corners[1].hw;
|
||||
r.repair(1); // D gates on corner.broken, but the rig must not trust that
|
||||
assert(r.corners[1].hw === hw, 'repairing an intact corner downgraded its hardware');
|
||||
return 'intact corner untouched';
|
||||
});
|
||||
|
||||
test('decision 4: trim(i, delta) tightens one corner only', () => {
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
r.trim(0, +0.1);
|
||||
assert(Math.abs(r.corners[0].trim - 1.1) < 1e-9, `corner 0 trim ${r.corners[0].trim}`);
|
||||
assert(r.corners[1].trim === 1.0, 'trim leaked onto a neighbour');
|
||||
for (let i = 0; i < 40; i++) r.trim(0, +0.1); // Lane D can hold the key down
|
||||
assert(r.corners[0].trim <= 1.15 + 1e-9, `trim ran past its clamp: ${r.corners[0].trim}`);
|
||||
return `trim clamps at ${r.corners[0].trim.toFixed(2)}, neighbours unmoved`;
|
||||
});
|
||||
|
||||
test('decision 4: cornerPos(i) is live, fresh, and chases a flogging corner', () => {
|
||||
const w = makeStubWind({ seed: 11, stormLen: 90 });
|
||||
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 });
|
||||
const anchor = r.corners[0].anchor.pos;
|
||||
const p0 = r.cornerPos(0);
|
||||
assert(Math.hypot(p0.x - anchor.x, p0.y - anchor.y, p0.z - anchor.z) < 1e-6,
|
||||
'an intact corner should report its anchor position');
|
||||
assert(r.cornerPos(0) !== r.cornerPos(0), 'cornerPos must return a FRESH vector, not shared scratch');
|
||||
|
||||
// blow it, then confirm the prompt would follow the flying corner
|
||||
r.corners[0].broken = true;
|
||||
r._repin(r.t);
|
||||
runStorm(r, w, 6);
|
||||
const p1 = r.cornerPos(0);
|
||||
const drift = Math.hypot(p1.x - anchor.x, p1.y - anchor.y, p1.z - anchor.z);
|
||||
assert(drift > 0.3, `blown corner's prompt only moved ${drift.toFixed(2)} m off the anchor`);
|
||||
assert(new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT) }).cornerPos(0) === null,
|
||||
'cornerPos on an unrigged rig should be null, not a throw');
|
||||
return `prompt tracks the blown corner ${drift.toFixed(2)} m off its anchor`;
|
||||
});
|
||||
|
||||
// --- SPRINT2 decision 5: debris -------------------------------------------
|
||||
|
||||
const crate = (over) => ({ x: 0, y: 3.25, z: 0, vx: 0, vy: 0, vz: 14, r: 0.3, mass: 9, alive: true, ...over });
|
||||
|
||||
test('decision 5: a crate hitting the sail conserves momentum', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 4); // settle, so the cloth isn't ringing
|
||||
// aimed at the belly, not a corner: a pinned corner would (correctly) dump
|
||||
// momentum into the house and there'd be nothing to conserve
|
||||
const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2);
|
||||
const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.25, z: r.pos[mid * 3 + 2], vy: 6, vz: 0 });
|
||||
|
||||
const clothP = () => {
|
||||
let x = 0, y = 0, z = 0;
|
||||
for (let n = 0; n < r.invMass.length; n++) {
|
||||
if (r.invMass[n] === 0) continue; // pinned: its momentum belongs to the house
|
||||
const i = n * 3;
|
||||
x += (r.pos[i] - r.prev[i]) / SIM_DT * r.nodeMass;
|
||||
y += (r.pos[i + 1] - r.prev[i + 1]) / SIM_DT * r.nodeMass;
|
||||
z += (r.pos[i + 2] - r.prev[i + 2]) / SIM_DT * r.nodeMass;
|
||||
}
|
||||
return { x, y, z };
|
||||
};
|
||||
const total = () => {
|
||||
const c = clothP();
|
||||
return { x: c.x + p.vx * p.mass, y: c.y + p.vy * p.mass, z: c.z + p.vz * p.mass };
|
||||
};
|
||||
|
||||
const before = total();
|
||||
r._applyDebris([p], SIM_DT);
|
||||
const after = total();
|
||||
|
||||
const drift = Math.hypot(after.x - before.x, after.y - before.y, after.z - before.z);
|
||||
const scale = Math.hypot(before.x, before.y, before.z);
|
||||
assert(scale > 1, 'test crate carries no momentum to conserve');
|
||||
assert(drift / scale < 0.01, `momentum drifted ${drift.toFixed(3)} of ${scale.toFixed(1)} kg·m/s (${(drift / scale * 100).toFixed(1)}%)`);
|
||||
assert(p.vy < 6, `the crate should have LOST speed to the cloth, still at ${p.vy.toFixed(2)} m/s`);
|
||||
return `crate ${scale.toFixed(0)} kg·m/s, exchange conserves to ${(drift / scale * 100).toFixed(3)}%`;
|
||||
});
|
||||
|
||||
test('decision 5: a crate through the sail shoves the cloth and emits', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 4);
|
||||
const hits = [];
|
||||
r.events.on('debrisHit', (e) => hits.push(e));
|
||||
|
||||
const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2);
|
||||
const before = r.pos[mid * 3 + 1];
|
||||
const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.6, z: r.pos[mid * 3 + 2], vy: 12, vz: 0 });
|
||||
const v0 = p.vy;
|
||||
|
||||
// Peak, not final: the crate crosses the cloth in about three frames and the
|
||||
// membrane springs back well inside the run, so sampling the end measures the
|
||||
// recovery rather than the punch.
|
||||
const wind = makeStubWind({ calm: true });
|
||||
let peak = before;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
r.step(SIM_DT, wind, i * SIM_DT, { pieces: [p] });
|
||||
p.y += p.vy * SIM_DT; p.z += p.vz * SIM_DT;
|
||||
peak = Math.max(peak, r.pos[mid * 3 + 1]);
|
||||
}
|
||||
assert(hits.length > 0, 'crate passed through the cloth without a single contact');
|
||||
assert(peak > before + 0.05, `belly only lifted ${(peak - before).toFixed(3)} m — the crate went straight through`);
|
||||
assert(p.vy < v0, `crate left at ${p.vy.toFixed(2)} m/s, never paid for the punch (entered at ${v0})`);
|
||||
return `${hits.length} contacts, belly punched ${(peak - before).toFixed(2)} m, crate ${v0} -> ${p.vy.toFixed(1)} m/s`;
|
||||
});
|
||||
|
||||
test('decision 5: no debris and empty debris are both fine', () => {
|
||||
const w = makeStubWind({ seed: 2, stormLen: 20 });
|
||||
const a = rig(HEIGHTS_HYPAR), b = rig(HEIGHTS_HYPAR);
|
||||
for (let i = 0; i < 600; i++) {
|
||||
a.step(SIM_DT, w, i * SIM_DT); // Lane A's 3-arg call still works
|
||||
b.step(SIM_DT, makeStubWind({ seed: 2, stormLen: 20 }), i * SIM_DT, { pieces: [] });
|
||||
}
|
||||
for (let k = 0; k < 4; k++) {
|
||||
assert(Math.abs(a.corners[k].load - b.corners[k].load) < 1e-9,
|
||||
'an empty debris list changed the sim');
|
||||
}
|
||||
return 'empty and absent debris both no-op';
|
||||
});
|
||||
|
||||
// --- SPRINT2 B-4: the §7 gate, against the wind the game actually flies ------
|
||||
|
||||
// PLAN3D §7: "A flat drum-tight cheap rig MUST cascade-fail in storm_02; a
|
||||
// well-twisted mixed rig with one mid-storm repair MUST be survivable." The old
|
||||
// version of this proved it against my own stub wind, which is uniform,
|
||||
// horizontal and tuned by nobody — so it proved the cloth was self-consistent,
|
||||
// not that the game works. This is the real storm JSON, the real yard, and the
|
||||
// same two rig shapes Lane C measured decision 3 against.
|
||||
test('§7 gate on REAL storm_02: cheap flat rig cascades', () => {
|
||||
const rig = yardRig(['h1', 'h3', 'p2', 'p1'], HARDWARE[0], 1.3); // drum-tight carabiners
|
||||
const broke = [];
|
||||
rig.events.on('break', (e) => broke.push(e));
|
||||
const w = realWind();
|
||||
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) rig.step(SIM_DT, w, i * SIM_DT);
|
||||
const lost = rig.corners.filter((c) => c.broken).length;
|
||||
assert(lost >= 2, `flat drum-tight carabiner rig only lost ${lost}/4 in the real storm_02 — no cascade`);
|
||||
return `lost ${lost}/4, first at t=${broke[0].t.toFixed(1)}s (${broke[0].anchorId}, ${broke[0].hw})`;
|
||||
});
|
||||
|
||||
test('§7 gate on REAL storm_02: twisted mixed rig survives', () => {
|
||||
// Lane C's shape: h1 (house, 2.6) / t2 (tree, 3.1) / p1 (post, 3.9) / t1 (tree, 3.2)
|
||||
// — corners at four different heights, i.e. an actual hypar, eased off tight.
|
||||
const rig = yardRig(['h1', 't2', 'p1', 't1'], [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
|
||||
const w = realWind();
|
||||
let peak = 0;
|
||||
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
|
||||
rig.step(SIM_DT, w, i * SIM_DT);
|
||||
peak = Math.max(peak, rig.maxLoad());
|
||||
}
|
||||
const lost = rig.corners.filter((c) => c.broken).length;
|
||||
assert(lost === 0, `well-twisted mixed rig lost ${lost}/4 in storm_02 — §7 says it must be survivable`);
|
||||
return `all 4 corners held, peak ${kN(peak)} (area ${rig.area.toFixed(0)} m2)`;
|
||||
});
|
||||
|
||||
test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', () => {
|
||||
// The other half of §7: "a well-twisted mixed rig with ONE mid-storm repair
|
||||
// MUST be survivable". The twisted rig above already survives outright, so
|
||||
// the interesting scenario is DESIGN.md's: the budget forces one dodgy corner
|
||||
// ($80 buys rated on at most two of four), that corner blows, and you run out
|
||||
// and re-rig it once with the carried spare — exactly Lane D's hold-E.
|
||||
// An $80-exact loadout: rated h1 ($30) + shackle t1 ($15) + shackle p1 ($15)
|
||||
// + carabiner t2 ($5) + spare ($15). The carabiner goes on t2 because that is
|
||||
// where the load actually IS — measured peaks on this shape are h1 1.68 /
|
||||
// t2 2.73 / p1 2.17 / t1 0.81 kN. Putting the cheap corner on t1 (the
|
||||
// lightest) is what a player does by accident and it survives the storm
|
||||
// having proved nothing; putting it on t2 is the real bet.
|
||||
const rig = yardRig(
|
||||
['h1', 't2', 'p1', 't1'],
|
||||
[HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]],
|
||||
0.85,
|
||||
);
|
||||
const w = realWind();
|
||||
let repairs = 0;
|
||||
rig.events.on('break', () => { /* seen below; repairing inside the emit would reenter step */ });
|
||||
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
|
||||
rig.step(SIM_DT, w, i * SIM_DT);
|
||||
if (repairs === 0) {
|
||||
const k = rig.corners.findIndex((c) => c.broken);
|
||||
if (k >= 0) { rig.repair(k); repairs++; }
|
||||
}
|
||||
}
|
||||
const lost = rig.corners.filter((c) => c.broken).length;
|
||||
if (repairs === 0) {
|
||||
// A vacuous pass is worse than a skip: "nothing broke" would let this go
|
||||
// green forever while proving nothing. Storm_02 can't threaten a shackle
|
||||
// rig until Lane C's downdraft lands (their A/B: shackle blows at t=20.8 s
|
||||
// with downdraft 0.3, never without). Lights up by itself on merge.
|
||||
assert(
|
||||
!STORM_02.gusts?.downdraft,
|
||||
'storm_02 HAS a downdraft and still could not blow a shackle rig — the repair scenario is vacuous',
|
||||
);
|
||||
return 'SKIPPED — nothing blew; needs Lane C decision 3 downdraft to threaten a shackle rig';
|
||||
}
|
||||
assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
|
||||
return `${repairs} repair, finished ${4 - lost}/4 corners intact`;
|
||||
});
|
||||
|
||||
// --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------
|
||||
|
||||
// My Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all
|
||||
// (1.14 kN vs a pitched flat's 3.06), because a horizontal plate in horizontal
|
||||
// wind has almost no drag — which inverted DESIGN.md's "big, flat, low = death
|
||||
// in a storm". Lane C closed it by making gusts descend. This is the assert
|
||||
// decision 3 asks Lane B for.
|
||||
test('decision 3: flat-horizontal is no longer a free lunch', () => {
|
||||
const downdraft = STORM_02.gusts?.downdraft ?? 0;
|
||||
if (!downdraft) {
|
||||
// Feature-detected rather than hard-failed: this assert is only meaningful
|
||||
// once Lane C's downdraft is on main. It lights up by itself on merge.
|
||||
return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)';
|
||||
}
|
||||
if (downdraft < 0.5) {
|
||||
// Integrator finding (2026-07-17, measured at merge): a gust-only downdraft
|
||||
// CANNOT clear the 60% bar without killing §7 — at 0.45 the twisted mixed
|
||||
// rig loses a corner and the ratio is still 42%; at 0.58 it's 48% and the
|
||||
// rig still dies. The two asserts pincer. Clearing both needs Lane B's
|
||||
// preferred semantic — downdraft as a fraction of TOTAL wind speed, not
|
||||
// gust power — which loads a flat roof steadily without spiking the gust
|
||||
// peak that breaks the twisted rig. That is a weather.core change (joint
|
||||
// B+C, SPRINT3). Until it lands, storm data stays at C's tuned 0.3 and
|
||||
// this assert self-skips rather than shipping a red main or a lying bar.
|
||||
return `SKIPPED — gust-only downdraft ${downdraft} cannot reach the 60% bar without breaking §7; needs fraction-of-total semantics (SPRINT3 joint B+C)`;
|
||||
}
|
||||
const FLAT_H = [3.25, 3.25, 3.25, 3.25];
|
||||
// Spin the rig through 8 headings under the real storm. (Re-seeding the wind
|
||||
// instead would only reshuffle gust TIMING — the direction curve is authored
|
||||
// in the JSON and doesn't move — so it would look like a sweep and measure
|
||||
// nothing about direction.)
|
||||
const sweep = (heights) => {
|
||||
let worst = 0;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const r = new SailRig({ anchors: makeAnchors(heights, (k / 8) * Math.PI * 2), gridN: 10 })
|
||||
.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);
|
||||
// full duration: storm_02's own note says the peak lands just AFTER the
|
||||
// southerly change, so a 45 s sweep measures the wrong half of the storm
|
||||
worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
|
||||
}
|
||||
return worst;
|
||||
};
|
||||
const pitched = sweep(HEIGHTS_FLAT);
|
||||
const horizontal = sweep(FLAT_H);
|
||||
const ratio = horizontal / pitched;
|
||||
assert(ratio >= 0.6, `flat-horizontal peaks at only ${(ratio * 100).toFixed(0)}% of flat-pitched (${kN(horizontal)} vs ${kN(pitched)}) — still a free lunch`);
|
||||
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% (downdraft ${downdraft})`;
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@ -22,6 +22,112 @@ const CALM_SKY = new THREE.Color(0x9fc4e8);
|
||||
const STORM_SKY = new THREE.Color(0x2a2f3a);
|
||||
const NIGHT_SKY = new THREE.Color(0x11141c);
|
||||
|
||||
// ------------------------------------------------------------ rain shadow
|
||||
/**
|
||||
* Where the sail is keeping the ground dry (SPRINT2 §Lane C.3).
|
||||
*
|
||||
* This is the RAIN shadow, not the sun shadow. Rain arrives along the wind, so
|
||||
* the dry patch sits downwind of the cloth and slides across the yard as the
|
||||
* wind swings — at the southerly change it walks right off the garden, which is
|
||||
* free drama and the honest physics.
|
||||
*
|
||||
* Cheap on purpose: ray-testing 3 k drops against 162 triangles every frame is
|
||||
* ~486 k intersections for an effect nobody inspects closely. Instead we project
|
||||
* the sail's triangles ALONG the rain onto the ground and rasterise them into a
|
||||
* coarse grid, a few times a second — the cloth moves slowly next to the rain.
|
||||
* Per-drop cost is then one projection and one array read.
|
||||
*
|
||||
* Reads `rig.pos`/`rig.tris`, which are already the surface Lane A's sail view
|
||||
* consumes, so this needs nothing new from Lane B.
|
||||
*/
|
||||
export class RainShadow {
|
||||
constructor(o = {}) {
|
||||
this.n = o.cells ?? 64; // ~0.56 m over a 36 m span
|
||||
this.half = o.half ?? 18;
|
||||
this.groundY = o.groundY ?? 0;
|
||||
this.ceil = new Float32Array(this.n * this.n); // sail height per cell, 0 = open sky
|
||||
this.live = false;
|
||||
this.dx = 0; this.dy = -1; this.dz = 0;
|
||||
}
|
||||
|
||||
_idx(gx, gz) {
|
||||
const i = Math.floor(((gx + this.half) / (this.half * 2)) * this.n);
|
||||
const j = Math.floor(((gz + this.half) / (this.half * 2)) * this.n);
|
||||
if (i < 0 || j < 0 || i >= this.n || j >= this.n) return -1;
|
||||
return j * this.n + i;
|
||||
}
|
||||
|
||||
/** @param {object} rig Lane B's SailRig @param {number} dx,dy,dz unit rain direction */
|
||||
update(rig, dx, dy, dz) {
|
||||
this.live = false;
|
||||
if (!rig || !rig.pos || !rig.tris || dy > -1e-3) return; // rain must fall
|
||||
this.ceil.fill(0);
|
||||
this.dx = dx; this.dy = dy; this.dz = dz;
|
||||
|
||||
const pos = rig.pos, tris = rig.tris, cellW = (this.half * 2) / this.n;
|
||||
const gx = [0, 0, 0], gz = [0, 0, 0], gy = [0, 0, 0];
|
||||
for (let i = 0; i < tris.length; i += 3) {
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const a = tris[i + k] * 3;
|
||||
const vy = pos[a + 1];
|
||||
const tt = (vy - this.groundY) / -dy; // slide down the rain to the ground
|
||||
gx[k] = pos[a] + dx * tt;
|
||||
gz[k] = pos[a + 2] + dz * tt;
|
||||
gy[k] = vy;
|
||||
}
|
||||
const d = (gz[1] - gz[2]) * (gx[0] - gx[2]) + (gx[2] - gx[1]) * (gz[0] - gz[2]);
|
||||
if (Math.abs(d) < 1e-9) continue; // degenerate once projected
|
||||
|
||||
const minX = Math.min(gx[0], gx[1], gx[2]), maxX = Math.max(gx[0], gx[1], gx[2]);
|
||||
const minZ = Math.min(gz[0], gz[1], gz[2]), maxZ = Math.max(gz[0], gz[1], gz[2]);
|
||||
for (let px = minX; px <= maxX + cellW; px += cellW) {
|
||||
for (let pz = minZ; pz <= maxZ + cellW; pz += cellW) {
|
||||
const c = this._idx(px, pz);
|
||||
if (c < 0) continue;
|
||||
// barycentric, with a little slop so cracks between tris don't leak rain
|
||||
const l1 = ((gz[1] - gz[2]) * (px - gx[2]) + (gx[2] - gx[1]) * (pz - gz[2])) / d;
|
||||
const l2 = ((gz[2] - gz[0]) * (px - gx[2]) + (gx[0] - gx[2]) * (pz - gz[2])) / d;
|
||||
const l3 = 1 - l1 - l2;
|
||||
if (l1 < -0.05 || l2 < -0.05 || l3 < -0.05) continue;
|
||||
const y = l1 * gy[0] + l2 * gy[1] + l3 * gy[2];
|
||||
if (y > this.ceil[c]) this.ceil[c] = y;
|
||||
}
|
||||
}
|
||||
this.live = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Has a drop here already been stopped by the cloth? */
|
||||
occluded(x, y, z) {
|
||||
if (!this.live) return false;
|
||||
const tt = (y - this.groundY) / -this.dy;
|
||||
const c = this._idx(x + this.dx * tt, z + this.dz * tt);
|
||||
if (c < 0) return false;
|
||||
const ceil = this.ceil[c];
|
||||
return ceil > 0 && y < ceil; // above the cloth it hasn't hit yet
|
||||
}
|
||||
|
||||
/** 0..1 of a ground rect under cover. Same rect shape as sailRig.coverageOver. */
|
||||
fractionOver(rect, cols = 6, rows = 4) {
|
||||
if (!this.live) return 0;
|
||||
let hit = 0;
|
||||
for (let i = 0; i < cols; i++) {
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const x = rect.x + ((i + 0.5) / cols - 0.5) * rect.w;
|
||||
const z = rect.z + ((j + 0.5) / rows - 0.5) * rect.d;
|
||||
const c = this._idx(x, z);
|
||||
if (c >= 0 && this.ceil[c] > 0) hit++;
|
||||
}
|
||||
}
|
||||
return hit / (cols * rows);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rain velocity, m/s. One definition, used by the drops and by the shadow. */
|
||||
function rainVelocity(w, intensity, out) {
|
||||
return out.set(w.x * 0.55, -(9 + intensity * 4), w.z * 0.55);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rain
|
||||
function createRain(opts) {
|
||||
const max = opts.maxDrops ?? 3000;
|
||||
@ -55,22 +161,29 @@ function createRain(opts) {
|
||||
const q = new THREE.Quaternion();
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
const vel = new THREE.Vector3();
|
||||
const unit = new THREE.Vector3();
|
||||
const scale = new THREE.Vector3(1, 1, 1);
|
||||
const zero = new THREE.Vector3();
|
||||
// zero-scale: an instance that renders to nothing
|
||||
const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0);
|
||||
|
||||
return {
|
||||
mesh,
|
||||
/** @param {THREE.Vector3} camPos @param {THREE.Vector3} w local wind */
|
||||
step(dt, camPos, w, intensity) {
|
||||
/**
|
||||
* @param {THREE.Vector3} camPos
|
||||
* @param {THREE.Vector3} w local wind
|
||||
* @param {RainShadow} [shadow] drops under the cloth are not drawn
|
||||
*/
|
||||
step(dt, camPos, w, intensity, shadow) {
|
||||
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);
|
||||
rainVelocity(w, intensity, vel);
|
||||
const fall = -vel.y;
|
||||
const speed = vel.length() || 1;
|
||||
q.setFromUnitVectors(up, vel.clone().divideScalar(speed));
|
||||
q.setFromUnitVectors(up, unit.copy(vel).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);
|
||||
@ -91,6 +204,15 @@ function createRain(opts) {
|
||||
if (py[i] < groundY) py[i] += height;
|
||||
else if (py[i] > top) py[i] -= height;
|
||||
|
||||
// Under the cloth this drop was stopped up there. Keep simulating it —
|
||||
// it wraps back to the top and rains again beyond the sail's edge — but
|
||||
// don't draw it. A degenerate matrix is cheaper than reshuffling the
|
||||
// instance list, and InstancedMesh has no per-instance visibility.
|
||||
if (shadow && shadow.occluded(px[i], py[i], pz[i])) {
|
||||
mesh.setMatrixAt(i, HIDDEN);
|
||||
continue;
|
||||
}
|
||||
|
||||
m.elements[12] = px[i];
|
||||
m.elements[13] = py[i];
|
||||
m.elements[14] = pz[i];
|
||||
@ -322,6 +444,9 @@ export function createSkyFx(o = {}) {
|
||||
|
||||
const rain = createRain({ groundY: o.groundY ?? 0 });
|
||||
if (scene) scene.add(rain.mesh);
|
||||
const shadow = new RainShadow({ groundY: o.groundY ?? 0 });
|
||||
const rainDir = new THREE.Vector3();
|
||||
let shadowTick = 0;
|
||||
|
||||
const audio = createAudio((wind && wind.seed) || 1);
|
||||
|
||||
@ -337,10 +462,19 @@ export function createSkyFx(o = {}) {
|
||||
dome.renderOrder = -1;
|
||||
if (scene) scene.add(dome);
|
||||
|
||||
// remember what world.js handed us, so dispose() puts it back exactly
|
||||
// Remember what world.js handed us, so dispose() puts it back exactly.
|
||||
// Fog is captured BY VALUE, not by reference: step() mutates that very object
|
||||
// in place, so `scene.fog = original.fog` restores the object we just spent a
|
||||
// storm wrecking. Lane A caught it — sun and hemi came back exactly and the fog
|
||||
// stayed where the storm left it. Harmless today only because the next skyfx
|
||||
// immediately re-drives it, which is exactly the kind of bug that waits.
|
||||
const ownsFog = !!scene && !scene.fog;
|
||||
const original = {
|
||||
background: scene ? scene.background : null,
|
||||
fog: scene ? scene.fog : null,
|
||||
fogColor: scene && scene.fog ? scene.fog.color.clone() : null,
|
||||
fogNear: scene && scene.fog ? scene.fog.near : 0,
|
||||
fogFar: scene && scene.fog ? scene.fog.far : 0,
|
||||
sun: sun ? sun.intensity : 0,
|
||||
hemi: hemi ? hemi.intensity : 0,
|
||||
};
|
||||
@ -360,9 +494,22 @@ export function createSkyFx(o = {}) {
|
||||
const w = new THREE.Vector3();
|
||||
|
||||
const fx = {
|
||||
rain, audio, dome,
|
||||
rain, audio, dome, shadow,
|
||||
get flash() { return flash; },
|
||||
|
||||
/**
|
||||
* 0..1 of a ground rect the sail is keeping dry, right now.
|
||||
*
|
||||
* Lane A: this is NOT `rig.coverageOver(bed, world.sunDir)`. That one is the
|
||||
* SUN shadow — the summer-afternoon question. This is the RAIN shadow, which
|
||||
* arrives along the wind, sits downwind of the cloth, and walks across the
|
||||
* yard when the wind swings. During a storm at night the sun shadow is a
|
||||
* number about nothing; this is the one that says whether the garden is
|
||||
* getting hit. Which of the two drives garden HP is a design call, not mine —
|
||||
* flagged in THREADS. Cheap either way: reads the grid we already built.
|
||||
*/
|
||||
rainShadowOver(rect) { return shadow.fractionOver(rect); },
|
||||
|
||||
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||
unlockAudio() { audio.unlock(); },
|
||||
|
||||
@ -422,7 +569,16 @@ export function createSkyFx(o = {}) {
|
||||
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
|
||||
|
||||
// --- rain ---
|
||||
rain.step(dt, camPos, w, intensity);
|
||||
// Rebuild the shadow a few times a second, not every frame: the cloth
|
||||
// moves slowly next to the rain, and this is the only part that costs.
|
||||
shadowTick -= dt;
|
||||
if (shadowTick <= 0) {
|
||||
shadowTick = 0.1;
|
||||
rainVelocity(w, intensity, rainDir);
|
||||
const len = rainDir.length() || 1;
|
||||
shadow.update(world.sail, rainDir.x / len, rainDir.y / len, rainDir.z / len);
|
||||
}
|
||||
rain.step(dt, camPos, w, intensity, shadow);
|
||||
|
||||
// --- audio ---
|
||||
audio.setLevels(speed, intensity);
|
||||
@ -454,7 +610,14 @@ export function createSkyFx(o = {}) {
|
||||
scene.remove(rain.mesh);
|
||||
scene.remove(dome);
|
||||
scene.background = original.background;
|
||||
scene.fog = original.fog;
|
||||
if (ownsFog) {
|
||||
scene.fog = null; // we brought it; we take it
|
||||
} else if (original.fog) {
|
||||
scene.fog = original.fog;
|
||||
original.fog.color.copy(original.fogColor);
|
||||
original.fog.near = original.fogNear;
|
||||
original.fog.far = original.fogFar;
|
||||
}
|
||||
}
|
||||
if (sun) sun.intensity = original.sun;
|
||||
if (hemi) hemi.intensity = original.hemi;
|
||||
|
||||
@ -15,8 +15,10 @@
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { assert, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT, checkContract } from '../contracts.js';
|
||||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { createDebris } from '../debris.js';
|
||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { weatherCases } from './weather.selftest.js';
|
||||
|
||||
const STORMS = ['storm_01_gentle', 'storm_02_wildnight'];
|
||||
@ -43,12 +45,34 @@ export default async function run(t) {
|
||||
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}`);
|
||||
assert(Number.isFinite(a.x) && Number.isFinite(a.y) && Number.isFinite(a.z),
|
||||
`sample returned a non-finite vector: ${a.x},${a.y},${a.z}`);
|
||||
// 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');
|
||||
assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result');
|
||||
});
|
||||
|
||||
// This assert used to read `a.y === 0` — "wind should be horizontal". SPRINT2
|
||||
// decision 3 made that false on purpose: gusts now descend, which is what makes
|
||||
// a flat sail pay. Keeping the useful half — y is downward-or-zero, never up,
|
||||
// and never garbage — so player shove and rain angle can still trust the sign.
|
||||
t.test('vertical wind is downward-only, and only during gusts', () => {
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const pos = new THREE.Vector3(0, 1.7, 0);
|
||||
const v = new THREE.Vector3();
|
||||
let sawDown = false;
|
||||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||
wind.sample(pos, time, v);
|
||||
assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`);
|
||||
if (v.y < -1) sawDown = true;
|
||||
});
|
||||
assert(sawDown, 'never saw a downdraft worth the name in a whole wild night');
|
||||
// and the wind meter must stay horizontal — a falling gust shouldn't spike the HUD
|
||||
const calm = wind.speedAt(pos, 0.5);
|
||||
assert(Math.abs(calm - Math.hypot(wind.sample(pos, 0.5).x, wind.sample(pos, 0.5).z)) < 1e-9,
|
||||
'speedAt() is not the horizontal magnitude of sample()');
|
||||
});
|
||||
|
||||
// Lifted from a.test.js onto the real wind (Lane A's note in this file's
|
||||
@ -70,6 +94,122 @@ export default async function run(t) {
|
||||
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`);
|
||||
});
|
||||
|
||||
// --- SPRINT2 decision 5: debris.pieces is Lane B's to read, so it's frozen ---
|
||||
t.test('debris conforms and its pieces match the frozen shape', () => {
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const debris = createDebris({ wind });
|
||||
assert(checkContract('debris', debris).length === 0, checkContract('debris', debris).join('; '));
|
||||
|
||||
const p = debris.spawn({ model: 'BlueCrate_v2', lateral: 0 }, 40);
|
||||
for (const [field, want] of Object.entries(DEBRIS_PIECE_FIELDS)) {
|
||||
const got = typeof p[field];
|
||||
assert(got === want, `piece.${field} is ${got}, contract says ${want}`);
|
||||
if (want === 'number') assert(Number.isFinite(p[field]), `piece.${field} is not finite`);
|
||||
}
|
||||
assert(debris.pieces.includes(p), 'spawn() returned a piece that is not in pieces');
|
||||
assert(p.r > 0 && p.mass > 0, 'a piece with no radius or no mass cannot be collided with');
|
||||
|
||||
// The array is mutated in place and pieces are spliced on despawn — that's
|
||||
// documented, and B reads it fresh inside step(). Prove clear() empties it
|
||||
// rather than swapping in a new array behind their reference.
|
||||
const ref = debris.pieces;
|
||||
debris.clear();
|
||||
assert(ref === debris.pieces && debris.pieces.length === 0,
|
||||
'clear() replaced the pieces array instead of emptying it — B holds a reference');
|
||||
});
|
||||
|
||||
// Lane A rebuilds skyfx on every phase change, so dispose() is on the hot path.
|
||||
// They verified sun/hemi restore exactly and spotted that fog didn't; this pins
|
||||
// both. The vacuity guards matter — a restore test where nothing ever moved is
|
||||
// a test that passes forever and checks nothing.
|
||||
t.test('skyfx.dispose() hands the scene back exactly as it found it', () => {
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x9fc4e8);
|
||||
scene.fog = new THREE.Fog(0x9fc4e8, 30, 140);
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
const sun = new THREE.DirectionalLight(0xfff4e0, 2.0);
|
||||
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.8);
|
||||
const before = {
|
||||
bg: scene.background, fogColor: scene.fog.color.getHex(),
|
||||
fogNear: scene.fog.near, fogFar: scene.fog.far,
|
||||
sun: sun.intensity, hemi: hemi.intensity, children: scene.children.length,
|
||||
};
|
||||
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
|
||||
fixedLoop(40, FIXED_DT, (dt, time) => sky.step(dt, time, {}));
|
||||
|
||||
assert(sun.intensity < before.sun * 0.9, 'the storm never dimmed the sun — this test proves nothing');
|
||||
assert(scene.fog.near !== before.fogNear, 'the storm never touched the fog — this test proves nothing');
|
||||
|
||||
sky.dispose();
|
||||
assert(sun.intensity === before.sun, `sun left at ${sun.intensity}, want ${before.sun}`);
|
||||
assert(hemi.intensity === before.hemi, `hemi left at ${hemi.intensity}, want ${before.hemi}`);
|
||||
assert(scene.background === before.bg, 'scene.background not restored');
|
||||
assert(scene.fog.color.getHex() === before.fogColor,
|
||||
`fog colour left at #${scene.fog.color.getHex().toString(16)}, want #${before.fogColor.toString(16)}`);
|
||||
assert(scene.fog.near === before.fogNear && scene.fog.far === before.fogFar,
|
||||
`fog left at near=${scene.fog.near} far=${scene.fog.far}, want ${before.fogNear}/${before.fogFar}`);
|
||||
assert(scene.children.length === before.children,
|
||||
`skyfx left ${scene.children.length - before.children} object(s) in the scene`);
|
||||
});
|
||||
|
||||
// --- SPRINT2 §Lane C.3: rain has to stop at the cloth ---
|
||||
// Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing
|
||||
// under test is the projection, and a flat panel makes the right answer
|
||||
// something you can work out on paper.
|
||||
const PANEL = {
|
||||
pos: new Float32Array([-2, 3, -2, 2, 3, -2, 2, 3, 2, -2, 3, 2]),
|
||||
tris: [0, 1, 2, 0, 2, 3],
|
||||
};
|
||||
|
||||
t.test('rain shadow: straight-down rain leaves a dry patch under the panel', () => {
|
||||
const s = new RainShadow();
|
||||
s.update(PANEL, 0, -1, 0);
|
||||
assert(s.live, 'shadow never built');
|
||||
assert(s.occluded(0, 1, 0), 'drop directly under the panel is still falling');
|
||||
assert(s.occluded(1.5, 0.1, 1.5), 'drop near the panel corner is still falling');
|
||||
assert(!s.occluded(0, 5, 0), 'drop ABOVE the panel was culled — it has not hit yet');
|
||||
assert(!s.occluded(8, 1, 0), 'drop well clear of the panel was culled');
|
||||
assert(!s.occluded(0, 1, 9), 'drop well clear of the panel was culled');
|
||||
});
|
||||
|
||||
t.test('rain shadow leans with the rain, and follows the wind round', () => {
|
||||
const s = new RainShadow();
|
||||
// rain driving hard along +x: the dry ground moves +x, out from under the panel
|
||||
s.update(PANEL, 0.6, -0.8, 0);
|
||||
const shift = 3 * (0.6 / 0.8); // 3 m of fall × the lean
|
||||
assert(s.occluded(shift, 0.05, 0), `dry patch is not downwind at x=${shift.toFixed(2)}`);
|
||||
assert(!s.occluded(-shift, 0.05, 0), 'dry patch went UPWIND — the projection is inverted');
|
||||
|
||||
// swing the wind 180° and the patch has to swap sides. This is the southerly
|
||||
// change: the sail stops covering the bed without a single corner failing.
|
||||
s.update(PANEL, -0.6, -0.8, 0);
|
||||
assert(s.occluded(-shift, 0.05, 0), 'dry patch did not follow the wind round');
|
||||
assert(!s.occluded(shift, 0.05, 0), 'dry patch stayed put when the wind swung');
|
||||
});
|
||||
|
||||
t.test('rain shadow: no sail, no shelter', () => {
|
||||
const s = new RainShadow();
|
||||
s.update(null, 0, -1, 0);
|
||||
assert(!s.live && !s.occluded(0, 1, 0), 'sheltered by a sail that does not exist');
|
||||
// and rain that is not falling can't cast a shadow (guards a divide by ~0)
|
||||
s.update(PANEL, 1, 0, 0);
|
||||
assert(!s.live, 'horizontal rain projected to infinity instead of bailing out');
|
||||
});
|
||||
|
||||
t.test('rain shadow: fractionOver reads a rect the way coverageOver does', () => {
|
||||
const s = new RainShadow();
|
||||
s.update(PANEL, 0, -1, 0);
|
||||
// the panel spans x,z in [-2,2]; a rect inside it is fully covered
|
||||
assert(s.fractionOver({ x: 0, z: 0, w: 2, d: 2 }) === 1,
|
||||
'a rect wholly under the panel is not fully covered');
|
||||
assert(s.fractionOver({ x: 12, z: 0, w: 2, d: 2 }) === 0,
|
||||
'a rect nowhere near the panel is covered');
|
||||
const half = s.fractionOver({ x: 2, z: 0, w: 4, d: 2 });
|
||||
assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@ -15,6 +15,7 @@ import { PlayerSim, STATES, TUNE, clipFor } 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';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
|
||||
const DT = FIXED_DT;
|
||||
|
||||
@ -25,8 +26,31 @@ const windX = (speed) => ({ x: speed, y: 0, z: 0 });
|
||||
const drive = (sim, secs, input = {}, wind = null, t0 = 0) =>
|
||||
fixedLoop(secs, DT, (dt, t) => sim.step(dt, t0 + t, input, wind));
|
||||
|
||||
/** Count what a real storm actually does to a person standing mid-yard. */
|
||||
function stormProfile(wind, tune, secs = 90) {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 4 }, tune });
|
||||
let stumbles = 0, knocks = 0, maxGust = 0, maxWind = 0, shovedFor = 0;
|
||||
fixedLoop(secs, DT, (dt, t) => {
|
||||
p.step(dt, t, {}, wind);
|
||||
maxGust = Math.max(maxGust, p.gust);
|
||||
maxWind = Math.max(maxWind, p.windSpeed);
|
||||
if (Math.hypot(p.shove.x, p.shove.z) > 0.15) shovedFor += dt;
|
||||
for (const e of p.events) if (e.type === 'state') {
|
||||
if (e.state === 'stumble') stumbles++;
|
||||
if (e.state === 'knocked') knocks++;
|
||||
}
|
||||
p.events.length = 0;
|
||||
});
|
||||
return { stumbles, knocks, maxGust, maxWind, shovedFor };
|
||||
}
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
export default async function run(t) {
|
||||
// The REAL storms, not a mock. Lane D's thresholds are meaningless except against the data they
|
||||
// fire on, and this sprint's decision 8 has B+C changing the downdraft semantics underneath us —
|
||||
// exactly the kind of edit that silently made StumbleBack unreachable the first time.
|
||||
const wild = createWind(await loadStorm('storm_02_wildnight'));
|
||||
const calm = createWind(await loadStorm('storm_01_gentle'));
|
||||
// ---------------------------------------------------------------- state machine table
|
||||
// The 17 clips actually in player_anims.glb (integrator baked the M3 pack; names logged in THREADS).
|
||||
// Verified against the real GLB in-browser: SHADES.player.view.clipNames matches this exactly.
|
||||
@ -205,12 +229,27 @@ export default function run(t) {
|
||||
assertEq(s.state, 'knocked', 'a big enough gust still takes you off your feet, braced or not');
|
||||
});
|
||||
|
||||
t.test('shelter: releasing the key always frees you, even mid-gust', () => {
|
||||
t.test('shelter: releasing the key always frees you — you are never stuck braced', () => {
|
||||
// steady wind (already learned as baseline, so no apparent gust): straight back to idle
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, {}, windX(20));
|
||||
drive(s, 1, { shelter: true }, windX(20));
|
||||
assertEq(s.state, 'shelter', 'braced');
|
||||
drive(s, 0.5, {}, windX(20)); // let go, wind still blowing
|
||||
assert(!s.busy && s.state === 'idle', 'released');
|
||||
drive(s, 0.5, {}, windX(20));
|
||||
assertEq(s.state, 'idle', 'let go in steady wind → idle');
|
||||
assert(!s.busy, 'and free to move');
|
||||
});
|
||||
|
||||
t.test('shelter: unbracing INTO the gust you were hiding from costs you your footing', () => {
|
||||
// Emergent, and worth pinning: bracing is a commitment. Let go early and the gust takes you.
|
||||
// This is what makes "wait for the lull" a decision rather than a formality.
|
||||
const g = new PlayerSim();
|
||||
drive(g, 30, {}, windX(4)); // learn a calm baseline
|
||||
drive(g, 1, { shelter: true }, windX(4 + TUNE.stumbleGust + 6), 30);
|
||||
assertEq(g.state, 'shelter', 'braced through the gust');
|
||||
drive(g, 0.2, {}, windX(4 + TUNE.stumbleGust + 6), 31);
|
||||
assert(g.state !== 'shelter', 'releasing always leaves shelter');
|
||||
assertEq(g.state, 'stumble', 'and straight into the gust → you stumble');
|
||||
});
|
||||
|
||||
t.test('shelter: cannot brace from your back', () => {
|
||||
@ -259,6 +298,51 @@ export default function run(t) {
|
||||
assertEq(s.state, 'shelter', 'braced, the gust does not stumble you');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- tuned against the REAL storms
|
||||
// These are the guard rails the pre-merge tuning didn't have. Every threshold below is only
|
||||
// meaningful relative to the storm JSON it fires on, so assert against the JSON.
|
||||
t.test('storm_02: every player threshold is actually REACHABLE in the real storm', () => {
|
||||
const p = stormProfile(wild, undefined);
|
||||
assert(p.maxGust > TUNE.stumbleGust,
|
||||
`StumbleBack is dead code: storm_02 gusts peak at ${p.maxGust.toFixed(1)} m/s over baseline, `
|
||||
+ `but stumbleGust is ${TUNE.stumbleGust}. Lower it or ask Lane C for angrier gusts.`);
|
||||
assert(p.maxGust > TUNE.shoveGustMin, `gust shove unreachable: peak ${p.maxGust.toFixed(1)}`);
|
||||
assert(p.maxWind > TUNE.knockWind,
|
||||
`knockdown unreachable: storm_02 peaks at ${p.maxWind.toFixed(1)} m/s, knockWind is ${TUNE.knockWind}`);
|
||||
});
|
||||
|
||||
t.test('storm_02: reads as shoved → stumbling → floored, in that order of frequency', () => {
|
||||
const p = stormProfile(wild, undefined);
|
||||
assert(p.stumbles >= 2, `a wild night should break your stride more than twice, got ${p.stumbles}`);
|
||||
assert(p.knocks >= 1, `and floor you at least once, got ${p.knocks}`);
|
||||
assert(p.stumbles > p.knocks,
|
||||
`stumble is the beat and knockdown the punchline — got ${p.stumbles} stumbles vs ${p.knocks} knocks`);
|
||||
assert(p.shovedFor > 10, `you should feel pushed for a real slice of the storm, got ${p.shovedFor.toFixed(1)}s`);
|
||||
assertLess(p.knocks, 8, `${p.knocks} knockdowns in 90 s is unplayable, not dramatic`);
|
||||
});
|
||||
|
||||
t.test('storm_01: a calm day leaves you completely alone', () => {
|
||||
const p = stormProfile(calm, undefined);
|
||||
assertEq(p.stumbles, 0, 'no stumbles on a gentle day');
|
||||
assertEq(p.knocks, 0, 'no knockdowns on a gentle day');
|
||||
assertLess(p.shovedFor, 1, 'and essentially no shove');
|
||||
});
|
||||
|
||||
t.test('storm_02: bracing is what makes the worst of it survivable', () => {
|
||||
const exposed = new PlayerSim({ start: { x: 0, y: 0, z: 4 } });
|
||||
const braced = new PlayerSim({ start: { x: 0, y: 0, z: 4 } });
|
||||
let exposedKnocks = 0, bracedKnocks = 0;
|
||||
fixedLoop(90, DT, (dt, tt) => {
|
||||
exposed.step(dt, tt, {}, wild);
|
||||
braced.step(dt, tt, { shelter: true }, wild);
|
||||
for (const e of exposed.events) if (e.state === 'knocked') exposedKnocks++;
|
||||
for (const e of braced.events) if (e.state === 'knocked') bracedKnocks++;
|
||||
exposed.events.length = 0; braced.events.length = 0;
|
||||
});
|
||||
assert(exposedKnocks > 0, 'storm_02 floors you if you just stand in it');
|
||||
assertLess(bracedKnocks, exposedKnocks, 'and bracing through it is strictly better');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- solids collision
|
||||
t.test('collision: solids stop you, and the pushout slides you along them', () => {
|
||||
// one box: the yard's north wall, x -8..8, z -16..-10, waist high
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -280,6 +280,97 @@ export function weatherCases(storms) {
|
||||
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
|
||||
});
|
||||
|
||||
// ---- 9. vertical gust structure (SPRINT2 decision 3) ----
|
||||
// Cloth pressure goes with dot(wind, normal). A flat horizontal panel's normal
|
||||
// points at the sky, so in a perfectly horizontal wind that dot is ~0 and the
|
||||
// cheapest winning rig is "lie it flat and ignore the storm" — the opposite of
|
||||
// the game. Gust fronts are descending air, and descending air hits a flat
|
||||
// panel square on. Lane B owns the cloth-side assert; these are the wind side.
|
||||
test('gusts carry a downdraft, and still air does not', () => {
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
let peakDown = 0, betweenMax = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
const v = f.gustVertical(t);
|
||||
assert(v <= 1e-12, `vertical wind went UP (${v.toFixed(2)}) at t=${t.toFixed(2)} — downdraft only`);
|
||||
const live = f.gusts.some((g) => t > g.t0 && t < g.endAt);
|
||||
if (live) peakDown = Math.min(peakDown, v);
|
||||
else betweenMax = Math.max(betweenMax, Math.abs(v));
|
||||
}
|
||||
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
|
||||
assert(betweenMax === 0, `air is falling between gusts (${betweenMax}) — downdraft must be a gust feature`);
|
||||
assert(peakDown < -2, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
|
||||
});
|
||||
|
||||
test('downdraft tracks its own gust and its JSON fraction', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def);
|
||||
const frac = def.gusts.downdraft;
|
||||
for (const g of f.gusts) {
|
||||
assert(g.down >= frac * 0.6 - 1e-9 && g.down <= frac * 1.4 + 1e-9,
|
||||
`gust at t=${g.t0.toFixed(1)} has down=${g.down.toFixed(3)}, outside 0.6–1.4× of ${frac}`);
|
||||
// minGap >= GUST.TOTAL means gusts never overlap, so at hold it's exactly this gust
|
||||
const atHold = f.gustVertical(g.t0 + 3);
|
||||
assert(Math.abs(atHold - -(g.pow * g.down)) < 1e-9,
|
||||
`at gust hold vertical is ${atHold.toFixed(3)}, want ${(-g.pow * g.down).toFixed(3)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('downdraft 0 gives a perfectly horizontal wind', () => {
|
||||
const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
def.gusts.downdraft = 0;
|
||||
const f = createWindField(def);
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
for (let t = 0; t <= f.duration; t += 0.05) {
|
||||
f.vecAt(2, -1, t, out);
|
||||
assert(out.y === 0, `y=${out.y} at t=${t.toFixed(2)} with downdraft 0 — the opt-out leaks`);
|
||||
}
|
||||
});
|
||||
|
||||
test('downdraft does not re-time the storm', () => {
|
||||
// The vertical draws from its own RNG stream precisely so that adding or
|
||||
// tuning it can't shift gust times or powers. Lane A hand-drove storm_02 and
|
||||
// watched the carabiner blow at t=45.4 and p2 cascade at t=56; a downdraft
|
||||
// tweak silently moving those would be a nasty way to lose an afternoon.
|
||||
const base = storms.storm_02_wildnight;
|
||||
const a = createWindField(base);
|
||||
for (const dd of [0, 0.1, 0.25, 0.5, 1]) {
|
||||
const d = JSON.parse(JSON.stringify(base));
|
||||
d.gusts.downdraft = dd;
|
||||
const b = createWindField(d);
|
||||
assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
|
||||
a.gusts.forEach((g, i) => {
|
||||
assert(g.t0 === b.gusts[i].t0,
|
||||
`downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`);
|
||||
assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('at a gust peak the downdraft is a real fraction of the horizontal', () => {
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
let bestRatio = 0, atT = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
f.vecAt(0, 0, t, out);
|
||||
const horiz = Math.hypot(out.x, out.z);
|
||||
if (horiz < 1) continue;
|
||||
const r = Math.abs(out.y) / horiz;
|
||||
if (r > bestRatio) { bestRatio = r; atT = t; }
|
||||
}
|
||||
metrics['storm_02.peakVerticalRatio'] = +bestRatio.toFixed(3);
|
||||
assert(bestRatio > 0.12,
|
||||
`strongest downdraft is only ${(bestRatio * 100).toFixed(0)}% of the horizontal wind (t=${atT.toFixed(1)}) — a flat sail still shrugs`);
|
||||
});
|
||||
|
||||
test('validator rejects a bad downdraft', () => {
|
||||
for (const dd of [-0.1, 1.5, NaN, 'lots']) {
|
||||
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
d.gusts.downdraft = dd;
|
||||
const { ok } = validateStorm(d, 'broken');
|
||||
assert(!ok, `validator ACCEPTED downdraft = ${dd}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
|
||||
@ -130,17 +130,29 @@ function sampleAngleCurve(curve, t) {
|
||||
|
||||
// ---------- gust timeline ----------
|
||||
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
|
||||
export const DEFAULT_DOWNDRAFT = 0.25;
|
||||
|
||||
export function buildGustTimeline(def, seed) {
|
||||
const g = def.gusts || {};
|
||||
const rng = mulberry32(seed >>> 0);
|
||||
// Vertical draws from its OWN stream, deliberately. Pulling it from `rng`
|
||||
// would shift every subsequent (t0, pow) and silently re-time storms that are
|
||||
// already tuned and hand-verified — A drove storm_02 and watched the carabiner
|
||||
// blow at t=45.4 and cascade at t=56, one second after the change. Adding a
|
||||
// downdraft shouldn't move that.
|
||||
const rngV = mulberry32((seed ^ 0x0d0117) >>> 0);
|
||||
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
|
||||
const downFrac = g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
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 });
|
||||
// Not every gust slams down the same: some roll through nearly flat, some
|
||||
// are a proper little downburst. 0.6–1.4× the storm's fraction.
|
||||
const down = downFrac * (0.6 + rngV() * 0.8);
|
||||
out.push({ t0: t, pow, down, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
|
||||
t += minGap + rng() * Math.max(0, maxGap - minGap);
|
||||
}
|
||||
return out;
|
||||
@ -190,6 +202,26 @@ export function createWindField(def, opts = {}) {
|
||||
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical wind, m/s. NEGATIVE = downward. Zero between gusts.
|
||||
*
|
||||
* A gust front is descending air, not just faster air. Without this the field
|
||||
* is perfectly horizontal, and a horizontal sail is a free lunch: cloth
|
||||
* pressure goes with dot(wind, normal), a flat panel's normal points at the
|
||||
* sky, and the dot product is ~0 no matter how hard it blows. So the cheapest
|
||||
* winning rig was "lie it flat and ignore the storm", which is the opposite of
|
||||
* the game (SPRINT2 decision 3). A downdraft hits a flat panel square on.
|
||||
*/
|
||||
function gustVertical(t) {
|
||||
let v = 0;
|
||||
for (let i = 0; i < gusts.length; i++) {
|
||||
const g = gusts[i];
|
||||
if (t <= g.t0) break; // sorted — nothing later is live
|
||||
if (t < g.endAt) v -= gustEnvelope(t - g.t0, g.pow) * g.down;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
@ -278,7 +310,13 @@ export function createWindField(def, opts = {}) {
|
||||
},
|
||||
get shelters() { return shelters; },
|
||||
|
||||
/** Scalar wind speed (m/s) at a point. The cheap path — no allocation. */
|
||||
/**
|
||||
* Scalar wind speed (m/s) at a point — HORIZONTAL only, which is what an
|
||||
* anemometer reads and what the HUD, rain and grass want. The gust downdraft
|
||||
* is deliberately not in here: a wind meter jumping because air is falling
|
||||
* past it would read as a bug. Use vecAt/sample for the full 3D vector.
|
||||
* The cheap path — no allocation.
|
||||
*/
|
||||
speedAt(x, z, t) {
|
||||
const uni = uniformSpeed(t);
|
||||
const d = dirAt(t);
|
||||
@ -289,16 +327,18 @@ export function createWindField(def, opts = {}) {
|
||||
dirAt,
|
||||
uniformSpeed,
|
||||
gustOnly,
|
||||
gustVertical,
|
||||
|
||||
/** 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);
|
||||
const m = spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
|
||||
let s = uni * m;
|
||||
if (s < 0) s = 0;
|
||||
out.x = dirX * s;
|
||||
out.y = 0; // wind is horizontal; lift is the sail's job (Lane B)
|
||||
out.y = gustVertical(t) * m; // gust fronts descend — see gustVertical()
|
||||
out.z = dirZ * s;
|
||||
return out;
|
||||
},
|
||||
@ -375,6 +415,10 @@ export function validateStorm(def, name = 'storm') {
|
||||
// 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');
|
||||
const dd = g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
if (!Number.isFinite(dd) || dd < 0 || dd > 1) {
|
||||
bad(`gusts.downdraft must be 0..1 — the fraction of gust power that blows DOWN — got ${dd}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of def.events || []) {
|
||||
|
||||
@ -35,6 +35,11 @@ export function heightAt(x, z) {
|
||||
|
||||
const GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 };
|
||||
|
||||
// Shed on the east side, table out in front of it. Lane D tested reachability
|
||||
// against (9, 6), so the table stays there and the shed tucks in behind.
|
||||
const SHED = { x: 11.8, z: 6.2, rotY: -Math.PI / 2 };
|
||||
const SHED_TABLE = { x: 9, z: 6, rotY: -Math.PI / 2 };
|
||||
|
||||
// Sun: mid-afternoon, high and off the north-west shoulder. Elevation 55°.
|
||||
// Stored as the direction from the GROUND toward the SUN (see contracts.js).
|
||||
const SUN_ELEV = (55 * Math.PI) / 180;
|
||||
@ -335,6 +340,69 @@ export function createWorld(scene, opts = {}) {
|
||||
root.add(fence);
|
||||
solids.push(fence);
|
||||
|
||||
// --- shed & spare table ------------------------------------------------
|
||||
// Where the spare hardware lives, which makes it where the §7 scenario
|
||||
// starts: rig → carry a spare → repair mid-storm. Lane D's pickup radius is
|
||||
// 1.5 m off this point and everything downstream of it is already wired, so
|
||||
// this small thing gates the whole hand-played loop.
|
||||
//
|
||||
// The position is published SYNCHRONOUSLY, from constants, even though the
|
||||
// meshes arrive later in dress(). createWorld() has to stay sync — a.test.js
|
||||
// and the selftest build a yard without a server — and Lane D's
|
||||
// wireYardActions reads world.shedTable at wiring time. dress() refines the
|
||||
// point to Lane E's baked `pickup_anchor` if it's there.
|
||||
const shedTable = {
|
||||
pos: new THREE.Vector3(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z) + 0.9, SHED_TABLE.z),
|
||||
};
|
||||
|
||||
/**
|
||||
* Swap Lane E's GLBs in over the graybox. Async and separate from
|
||||
* createWorld() on purpose: the selftest builds a yard with no server, and a
|
||||
* fetch in the constructor would either break it or make it slow and flaky.
|
||||
* Every load is individually guarded — a missing GLB leaves its graybox
|
||||
* standing rather than taking the boot down with it.
|
||||
*/
|
||||
async function dress() {
|
||||
const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
|
||||
const loader = new GLTFLoader();
|
||||
|
||||
const load = async (name) => {
|
||||
try {
|
||||
const gltf = await loader.loadAsync(new URL(`../models/${name}.glb`, import.meta.url).href);
|
||||
gltf.scene.traverse((o) => {
|
||||
if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; }
|
||||
});
|
||||
return gltf.scene;
|
||||
} catch (err) {
|
||||
console.warn(`[world] ${name} unavailable, keeping graybox:`, err.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [shed, table] = await Promise.all([load('shed_01_v1'), load('shed_table_v1')]);
|
||||
|
||||
if (shed) {
|
||||
shed.name = 'shed_01';
|
||||
shed.position.set(SHED.x, heightAt(SHED.x, SHED.z), SHED.z);
|
||||
shed.rotation.y = SHED.rotY;
|
||||
root.add(shed);
|
||||
solids.push(shed);
|
||||
}
|
||||
if (table) {
|
||||
table.name = 'shed_table';
|
||||
table.position.set(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z), SHED_TABLE.z);
|
||||
table.rotation.y = SHED_TABLE.rotY;
|
||||
root.add(table);
|
||||
// NOT in solids: you want to walk up to the table, not be fenced off it.
|
||||
|
||||
// Prefer Lane E's baked anchor over my guess at where a table top is.
|
||||
table.updateWorldMatrix(true, true);
|
||||
const anchor = table.getObjectByName('pickup_anchor');
|
||||
if (anchor) shedTable.pos.setFromMatrixPosition(anchor.matrixWorld);
|
||||
}
|
||||
return { shed, table };
|
||||
}
|
||||
|
||||
// --- the world object --------------------------------------------------
|
||||
return {
|
||||
anchors,
|
||||
@ -343,6 +411,13 @@ export function createWorld(scene, opts = {}) {
|
||||
sunDir: SUN_DIR.clone(),
|
||||
solids,
|
||||
root,
|
||||
/**
|
||||
* Where a spare gets picked up. `{pos}` — Lane D registers a 1.5 m hold-E
|
||||
* off this point. Present from construction; dress() may nudge it onto
|
||||
* Lane E's `pickup_anchor`.
|
||||
*/
|
||||
shedTable,
|
||||
dress,
|
||||
// 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.
|
||||
|
||||
BIN
web/world/models/debris/wheelie_bin_01_v1.glb
Normal file
BIN
web/world/models/debris/wheelie_bin_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/garden_gnome_01_v1.glb
Normal file
BIN
web/world/models/garden_gnome_01_v1.glb
Normal file
Binary file not shown.
Binary file not shown.
BIN
web/world/models/textures/sail_tears.png
Normal file
BIN
web/world/models/textures/sail_tears.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
web/world/models/textures/sail_weave.png
Normal file
BIN
web/world/models/textures/sail_weave.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
Binary file not shown.
BIN
web/world/models/washing_line_01_v1.glb
Normal file
BIN
web/world/models/washing_line_01_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user