Compare commits
16 Commits
fd6dfc77da
...
9f83fd4f20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f83fd4f20 | ||
|
|
9b8aabe0db | ||
|
|
624a72e458 | ||
|
|
1ace50da0f | ||
|
|
cd173d1ce0 | ||
|
|
3f6fc27d00 | ||
|
|
ac5021d279 | ||
|
|
af2694257a | ||
|
|
0f5f4d8368 | ||
|
|
d3e849fcc3 | ||
|
|
7cd25a5d2a | ||
|
|
43ab43d3fb | ||
|
|
d6aa124cb7 | ||
|
|
6971f31984 | ||
|
|
264c2e25d2 | ||
|
|
135511fb05 |
@ -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.
|
||||
321
THREADS.md
321
THREADS.md
@ -506,6 +506,144 @@ 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)`,
|
||||
@ -584,3 +722,186 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
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:
|
||||
· **`world.solids` collision** — the biggest gap at gate 1: the ped walked through the house.
|
||||
Now stops dead 0.30 m off the wall face (expected −9.70, measured −9.70), off trunks, posts and
|
||||
the fence, and slides along walls when you hit them at an angle. Injected as `opts.collide`
|
||||
the same way `groundAt` is, so player.sim.js stays zero-import and node-runnable.
|
||||
· **the M3 verbs are live**: carrying swaps locomotion to Carry/CarryIdle · an interaction names
|
||||
its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) via a new `clip` field on
|
||||
the interact spec · `StumbleBack` on a gust that breaks your stride · **`TakeCover` (hold C)**
|
||||
is now a real mechanic, not a pose — see below.
|
||||
· Table-driven throughout: STATES gained `carryClip`, and `clipFor(sim)` is exported so the
|
||||
selftest can assert what plays without a renderer.
|
||||
|
||||
[D] 2026-07-17 — 🛡️ **NEW MECHANIC — shelter (hold C), flagging it because it's a design addition.**
|
||||
SPRINT2 §Lane D.4 said "TakeCover as the storm shelter verb" and left the transition to me. It
|
||||
brace-locks you: knockWind ×2.0 and shove ×0.25 while held. Measured in the real yard: a **38 m/s
|
||||
gale floors you standing and does NOT while braced — let go in the same gale and you're down in
|
||||
half a second.** So the storm's answer to "the gusts are too strong to cross the yard" is now
|
||||
*wait one out, then move in the lull*, which is exactly the lull-as-repair-window language
|
||||
DESIGN.md §Wind already uses. It raises the bar, it doesn't remove it — a big enough gust still
|
||||
takes you off your feet, braced or not (asserted). You cannot brace from your back.
|
||||
|
||||
[D] 2026-07-17 — 📌 **CORRECTION, Lane A — `knockdown()` does NOT jam the state machine.** Your note
|
||||
says `knockdown(impact)` "jams ~40 into the state machine's start time and you never get up".
|
||||
Reproduced it exactly in the live game: `knockdown(40)` → knocked, then **getup at 1.38 s, idle at
|
||||
2.68 s. You get up.** `t` is only ever written into the event log; timing runs off `stateT`, which
|
||||
`setState` zeroes. The only real effect is cosmetic — polluted event timestamps. **Your wiring
|
||||
(`knockdown(windT, piece.vx, piece.vz)`) is right and better than the plain call** — falling the
|
||||
way the crate travelled is the good version — so nothing to change; I'm only correcting the record
|
||||
so nobody burns an hour hunting a state-machine bug that isn't there. Leaving the signature alone:
|
||||
it's correctly wired at the one call site that matters.
|
||||
(`player.pos` being `{x,y,z}` not `Vector3`: taking your offer to relax the contract wording. Making
|
||||
it real would mean either importing THREE into the zero-import sim — which is what makes it
|
||||
node-runnable and deterministic — or handing back a synced mirror whose writes silently don't move
|
||||
the player. Neither is worth a nominal type match.)
|
||||
|
||||
[D] 2026-07-17 — ❗ **BLOCKED ON LANE A — `world.shedTable`, and it gates the sprint's "done".** The §7
|
||||
scenario is *rig → carry a spare → repair mid-storm*, and there is nowhere to pick a spare up:
|
||||
`world.shedTable` is undefined, so `wireYardActions` self-skips the pickup and **nothing in the game
|
||||
can put a spare in the player's hands.** E shipped `shed_01_v1.glb` AND `shed_table_v1.glb` and
|
||||
they're on disk unused. All I need is world.js to place them and expose
|
||||
`world.shedTable = { pos }` (a `pickup_anchor` empty inside the GLB if E put one there, else the
|
||||
table's top-centre); ~1.5 m from the table's edge is reachable. Everything downstream of it is
|
||||
already wired and asserted. Yard dressing is your file, so I'm not touching it — shout if you'd
|
||||
rather I take it.
|
||||
|
||||
[D] 2026-07-17 — 👋 **LANE B — decision 4, exactly what I call, so you can land it without guessing.**
|
||||
I've hardened my side while waiting; `sail.js` already has `repairCorner(i, hw)` / `trimCorner(i,
|
||||
delta)` internally, so this should be three thin aliases:
|
||||
· `rig.repair(i)` — I gate on `corners[i].broken && carrying === 'spare'`, hold 2.5 s, then call
|
||||
it and consume the spare. Pick the hw yourself (the spare is untyped on my side for now).
|
||||
· `rig.trim(i, delta)` — I call `trim(i, +0.1)` after a 1.2 s hold. Plays `Crank`.
|
||||
· `rig.cornerPos(i)` — **live world position, fresh vector.** I resolve it every frame so a
|
||||
flogging corner's prompt tracks it; `corners[i].pos` is my fallback and doesn't exist today,
|
||||
so with neither, my prompts have no position and silently never appear (they fail safe, which
|
||||
is why the game doesn't crash right now — but it also means none of my repair UI is reachable
|
||||
until this lands).
|
||||
Also: your `attach()` replaces the corners array, per Lane A's warning. **My closures now read
|
||||
`sailRig.corners[i]` live by index instead of capturing the corner object**, so a re-rig can't
|
||||
strand them whether or not anyone re-wires. Asserted both ways (swap the array → the targets track
|
||||
the new objects). You don't have to call `wireYardActions` again after `attach()`, though it's
|
||||
harmless if you do — ids are stable so it replaces rather than stacks.
|
||||
|
||||
[D] 2026-07-17 — ✅ **THE §7 REPAIR LOOP RUNS END TO END — proven with the two missing seams stood in.**
|
||||
Rather than wait, I stubbed exactly what I asked A and B for, in the live game, and drove the whole
|
||||
scenario through the REAL sim + REAL interact + REAL rig:
|
||||
`targets: rerig_0..3, trim_0..3, spare_table`
|
||||
`corner 2 broken → cornerPos(2) = 5.3, 3.9, 8`
|
||||
`walk to shed table → arrived → prompt "take a spare" → hold → carrying=spare`
|
||||
`walk to blown corner → prompt "re-rig corner" (rerig_2) → hold 2.5 s`
|
||||
`→ corner2.broken=false, carrying=null` ← the spare was consumed, the corner is back
|
||||
**So Lane D's half of gate 3 is done and the only thing between us and a hand-playable repair is
|
||||
two tiny seams.** Nothing else of mine is waiting on anything.
|
||||
|
||||
[D] 2026-07-17 — 🔧 **LANE B — `cornerPos(i)`, written and proven against your sim. Three lines.** I had
|
||||
to build it to run the proof above, so here it is exactly as it worked, no guessing:
|
||||
```js
|
||||
/** Live world position of corner i — the anchor while pinned, the flogging node once blown. */
|
||||
cornerPos(i) {
|
||||
const ci = this.cornerIdx[i] * 3;
|
||||
return new THREE.Vector3(this.pos[ci], this.pos[ci + 1], this.pos[ci + 2]);
|
||||
}
|
||||
repair(i) { return this.repairCorner(i); }
|
||||
trim(i, delta) { return this.trimCorner(i, delta); }
|
||||
```
|
||||
Reading the NODE rather than the anchor is the whole point and costs nothing: `_pinCorners()`
|
||||
already pins unbroken corners onto `_anchorPos(c.anchor, t)` and leaves blown ones free, so one
|
||||
implementation gives me a static prompt on a live corner AND a prompt that chases a flogging one.
|
||||
Fresh vector per call per decision 4 — I call it every frame and don't retain it. (Note your
|
||||
corners carry `anchor` but no `pos`, so `corners[i].pos` — my documented fallback — is always
|
||||
undefined; `cornerPos` is the real path.)
|
||||
|
||||
[D] 2026-07-17 — 🔧 **LANE A — `world.shedTable`, one line, and it unblocks the sprint's "done".** What I
|
||||
stubbed to prove the loop was literally:
|
||||
```js
|
||||
world.shedTable = { pos: new THREE.Vector3(9, heightAt(9, 6) + 0.9, 6) }; // table-top centre
|
||||
```
|
||||
Anywhere reachable works — my pickup radius is 1.5 m off that point and the player walked to it
|
||||
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.
|
||||
|
||||
[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.
|
||||
|
||||
[B] 2026-07-17 — 🛑 **DECISION 8 CANNOT WORK, AND NOR CAN DECISION 3. C + INTEGRATOR — read before you
|
||||
spend the sprint on it.** I implemented fraction-of-TOTAL downdraft and swept it. (Modelled exactly,
|
||||
without touching C's file: `vecAt` already returns the full horizontal speed, so `out.y =
|
||||
-hypot(x,z)*frac` IS decision 8.) It pincers the same way gust-only did:
|
||||
```
|
||||
frac (of total) flat-h / flat-pitched twisted rig
|
||||
0.05 21% 4/4 holds
|
||||
0.10 27% 4/4 holds
|
||||
0.15 32% loses one <- gate B breaks here
|
||||
0.30 48% loses one
|
||||
```
|
||||
The integrator was right that data can't settle it. But the reason isn't the semantics — **it's the
|
||||
bar.** This is algebra, not a measurement:
|
||||
· flat-pitched (my 16.7° reference rig) has normal `(0, 0.958, 0.287)` — still 96% vertical.
|
||||
· So a downdraft loads the PITCHED sail too, and on its worst heading pitch and downdraft ADD:
|
||||
`|d| = 0.287 + 0.958·f`, against the horizontal sail's `|d| = f`.
|
||||
· ratio = `(f / (0.287 + 0.958·f))²`. Solve for 60% → **f = 0.86**, i.e. at storm_02's 32.6 m/s peak,
|
||||
**28 m/s of falling air**. The asymptote at INFINITE downdraft is **109%**.
|
||||
No downdraft value satisfies the 60% bar under any semantics. It was never a tuning problem.
|
||||
I also respecified the bar the way I think it was meant — flat-horizontal vs the **hypar**, since
|
||||
flat-vs-flat was never the design claim (the player's choice is flat or twisted) — and it fails too:
|
||||
40% at f=0, 68% at f=0.25, twisted rig dead from f=0.15. **A flat horizontal sail is the lowest-load
|
||||
rig in this sim at every downdraft value, because a horizontal plate in a mostly-horizontal flow
|
||||
catches less than any tilted surface, and adding vertical flow raises everyone.**
|
||||
➜ **Recommendation: retire decision 3/8 as a WIND problem and drop the sprint item.** It costs nothing
|
||||
to ship gate 3 with the loophole open: flat-horizontal is only reachable by planting four posts at
|
||||
identical heights, every real quad in the yard is pitched, and §7's three legs pass today on C's
|
||||
landed 0.3 without any of this. C — your downdraft is worth keeping regardless: it's what made the
|
||||
cascade meaner and it's real weather. It just can't carry this bar.
|
||||
|
||||
[B] 2026-07-17 — **the flat-sail loophole has an answer and DESIGN.md already wrote it: PONDING, not
|
||||
wind.** §"Rain → ponding": *"Flat sails collect water; water is heavy; the belly collects more
|
||||
(positive feedback) until sudden dump, tear, or corner failure."* The arithmetic isn't close:
|
||||
```
|
||||
5 cm of water on a 25 m² flat sail = 1250 kg = 3.1 kN/corner
|
||||
the fabric itself, 25 m² @ 0.32 = 8 kg
|
||||
measured storm_02 WIND on that sail = 0.2-1.1 kN/corner
|
||||
```
|
||||
Ponding is 3-15× the entire quantity we've spent two sprints trying to tune; it only loads sails that
|
||||
can HOLD water, so unlike a downdraft it CANNOT pincer the twisted rig — a hypar has no flat to pool
|
||||
in, so the feedback loop never starts. It needs nothing new from C (`wind.rainAt(t)` exists) and it
|
||||
gives DESIGN.md's broom — "the funniest correct mechanic in the game" — somewhere to live.
|
||||
⚠️ **But it cannot bite in 90 seconds, and that's worth knowing now.** Real heavy rain (50 mm/hr)
|
||||
delivers 1.25 mm over a 90 s storm = 31 kg = 0.08 kN/corner — **2.5%** of what's needed. Ponding wants
|
||||
~40 min of rain. storm_02 is 90 s of wall clock but a whole night of story ("southerly change around
|
||||
the hour mark"), so making it bite means ruling that game-time rain runs ~40× real. That's a design
|
||||
fiat, not physics, and above my lane. I prototyped it (~50 lines: `rainAt` × per-node flatness → water
|
||||
mass → weight, plus `pondMass()` for the HUD) and **reverted it** — default-off code tuned by a
|
||||
constant I invented is worse than the finding. Clean M4 item the moment someone owns the
|
||||
time-compression call; it's about a day.
|
||||
|
||||
[B] 2026-07-17 — Sprint 3 §B-3 done: **sail UVs + E's weave.** E's recipe verbatim (grid i,j → u,v,
|
||||
repeat 6×6, sRGB), plus anisotropy 4 — the sail is mostly seen at a raking angle from underneath,
|
||||
which is exactly where an unfiltered weave moirés. E: took your density as shipped, and the
|
||||
seam-by-construction assert is a good idea. A missing texture warns and falls back to flat colour
|
||||
rather than throwing: the cloth is the game, the weave is a finish, and it shouldn't be able to take
|
||||
the sail down. The URL resolves against `import.meta.url` like weather.js's STORM_DIR, so it survives
|
||||
whatever root server.py runs on — same class of bug as the `/world/` paths the integrator fixed.
|
||||
`sail_tears.png` noted for M3, not this sprint.
|
||||
|
||||
[B] 2026-07-17 — **A — the preview-rig offer stands, and it's ~10 lines my side.** For prep force arrows:
|
||||
build a second `SailRig` over the session's current picks, `step()` it against the calm wind during
|
||||
prep, and read `corners[i].loadVec` — it's already there and it's the reaction VECTOR, not just the
|
||||
magnitude, so it points the arrow for you; `.load` gives you the length. That also closes the real gap
|
||||
I flagged last sprint: prep can't show loads at all today because nothing is attached until commit, so
|
||||
the player commits blind to the one number the whole game is about. If you want the cheap version of
|
||||
the same lesson instead, `riggingUI.summary.area` is already live and picking the obvious quad reads
|
||||
"191 m2" before you commit to it.
|
||||
|
||||
@ -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]],
|
||||
|
||||
@ -201,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.
|
||||
*
|
||||
@ -269,6 +311,20 @@ export const CONTRACT = {
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -30,12 +30,14 @@ export class Interact {
|
||||
* @param {string|function} [spec.label] string, or (player)->string for live text
|
||||
* @param {function} [spec.canUse] (player) -> bool
|
||||
* @param {function} [spec.onDone] (player, t) -> void
|
||||
* @param {string} [spec.clip] verb played for the length of the hold ('Crank', 'PickUp', …).
|
||||
* Must name a clip in player_anims.glb; omitted means the busy state's default Idle.
|
||||
* @returns {function} unregister
|
||||
*/
|
||||
register(spec) {
|
||||
if (!spec || !spec.id) throw new Error('interact.register: id required');
|
||||
const target = {
|
||||
radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, ...spec,
|
||||
radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, clip: null, ...spec,
|
||||
};
|
||||
this.targets.set(target.id, target);
|
||||
return () => this.unregister(target.id);
|
||||
@ -70,6 +72,7 @@ export class Interact {
|
||||
if (!this.active) return;
|
||||
// only hand the player back if they're still ours — a knockdown mid-hold already re-stated them
|
||||
if (player.state === 'busy') player.setState('idle', t);
|
||||
player.busyClip = null;
|
||||
this.events.push({ type: 'cancel', id: this.active.id, t });
|
||||
this.active = null;
|
||||
this.progress = 0;
|
||||
@ -99,6 +102,7 @@ export class Interact {
|
||||
if (!this.active && holding && !this.latched && near && !player.busy) {
|
||||
this.active = near;
|
||||
this.progress = 0;
|
||||
player.busyClip = near.clip || null; // the verb: Crank at a turnbuckle, PickUp at the table
|
||||
player.setState('busy', t);
|
||||
}
|
||||
|
||||
@ -110,6 +114,7 @@ export class Interact {
|
||||
this.progress = 0;
|
||||
this.latched = true;
|
||||
player.setState('idle', t); // release busy FIRST — onDone may pickUp(), which refuses while busy
|
||||
player.busyClip = null; // ...and after it, so the carry clips win on the next frame
|
||||
if (done.onDone) done.onDone(player, t);
|
||||
this.events.push({ type: 'done', id: done.id, t });
|
||||
}
|
||||
@ -129,38 +134,54 @@ export class Interact {
|
||||
* Register the standard yard actions (PLAN3D §5-D.4). Duck-typed against the contracts so Lane D
|
||||
* never edits Lane B's or Lane A's files — anything not yet landed is simply skipped.
|
||||
*
|
||||
* Every closure reads `sailRig.corners[i]` LIVE rather than capturing the corner object. Lane A's
|
||||
* THREADS note: `attach()` REPLACES the corners array, so a captured corner is a stale object the
|
||||
* sim no longer steps — the prompt would gate forever on a `broken` flag that can never change
|
||||
* again. Reading by index means a re-rig can't strand these targets whether or not we get re-wired.
|
||||
*
|
||||
* @param {Interact} interact
|
||||
* @param {object} deps {sailRig, world, spares}
|
||||
* sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B)
|
||||
* sailRig.repair(i) -> void [PROPOSED — see THREADS.md]
|
||||
* sailRig.trim(i,d) -> void [PROPOSED — per-corner turnbuckle, see THREADS.md]
|
||||
* world.shedTable -> {pos} (Lane A/E)
|
||||
* @param {object} deps {sailRig, world}
|
||||
* sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B)
|
||||
* sailRig.repair(i) -> void (decision 4)
|
||||
* sailRig.trim(i,d) -> void (decision 4)
|
||||
* sailRig.cornerPos(i) -> Vector3 (decision 4 — live world position; a flogging corner moves)
|
||||
* world.shedTable -> {pos} (Lane A — until it lands, the pickup self-skips)
|
||||
*/
|
||||
export function wireYardActions(interact, deps = {}) {
|
||||
const { sailRig, world } = deps;
|
||||
const wired = [];
|
||||
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null;
|
||||
// a flogging corner is MOVING — resolve position every frame, never once at wire time
|
||||
const posAt = (i) => () => {
|
||||
const c = cornerAt(i);
|
||||
if (!c) return null;
|
||||
return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null;
|
||||
};
|
||||
|
||||
if (sailRig && Array.isArray(sailRig.corners)) {
|
||||
sailRig.corners.forEach((corner, i) => {
|
||||
sailRig.corners.forEach((_corner, i) => {
|
||||
// re-rig a broken corner — costs the spare you're carrying
|
||||
wired.push(interact.register({
|
||||
id: `rerig_${i}`,
|
||||
pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
|
||||
pos: posAt(i),
|
||||
radius: 1.8,
|
||||
holdSecs: 2.5,
|
||||
label: 're-rig corner',
|
||||
canUse: (p) => corner.broken && p.carrying === 'spare',
|
||||
onDone: (p) => { p.carrying = null; if (sailRig.repair) sailRig.repair(i); },
|
||||
clip: 'Crank',
|
||||
canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken)
|
||||
&& p.carrying === 'spare' && !!sailRig.repair,
|
||||
onDone: (p) => { p.carrying = null; sailRig.repair(i); },
|
||||
}));
|
||||
// per-corner turnbuckle trim — new vs the prototype; makes corners individual
|
||||
wired.push(interact.register({
|
||||
id: `trim_${i}`,
|
||||
pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
|
||||
pos: posAt(i),
|
||||
radius: 1.8,
|
||||
holdSecs: 1.2,
|
||||
label: 'tighten turnbuckle',
|
||||
canUse: () => !corner.broken && !!sailRig.trim,
|
||||
onDone: () => sailRig.trim && sailRig.trim(i, +0.1),
|
||||
clip: 'Crank',
|
||||
canUse: () => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim,
|
||||
onDone: () => sailRig.trim(i, +0.1),
|
||||
}));
|
||||
});
|
||||
}
|
||||
@ -172,6 +193,7 @@ export function wireYardActions(interact, deps = {}) {
|
||||
radius: 1.5,
|
||||
holdSecs: 0.6,
|
||||
label: (p) => (p.carrying ? 'hands full' : 'take a spare'),
|
||||
clip: 'PickUp',
|
||||
canUse: (p) => !p.carrying, // hands-full rule
|
||||
onDone: (p, t) => p.pickUp('spare', t),
|
||||
}));
|
||||
|
||||
@ -346,7 +346,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 });
|
||||
}
|
||||
|
||||
@ -15,9 +15,9 @@
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js';
|
||||
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
import { PlayerSim, STATES, TUNE } from './player.sim.js';
|
||||
import { PlayerSim, STATES, TUNE, clipFor } from './player.sim.js';
|
||||
|
||||
export { PlayerSim, STATES, TUNE };
|
||||
export { PlayerSim, STATES, TUNE, clipFor };
|
||||
|
||||
export const CHAR_URL = './models/player_01.glb';
|
||||
export const ANIM_URL = './models/player_anims.glb';
|
||||
@ -56,6 +56,69 @@ const _loadGLTF = (loader, url) => new Promise((res, rej) =>
|
||||
|
||||
const UP = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
const _clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
/**
|
||||
* Build the player's collision test out of `world.solids` (contracts World).
|
||||
*
|
||||
* Shape of the problem, measured in the real yard rather than assumed:
|
||||
* · `fence` is a GROUP of 37 child meshes whose combined box is the whole 30×20 m yard — so one
|
||||
* box per entry in solids is useless. We flatten to leaf meshes and box each one.
|
||||
* · the house ROOF is a solid spanning y 2.99–3.21, i.e. entirely above a 1.72 m head. A flat
|
||||
* footprint test would wall off the eaves, so every box is filtered by vertical overlap with
|
||||
* the body and the roof simply drops out.
|
||||
* Solids are static, so the boxes are computed once. ~44 leaves, distance-pruned — no raycast per
|
||||
* frame. (Lane A's note: the ground is deliberately NOT in solids; heightAt covers it.)
|
||||
*
|
||||
* @param {object} world contracts World
|
||||
* @param {object} [opts] {radius} metres, the player's shoulder radius
|
||||
* @returns {(x:number,z:number,feetY:number,headY:number)=>{x:number,z:number}}
|
||||
*/
|
||||
export function makeSolidCollider(world, opts = {}) {
|
||||
const radius = opts.radius ?? 0.3;
|
||||
const boxes = [];
|
||||
const b = new THREE.Box3();
|
||||
for (const root of (world && world.solids) || []) {
|
||||
root.updateWorldMatrix(true, true);
|
||||
root.traverse((o) => {
|
||||
if (!o.isMesh) return;
|
||||
b.setFromObject(o);
|
||||
if (!isFinite(b.min.x)) return;
|
||||
boxes.push({ x0: b.min.x, x1: b.max.x, z0: b.min.z, z1: b.max.z, y0: b.min.y, y1: b.max.y });
|
||||
});
|
||||
}
|
||||
const out = { x: 0, z: 0 }; // scratch — copied by the caller immediately, never retained
|
||||
const r2 = radius * radius;
|
||||
|
||||
return function collide(x, z, feetY, headY) {
|
||||
out.x = x; out.z = z;
|
||||
for (let i = 0; i < boxes.length; i++) {
|
||||
const bx = boxes[i];
|
||||
if (bx.y1 <= feetY + 0.05 || bx.y0 >= headY) continue; // under the eaves / over a low wall
|
||||
// closest point on the box to the body centre, in XZ
|
||||
const cx = _clamp(out.x, bx.x0, bx.x1), cz = _clamp(out.z, bx.z0, bx.z1);
|
||||
const dx = out.x - cx, dz = out.z - cz;
|
||||
const d2 = dx * dx + dz * dz;
|
||||
if (d2 >= r2) continue; // clear
|
||||
if (d2 > 1e-10) { // outside: push along the normal
|
||||
const d = Math.sqrt(d2);
|
||||
out.x = cx + (dx / d) * radius;
|
||||
out.z = cz + (dz / d) * radius;
|
||||
} else {
|
||||
// centre is inside the box (spawned in a wall, or shoved through): eject through the nearest
|
||||
// face rather than picking an arbitrary axis, so you pop out the side you came in.
|
||||
const l = out.x - bx.x0, rr = bx.x1 - out.x, u = out.z - bx.z0, dn = bx.z1 - out.z;
|
||||
const m = Math.min(l, rr, u, dn);
|
||||
if (m === l) out.x = bx.x0 - radius;
|
||||
else if (m === rr) out.x = bx.x1 + radius;
|
||||
else if (m === u) out.z = bx.z0 - radius;
|
||||
else out.z = bx.z1 + radius;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
export class PlayerView {
|
||||
/**
|
||||
* @param {object} rig {scene, anims} — the character
|
||||
@ -127,7 +190,8 @@ export class PlayerView {
|
||||
/** Push one sim frame onto the rig. dt drives the mixer only — the sim already stepped. */
|
||||
sync(sim, dt) {
|
||||
const st = STATES[sim.state];
|
||||
this.play(st.clip, st.loop !== false);
|
||||
// clipFor, not st.clip: carrying swaps in Carry/CarryIdle, and an interaction names its own verb
|
||||
this.play(clipFor(sim), st.loop !== false);
|
||||
|
||||
this.root.position.set(sim.pos.x, sim.pos.y, sim.pos.z);
|
||||
|
||||
@ -202,9 +266,13 @@ export async function loadPlayer(scene, opts = {}) {
|
||||
* @returns {Promise<object>} satisfies checkContract('player', …)
|
||||
*/
|
||||
export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
const height = opts.height || 1.72;
|
||||
const p = await loadPlayer(scene, {
|
||||
...opts,
|
||||
height,
|
||||
groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined,
|
||||
// built AFTER the world exists so the boxes capture E's real GLBs, not the graybox
|
||||
collide: opts.collide !== undefined ? opts.collide : makeSolidCollider(world, opts),
|
||||
start: opts.start || { x: 0, y: 0, z: 6 },
|
||||
});
|
||||
const keyboard = new KeyboardInput();
|
||||
@ -257,7 +325,11 @@ export class KeyboardInput {
|
||||
const k = this.keys;
|
||||
const x = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0);
|
||||
const z = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0);
|
||||
return { x, z, run: k.has('ShiftLeft') || k.has('ShiftRight'), camYaw };
|
||||
return {
|
||||
x, z, camYaw,
|
||||
run: k.has('ShiftLeft') || k.has('ShiftRight'),
|
||||
shelter: k.has('KeyC'), // hold to brace — see STATES.shelter
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
@ -11,23 +11,39 @@
|
||||
|
||||
/**
|
||||
* The state machine, as a table (PLAN3D §5-D.5 asks for a table test).
|
||||
* clip — clip name in player_anims.glb
|
||||
* locked — movement input is ignored, and `player.busy` is true
|
||||
* secs — timed states auto-advance to `next` after this long
|
||||
* clip — clip name in player_anims.glb
|
||||
* carryClip — clip to use instead when the player has something in their hands
|
||||
* locked — movement input is ignored, and `player.busy` is true
|
||||
* secs — timed states auto-advance to `next` after this long
|
||||
* Invariant the selftest enforces: every locked state either has a `next` (so it drains on its own)
|
||||
* or is released by an external actor. `busy` is the only externally-released state — interact.js
|
||||
* both enters and leaves it, so a dropped release can't strand the player.
|
||||
* or names an external releaser. `busy` and `shelter` are the released ones — interact.js and the
|
||||
* shelter key each both ENTER and LEAVE their own state, so a dropped release can't strand you.
|
||||
*/
|
||||
export const STATES = {
|
||||
idle: { clip: 'Idle', locked: false, loop: true },
|
||||
walk: { clip: 'Walk', locked: false, loop: true },
|
||||
run: { clip: 'Run', locked: false, loop: true },
|
||||
idle: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true },
|
||||
walk: { clip: 'Walk', carryClip: 'Carry', locked: false, loop: true },
|
||||
run: { clip: 'Run', carryClip: 'Carry', locked: false, loop: true },
|
||||
busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' },
|
||||
shelter: { clip: 'TakeCover', locked: true, loop: true, releasedBy: 'input' },
|
||||
stumble: { clip: 'StumbleBack', locked: true, loop: false, secs: 0.8, next: 'idle' },
|
||||
stagger: { clip: 'Reaction', locked: true, loop: false, secs: 0.9, next: 'idle' },
|
||||
knocked: { clip: 'Falling', locked: true, loop: false, secs: 1.4, next: 'getup' },
|
||||
getup: { clip: 'CrouchToStand', locked: true, loop: false, secs: 1.3, next: 'idle' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Which clip a state actually plays right now. Carrying swaps the locomotion set (Carry/CarryIdle),
|
||||
* and an interaction can name its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) —
|
||||
* interact.js writes that into `sim.busyClip`. Everything else is the table's `clip`.
|
||||
* Kept here rather than in player.js so the selftest can assert it without a renderer.
|
||||
*/
|
||||
export function clipFor(sim) {
|
||||
const st = STATES[sim.state];
|
||||
if (sim.state === 'busy' && sim.busyClip) return sim.busyClip;
|
||||
if (sim.carrying && st.carryClip) return st.carryClip;
|
||||
return st.clip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuning. Ported from the 2D prototype's shape (prototype/game.js:250-252), retuned to metres and
|
||||
* m/s per PLAN3D §1 ("port the behaviour, retune the constants").
|
||||
@ -61,6 +77,16 @@ export const TUNE = {
|
||||
knockSustain: 0.5, // s above knockWind before it happens
|
||||
knockBleed: 2, // exposure drains this many × faster than it fills
|
||||
pitchSecs: 0.35, // s for the body to swing down / back up (view reads sim.pitch)
|
||||
|
||||
// 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)
|
||||
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
|
||||
// gusts are too strong to cross the yard" — wait one out, then move in the lull.
|
||||
shelterKnockMult: 2.0, // knockWind × this while braced — a gust that floors you standing won't
|
||||
shelterShoveMult: 0.25, // and it barely pushes you
|
||||
};
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
@ -78,6 +104,10 @@ export class PlayerSim {
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.start] {x,y,z} spawn, metres
|
||||
* @param {function} [opts.groundAt] (x,z) -> y. Lane A's world.js provides the real one.
|
||||
* @param {function} [opts.collide] (x,z,feetY,headY) -> {x,z} pushed clear of world.solids.
|
||||
* Injected, not imported, for the same reason as groundAt: this file must stay renderer-free.
|
||||
* player.js#makeSolidCollider builds the real one out of world.solids.
|
||||
* @param {number} [opts.height] body height, metres — the collider's vertical span
|
||||
* @param {object} [opts.tune] overrides for TUNE
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
@ -90,6 +120,8 @@ export class PlayerSim {
|
||||
this.state = 'idle';
|
||||
this.stateT = 0;
|
||||
this.carrying = null; // contract: player.carrying — one item, hands-full rule
|
||||
this.busyClip = null; // interact.js names the verb for the current hold (Crank, PickUp…)
|
||||
this.stumbleCool = 0; // s until a gust may stumble you again
|
||||
this.events = []; // {type:'state'|'drop'|'knockdown', …} drained by the view/HUD
|
||||
|
||||
this.exposure = 0; // s spent above knockWind
|
||||
@ -100,6 +132,8 @@ export class PlayerSim {
|
||||
this.knockDir = { x: 0, z: 1 }; // which way the body went down
|
||||
|
||||
this.groundAt = opts.groundAt || (() => 0);
|
||||
this.collide = opts.collide || null;
|
||||
this.bodyHeight = opts.height || 1.72;
|
||||
this.tune = { ...TUNE, ...(opts.tune || {}) };
|
||||
}
|
||||
|
||||
@ -168,6 +202,7 @@ export class PlayerSim {
|
||||
step(dt, t, input = {}, wind = null) {
|
||||
const T = this.tune;
|
||||
this.stateT += dt;
|
||||
this.stumbleCool = Math.max(0, this.stumbleCool - dt);
|
||||
|
||||
// --- local wind, and how much of it is gust ---
|
||||
let wx = 0, wz = 0;
|
||||
@ -180,11 +215,29 @@ export class PlayerSim {
|
||||
this.windBase += (ws - this.windBase) * clamp(dt * T.baseTrack, 0, 1);
|
||||
this.gust = Math.max(0, ws - this.windBase);
|
||||
|
||||
// --- sustained extreme wind puts you down (same rule as a sail corner letting go) ---
|
||||
if (ws > T.knockWind) this.exposure += dt;
|
||||
// --- shelter: hold to brace. Enters and leaves itself, so releasing the key always frees you
|
||||
// even mid-gust. Refused while you're down — you can't brace from your back. ---
|
||||
const wantShelter = !!input.shelter;
|
||||
const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run';
|
||||
if (wantShelter && canShelter) this.setState('shelter', t);
|
||||
else if (!wantShelter && this.state === 'shelter') this.setState('idle', t);
|
||||
const braced = this.state === 'shelter';
|
||||
|
||||
// --- sustained extreme wind puts you down (same rule as a sail corner letting go).
|
||||
// Bracing raises the bar rather than removing it: a big enough gust still wins. ---
|
||||
const knockAt = braced ? T.knockWind * T.shelterKnockMult : T.knockWind;
|
||||
if (ws > knockAt) this.exposure += dt;
|
||||
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
|
||||
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
|
||||
|
||||
// --- a gust below the knockdown bar can still break your stride ---
|
||||
if (!braced && this.gust > T.stumbleGust && this.stumbleCool <= 0
|
||||
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
|
||||
this.stumbleCool = T.stumbleCooldown;
|
||||
this.setState('stumble', t);
|
||||
this.vel.x = this.vel.z = 0;
|
||||
}
|
||||
|
||||
const st = STATES[this.state];
|
||||
|
||||
// --- movement ---
|
||||
@ -215,7 +268,7 @@ export class PlayerSim {
|
||||
// --- gust shove: pressure ∝ speed², gust only, never while you're already on the ground ---
|
||||
const grounded = this.state === 'knocked' || this.state === 'getup';
|
||||
if (!grounded && this.gust > T.shoveGustMin && ws > 1e-3) {
|
||||
const a = T.shoveK * ws * ws;
|
||||
const a = T.shoveK * ws * ws * (braced ? T.shelterShoveMult : 1);
|
||||
this.shove.x += (wx / ws) * a * dt;
|
||||
this.shove.z += (wz / ws) * a * dt;
|
||||
}
|
||||
@ -226,6 +279,15 @@ export class PlayerSim {
|
||||
this.pos.z += (this.vel.z + this.shove.z) * dt;
|
||||
this.pos.y = this.groundAt(this.pos.x, this.pos.z);
|
||||
|
||||
// Solids: push back out of anything we ended up inside. Pushout is perpendicular to the surface,
|
||||
// so walking into a wall at an angle keeps its tangential component and slides along it for free
|
||||
// — no separate slide pass. Velocity is deliberately NOT zeroed: the wind should still be able to
|
||||
// hold you against a fence, and the pushout wins over it every frame anyway.
|
||||
if (this.collide) {
|
||||
const r = this.collide(this.pos.x, this.pos.z, this.pos.y, this.pos.y + this.bodyHeight);
|
||||
if (r) { this.pos.x = r.x; this.pos.z = r.z; }
|
||||
}
|
||||
|
||||
// --- body pitch: the sim owns it so a knockdown is deterministic and falls DOWNWIND,
|
||||
// which a canned clip can't do. player.js just reads sim.pitch + sim.knockDir. ---
|
||||
const wantPitch = this.state === 'knocked' ? 1
|
||||
|
||||
@ -755,9 +755,37 @@ export async function createSailView(rig, { color = 0xd8c48a } = {}) {
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
|
||||
geo.setIndex(new THREE.BufferAttribute(new Uint16Array(rig.tris), 1));
|
||||
|
||||
// UVs: the grid IS the UV space, so (i, j) maps straight to (u, v). Without
|
||||
// this three defaults every vertex to (0,0), the map samples one texel, and
|
||||
// the membrane reads as flat colour — which looks like the texture failing
|
||||
// rather than like a bug. (Lane E's recipe, THREADS.)
|
||||
const N = rig.N;
|
||||
const 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 mat = new THREE.MeshStandardMaterial({
|
||||
color, side: THREE.DoubleSide, roughness: 0.92, metalness: 0.0,
|
||||
});
|
||||
|
||||
// Resolved against this module rather than the server root: the same reason
|
||||
// weather.js builds STORM_DIR this way, and it's what the integrator's
|
||||
// /world/ -> relative pass was fixing. A missing texture must not take the
|
||||
// sail down — the cloth is the game, the weave is a finish.
|
||||
try {
|
||||
const tex = await new THREE.TextureLoader().loadAsync(
|
||||
new URL('../models/textures/sail_weave.png', import.meta.url).href,
|
||||
);
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(6, 6); // ~6 tiles across a 5 m sail (E's density)
|
||||
tex.colorSpace = THREE.SRGBColorSpace; // r175 spelling — `encoding` is gone
|
||||
tex.anisotropy = 4; // it's viewed at a raking angle from underneath
|
||||
mat.map = tex; // keep mat.color: the weave multiplies it
|
||||
} catch (err) {
|
||||
console.warn('[sail] weave texture missing, falling back to flat colour:', err.message);
|
||||
}
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.castShadow = true; // the shadow IS the product
|
||||
mesh.receiveShadow = true;
|
||||
|
||||
@ -618,6 +618,18 @@ test('decision 3: flat-horizontal is no longer a free lunch', () => {
|
||||
// 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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
* web/world/dev_player.html and written up in THREADS.md: head bone 1.715 m at fig scale 0.983,
|
||||
* all six clips bound, Hips tracks correctly absent.
|
||||
*/
|
||||
import { PlayerSim, STATES, TUNE } from '../player.sim.js';
|
||||
import { 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';
|
||||
@ -28,13 +28,36 @@ const drive = (sim, secs, input = {}, wind = null, t0 = 0) =>
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
// ---------------------------------------------------------------- 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.
|
||||
const PACK = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction',
|
||||
'ClimbLadder', 'Crank', 'Dig', 'PickUp', 'Carry', 'CarryTurn', 'CarryIdle', 'StandUp',
|
||||
'TakeCover', 'StumbleBack', 'PlantSeeds']);
|
||||
|
||||
t.test('state table: every state\'s clip exists in player_anims.glb', () => {
|
||||
const clips = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction']);
|
||||
for (const [name, st] of Object.entries(STATES)) {
|
||||
assert(clips.has(st.clip), `state ${name} wants missing clip ${st.clip}`);
|
||||
assert(PACK.has(st.clip), `state ${name} wants missing clip ${st.clip}`);
|
||||
if (st.carryClip) assert(PACK.has(st.carryClip), `state ${name} wants missing ${st.carryClip}`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('clipFor: carrying swaps the locomotion set, an interaction names its own verb', () => {
|
||||
const s = new PlayerSim();
|
||||
assertEq(clipFor(s), 'Idle', 'empty-handed idle');
|
||||
s.state = 'walk'; assertEq(clipFor(s), 'Walk');
|
||||
s.carrying = 'spare';
|
||||
assertEq(clipFor(s), 'Carry', 'carrying while walking');
|
||||
s.state = 'run'; assertEq(clipFor(s), 'Carry', 'no CarryRun clip exists — Carry covers it');
|
||||
s.state = 'idle'; assertEq(clipFor(s), 'CarryIdle', 'carrying while standing');
|
||||
s.state = 'busy'; s.busyClip = 'Crank';
|
||||
assertEq(clipFor(s), 'Crank', 'the verb wins over the carry set while busy');
|
||||
s.busyClip = null;
|
||||
assertEq(clipFor(s), 'Idle', 'busy with no named verb falls back to the table');
|
||||
// locked states have no carry variant — you drop what you held anyway
|
||||
s.carrying = 'spare'; s.state = 'knocked';
|
||||
assertEq(clipFor(s), 'Falling', 'knockdown always plays Falling');
|
||||
});
|
||||
|
||||
t.test('state table: no stuck states — every locked state drains to a free one', () => {
|
||||
for (const [name, st] of Object.entries(STATES)) {
|
||||
if (!st.locked) continue;
|
||||
@ -163,6 +186,130 @@ export default function run(t) {
|
||||
assert(!s.busy, 'player is free');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- shelter (hold C)
|
||||
t.test('shelter: bracing survives a gust that floors you standing', () => {
|
||||
const gust = TUNE.knockWind + 8; // over the standing bar, under the braced one
|
||||
const standing = new PlayerSim();
|
||||
drive(standing, TUNE.knockSustain + 0.3, {}, windX(gust));
|
||||
assertEq(standing.state, 'knocked', 'standing, this gust floors you');
|
||||
|
||||
const braced = new PlayerSim();
|
||||
drive(braced, TUNE.knockSustain + 0.3, { shelter: true }, windX(gust));
|
||||
assertEq(braced.state, 'shelter', 'braced, the same gust does not');
|
||||
assert(braced.busy, 'shelter is locked — you cannot walk while braced');
|
||||
});
|
||||
|
||||
t.test('shelter: raises the bar, it does not remove it', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, TUNE.knockSustain + 0.2, { shelter: true }, windX(TUNE.knockWind * TUNE.shelterKnockMult + 5));
|
||||
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', () => {
|
||||
const s = new PlayerSim();
|
||||
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');
|
||||
});
|
||||
|
||||
t.test('shelter: cannot brace from your back', () => {
|
||||
const s = new PlayerSim();
|
||||
s.knockdown(0);
|
||||
drive(s, 0.3, { shelter: true });
|
||||
assertEq(s.state, 'knocked', 'holding C while down does not hijack the knockdown');
|
||||
});
|
||||
|
||||
t.test('shelter: the wind barely moves you while braced', () => {
|
||||
const push = (input) => {
|
||||
const p = new PlayerSim();
|
||||
drive(p, 30, input, windX(2)); // learn a calm baseline
|
||||
const x0 = p.pos.x;
|
||||
drive(p, 1.2, input, windX(24), 30);
|
||||
return Math.abs(p.pos.x - x0);
|
||||
};
|
||||
assertLess(push({ shelter: true }), push({}) * 0.5, 'bracing must cut the shove hard');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- stumble
|
||||
t.test('stumble: a gust below the knockdown bar still breaks your stride', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, {}, windX(3)); // calm baseline
|
||||
drive(s, 0.4, {}, windX(3 + TUNE.stumbleGust + 4), 30);
|
||||
assertEq(s.state, 'stumble', 'gust over stumbleGust but under knockWind');
|
||||
assert(s.busy, 'stumble is locked');
|
||||
drive(s, 1.0, {}, windX(3), 31);
|
||||
assertEq(s.state, 'idle', 'and it drains on its own');
|
||||
});
|
||||
|
||||
t.test('stumble: one gust hold must not stumble you twice', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, {}, windX(3));
|
||||
let stumbles = 0;
|
||||
const before = s.events.length;
|
||||
drive(s, 2.5, {}, windX(3 + TUNE.stumbleGust + 4), 30); // a full ~1.7 s hold and then some
|
||||
for (const e of s.events.slice(before)) if (e.type === 'state' && e.state === 'stumble') stumbles++;
|
||||
assertEq(stumbles, 1, 'stumbleCooldown makes it punctuation, not a stutter');
|
||||
});
|
||||
|
||||
t.test('stumble: bracing means you keep your feet', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, { shelter: true }, windX(3));
|
||||
drive(s, 0.5, { shelter: true }, windX(3 + TUNE.stumbleGust + 4), 30);
|
||||
assertEq(s.state, 'shelter', 'braced, the gust does not stumble you');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 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
|
||||
const wall = { x0: -8, x1: 8, z0: -16, z1: -10, y0: 0, y1: 3 };
|
||||
const R = 0.3;
|
||||
const collide = (x, z, feetY, headY) => {
|
||||
if (wall.y1 <= feetY + 0.05 || wall.y0 >= headY) return { x, z };
|
||||
const cx = Math.min(Math.max(x, wall.x0), wall.x1);
|
||||
const cz = Math.min(Math.max(z, wall.z0), wall.z1);
|
||||
const dx = x - cx, dz = z - cz, d2 = dx * dx + dz * dz;
|
||||
if (d2 >= R * R || d2 <= 1e-10) return { x, z };
|
||||
const d = Math.sqrt(d2);
|
||||
return { x: cx + dx / d * R, z: cz + dz / d * R };
|
||||
};
|
||||
|
||||
const s = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide });
|
||||
drive(s, 6, { x: 0, z: 1, run: true, camYaw: 0 }, null); // camYaw 0 → forward is -Z
|
||||
assert(s.pos.z >= -10 - 1e-6, `must not enter the wall, z=${s.pos.z.toFixed(3)}`);
|
||||
assertClose(s.pos.z, -10 + R, 0.02, 'stops exactly one body radius off the face');
|
||||
|
||||
// Diagonal into a LONG wall: blocked north, but must still slide east. The wall has to outrun
|
||||
// the player here — against the 16 m one above, a 4 s diagonal sprint rounds its east end and
|
||||
// gets past, which is correct behaviour and not what this assert is about.
|
||||
const long = { ...wall, x0: -100, x1: 100 };
|
||||
const collideLong = (x, z, feetY, headY) => {
|
||||
if (long.y1 <= feetY + 0.05 || long.y0 >= headY) return { x, z };
|
||||
const cx = Math.min(Math.max(x, long.x0), long.x1);
|
||||
const cz = Math.min(Math.max(z, long.z0), long.z1);
|
||||
const dx = x - cx, dz = z - cz, d2 = dx * dx + dz * dz;
|
||||
if (d2 >= R * R || d2 <= 1e-10) return { x, z };
|
||||
const d = Math.sqrt(d2);
|
||||
return { x: cx + dx / d * R, z: cz + dz / d * R };
|
||||
};
|
||||
const g = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide: collideLong });
|
||||
drive(g, 4, { x: 1, z: 1, run: true, camYaw: 0 }, null);
|
||||
assertClose(g.pos.z, -10 + R, 0.02, 'held off the wall');
|
||||
assert(g.pos.x > 2, `pushout must preserve the tangential slide, x=${g.pos.x.toFixed(2)}`);
|
||||
});
|
||||
|
||||
t.test('collision: you can walk under an overhang (eaves are above your head)', () => {
|
||||
// the real roof: y 2.99..3.21, reaching 0.4 m further into the yard than the wall below it
|
||||
const collide = (x, z, feetY, headY) => {
|
||||
const y0 = 2.99, y1 = 3.21;
|
||||
if (y1 <= feetY + 0.05 || y0 >= headY) return { x, z }; // filtered out for a 1.72 m body
|
||||
return { x, z: Math.max(z, -9.6 + 0.3) }; // would wall you off if it applied
|
||||
};
|
||||
const s = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide, height: 1.72 });
|
||||
drive(s, 4, { x: 0, z: 1, run: true, camYaw: 0 }, null); // camYaw 0 → forward is -Z
|
||||
assert(s.pos.z < -9, `a 3 m eave must not block a 1.7 m person, z=${s.pos.z.toFixed(2)}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- determinism (PLAN3D §4)
|
||||
t.test('determinism: two identical 50 s runs produce byte-equal traces', () => {
|
||||
const trace = () => {
|
||||
@ -270,6 +417,61 @@ export default function run(t) {
|
||||
assertEq(sim.carrying, null, 'hands empty');
|
||||
});
|
||||
|
||||
t.test('interact: the action names the verb the player plays', () => {
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
it.register({ id: 'crank', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 1, clip: 'Crank' });
|
||||
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(sim.busyClip, 'Crank', 'busyClip is set for the hold');
|
||||
assertEq(clipFor(sim), 'Crank', 'and that is what plays');
|
||||
it.step(DT, 1, sim, false);
|
||||
assertEq(sim.busyClip, null, 'cancelling clears the verb');
|
||||
assertEq(clipFor(sim), 'Idle', 'back to the table');
|
||||
});
|
||||
|
||||
t.test('interact: the verb is cleared on completion, so carry clips win afterwards', () => {
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
it.register({ id: 'take', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5, clip: 'PickUp',
|
||||
onDone: (p, tt) => p.pickUp('spare', tt) });
|
||||
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(sim.carrying, 'spare', 'picked it up');
|
||||
assertEq(sim.busyClip, null, 'verb cleared');
|
||||
assertEq(clipFor(sim), 'CarryIdle', 'and the player now reads as carrying');
|
||||
});
|
||||
|
||||
t.test('wireYardActions: reads corners LIVE, so attach() cannot strand the targets', () => {
|
||||
// Lane A's warning: attach() REPLACES the corners array. Capturing the corner object at wire
|
||||
// time would leave these gated on a `broken` flag nothing updates ever again.
|
||||
const rig = {
|
||||
corners: [{ anchorId: 'p1', broken: true, pos: { x: 0, y: 2, z: 0 } }],
|
||||
repair: () => {}, trim: () => {},
|
||||
cornerPos: (i) => rig.corners[i].pos,
|
||||
};
|
||||
const it = new Interact();
|
||||
wireYardActions(it, { sailRig: rig });
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
p.carrying = 'spare';
|
||||
assertEq(it.nearest(p).id, 'rerig_0', 'broken corner offers a re-rig');
|
||||
|
||||
// now do what attach() does: swap the whole array for fresh objects
|
||||
rig.corners = [{ anchorId: 'p1', broken: false, pos: { x: 0, y: 2, z: 0 } }];
|
||||
assertEq(it.nearest(p).id, 'trim_0', 'the NEW corner is unbroken → trim, not re-rig');
|
||||
rig.corners = [{ anchorId: 'p1', broken: true, pos: { x: 4, y: 2, z: 4 } }];
|
||||
assertEq(it.nearest(p), null, 'and it followed the corner when it moved out of range');
|
||||
});
|
||||
|
||||
t.test('wireYardActions: prompts track a moving (flogging) corner', () => {
|
||||
const corner = { anchorId: 'p1', broken: false, pos: { x: 0, y: 2, z: 0 } };
|
||||
const rig = { corners: [corner], repair: () => {}, trim: () => {}, cornerPos: (i) => rig.corners[i].pos };
|
||||
const it = new Interact();
|
||||
wireYardActions(it, { sailRig: rig });
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
assertEq(it.nearest(p).id, 'trim_0', 'in range at the start');
|
||||
corner.pos = { x: 9, y: 2, z: 9 }; // the corner blows away
|
||||
assertEq(it.nearest(p), null, 'prompt follows it out of range, not pinned to where it was');
|
||||
});
|
||||
|
||||
t.test('wireYardActions: duck-types against a half-landed world', () => {
|
||||
const empty = new Interact();
|
||||
wireYardActions(empty, {});
|
||||
|
||||
@ -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 || []) {
|
||||
|
||||
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