Compare commits
17 Commits
acb35752ee
...
7dd77775da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dd77775da | ||
|
|
1f728e4a0f | ||
|
|
f1d51d6b87 | ||
|
|
cfe80e2837 | ||
|
|
eb6986d1a0 | ||
|
|
0813b18f5a | ||
|
|
f7d747a578 | ||
|
|
0b4dd0d0bd | ||
|
|
34712bf297 | ||
|
|
da35a954e0 | ||
|
|
ce8d22cb60 | ||
|
|
0ceff91d5a | ||
|
|
966dda9590 | ||
|
|
2b66ce4b9a | ||
|
|
4bb0efcfd1 | ||
|
|
ea128e1307 | ||
|
|
7c699e776d |
@ -274,3 +274,59 @@ Read THREADS' last [I] entry (the dispute ruling) then SPRINT4.md — decisions
|
||||
> (the prop waits for the mechanic), fence_panel_snapped if not shipped, and
|
||||
> refresh the assembled-yard contact sheet for DESIGN.md — the yard finally
|
||||
> looks like the game.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# SPRINT 5 prompts (hail & the broom — fire all five)
|
||||
|
||||
Same rules: own clone, own branch, rebase onto latest main FIRST (Sprint 4
|
||||
merged; router passes rain data; downdraftOfTotal 0.45; decision 13 ruled).
|
||||
Read THREADS' last [I] entry then SPRINT5.md.
|
||||
|
||||
## Lane A — Sprint 5
|
||||
> You are Lane A on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
|
||||
> §Lane A. Wire decision 13 (garden drain = C's hail exposure helper + small
|
||||
> rain term; aftermath adds "hail blocked", verdicts re-tuned), pond warnings
|
||||
> in the HUD off B's pondMass(), take D's greyed-prompt surface (label +
|
||||
> reason) into hud.js, and the small carried bits: sway_amp/sway_phase canopy
|
||||
> handles, C's router-contract tripwire, answer B's panel question (keep their
|
||||
> panel). Keep 60 fps with hail + rain + ponding live. Shepherd as always.
|
||||
|
||||
## Lane B — Sprint 5
|
||||
> You are Lane B on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
|
||||
> §Lane B — the water arc has been carried twice and everything now waits on
|
||||
> it. Ponding v1 from your reverted prototype + C's landed data (use exported
|
||||
> RAIN_TIME_COMPRESSION, never hardcode 40): accumulation × flatness → node
|
||||
> water mass → weight; pondMass() + pond centroid; dump on corner break and
|
||||
> belly-tipping tension change. Asserts: hypar pools nothing, flat rig dies of
|
||||
> water alone in storm_02, storm_01 harmless, mass conserves until dumped.
|
||||
> Then drainPondAt(node) for D's broom (agree the shape in THREADS early),
|
||||
> session.reset() for A, and the tn-1.04 stability clamp (post §7 deltas
|
||||
> before landing).
|
||||
|
||||
## Lane C — Sprint 5
|
||||
> You are Lane C on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
|
||||
> §Lane C — hail is yours and it's decision 13's engine. Storm JSON hail
|
||||
> blocks (bursts timed with big gusts, validator, determinism), hailAt(t) +
|
||||
> a STEEP fall vector (~15-20° max lean, cite why), sky.gardenHailExposure in
|
||||
> your gardenExposure mold, hail audio (drum on cloth, clatter on ground) and
|
||||
> cheap visuals, and the decision-13 assert: no-sail garden damage ≥2× a good
|
||||
> rig's in storm_02. storm_02 gets its burst at the change; storm_03 mild;
|
||||
> storm_01 none.
|
||||
|
||||
## Lane D — Sprint 5
|
||||
> You are Lane D on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
|
||||
> §Lane D — the broom is yours and it's the gate: take broom (hands-full rules
|
||||
> vs spare/ladder), walk under the belly, hold-E poke (Crank/Dig per E's
|
||||
> anim_hint) → B's drainPondAt() → the water dumps ON YOU (stagger if big —
|
||||
> comedy is the point) and the sail springs back. Also surface unusable
|
||||
> prompts greyed-with-reason into A's HUD (your offer), and run a feel pass on
|
||||
> the full loop with hail + ponding live. Log everything in THREADS.
|
||||
|
||||
## Lane E — Sprint 5
|
||||
> You are Lane E on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
|
||||
> §Lane E. Small juice pass: hail impact pips + ground ring decal
|
||||
> (instanced-friendly, stone mesh only if C asks), plant-shred particle puff
|
||||
> for hail hits on the bed, and the carried contact-sheet refresh (night +
|
||||
> daylight) for DESIGN.md.
|
||||
|
||||
115
SPRINT5.md
Normal file
115
SPRINT5.md
Normal file
@ -0,0 +1,115 @@
|
||||
# SPRINT 5 — HAIL & THE BROOM (instructions for Opus 4.8 lanes)
|
||||
|
||||
*Sprint 4 verdict: the game has a face and the longest argument in the repo is
|
||||
closed — B conceded decision 11 with numbers and a post-mortem, storm_02 runs
|
||||
downdraftOfTotal 0.45, the ladder loop landed whole, and you can play a round
|
||||
start to finish with a mouse. The face immediately earned its keep by exposing
|
||||
the real problem: a PERFECT rig scores 54% garden vs 48% for not turning up.
|
||||
The sail cannot be scored on rain, because C's weather is honest: driving rain
|
||||
walks under a sail. Sprint 5 makes the garden score mean something (hail), and
|
||||
ships the water arc DESIGN.md promised (ponding + the broom).*
|
||||
|
||||
Read THREADS from the last [I] entry. Standing items you can rely on: the wind
|
||||
router now passes rainMmPerHour/rainDepthMm; C's ponding data is calibrated to
|
||||
B's arithmetic (storm_02 = 3.12 kN/corner of water on a flat 25 m² rig, exactly
|
||||
the kill number); E's pond/broom assets are on disk with recipes.
|
||||
|
||||
## Decision
|
||||
|
||||
13. **Hail carries storm garden damage; rain demotes to a small drain.**
|
||||
Hailstones are dense and fall fast and steep — overhead cloth blocks them
|
||||
even in wind, so the garden score becomes rig-responsive WITHOUT faking the
|
||||
rain physics. This is also canon: DESIGN.md always said hail shreds gardens
|
||||
and drainage (future content) answers rain. Aftermath keeps garden % as a
|
||||
headline but it must now respond: target ≥2× garden damage for no-sail vs
|
||||
a good rig in a hail-bearing storm, stated as an assert.
|
||||
|
||||
## Lane C — hail (the sprint's new system)
|
||||
|
||||
1. Storm JSON: `hail` block — bursts (timed like gust events, the big ones
|
||||
arriving WITH gusts for maximum drama), stone intensity/size scalar,
|
||||
validator. storm_02 gets a proper hail burst at the change; storm_03 a mild
|
||||
one; storm_01 none. Determinism rules as ever (own stream or zero draws).
|
||||
2. `hailAt(t)` intensity + a fall vector that's STEEP (slight wind lean only —
|
||||
stones are dense; ~15-20° max off vertical at gale speeds, cite the number
|
||||
in a comment so nobody re-litigates rain-angle here).
|
||||
3. Garden damage feed: `sky.gardenHailExposure(bed, t)` in the gardenExposure
|
||||
mold (hailAt × (1 − hail-angle shadow over bed)). A wires the drain.
|
||||
4. Audio + visuals: hail layer (on cloth = drum, on ground = clatter — the
|
||||
cloth drum IS the "my sail is earning its money" sound), instanced stones
|
||||
or impact pips, your call on cost. Lightning already lands on big gusts.
|
||||
5. Assert per decision 13: no-sail garden damage ≥2× good-rig damage in
|
||||
storm_02, using B's SailRig over your hail feed.
|
||||
|
||||
## Lane B — the water arc, finish it (carried twice, now everything waits on you)
|
||||
|
||||
1. **Ponding v1** — your reverted prototype + C's landed data (`rainMmPerHour`,
|
||||
`RAIN_TIME_COMPRESSION` exported so nobody hardcodes 40 twice): accumulation
|
||||
× per-node flatness → node water mass → weight in step(); `pondMass()` and
|
||||
per-pond centroid for HUD/visuals; dump on corner break and on tension
|
||||
change that tips the belly. Asserts: hypar pools ~nothing; a flat rig DIES
|
||||
of water alone in storm_02; storm_01 cannot hurt anyone; mass conserves
|
||||
until dumped.
|
||||
2. **`drainPondAt(node)` API for Lane D's broom** — poke transfers/dumps water
|
||||
locally over ~1.5 s. Coordinate the shape with D in THREADS early — the
|
||||
broom is this sprint's gate and you two meet in the middle.
|
||||
3. `session.reset()` (A's ask — they're reaching into your state machine to
|
||||
fake it). Small.
|
||||
4. Stability clamp for the tn-1.04 cliff (carried; if it moves §7 numbers,
|
||||
post before landing).
|
||||
|
||||
## Lane D — the broom (DESIGN.md's funniest correct mechanic)
|
||||
|
||||
1. Broom loop: take broom from shed wall (E's `broom_01`, `grip_anchor`,
|
||||
carry_type broom — hands-full rules vs spare/ladder apply), walk under the
|
||||
belly, hold-E poke (reuse Crank/Dig per E's anim_hint) → B's
|
||||
`drainPondAt()` → water dumps ON YOU (knock/stagger if the pond is big —
|
||||
your knockdown machinery, the comedy is the point), sail springs back.
|
||||
2. Surface unusable prompts greyed with reasons (your offer + A's HUD hook —
|
||||
"hands full", "out of reach — needs the ladder"). Kills the
|
||||
vanishing-prompt confusion you logged.
|
||||
3. Feel pass on the full loop with hail + ponding live; log notes. You're
|
||||
still the only lane that plays like a player.
|
||||
|
||||
## Lane A — score truth & polish
|
||||
|
||||
1. Wire decision 13: garden drain = hail exposure (C's helper) + small rain
|
||||
drain; aftermath keeps garden % headline + adds "hail blocked" line;
|
||||
verdicts re-tuned so the words track the new numbers.
|
||||
2. Pond HUD: pond mass warning on the corner bars' sail (B's `pondMass()`),
|
||||
"SAIL PONDING — get the broom" ticker line at threshold.
|
||||
3. Take D's greyed-prompt surface into hud.js (they provide label + reason).
|
||||
4. Small carried bits: E's `sway_amp`/`sway_phase` canopy handles, the router
|
||||
contract tripwire C suggested, B's panel question (answer: keep B's panel,
|
||||
you own everything else).
|
||||
5. Shepherd as always; keep the assembled game at 60 fps with hail + rain +
|
||||
ponding all live (you have ~16 ms of headroom; spend some, keep most).
|
||||
|
||||
## Lane E — hail & water juice (small)
|
||||
|
||||
1. Hail impact pips on cloth + ground decal ring (cheap, instanced-friendly),
|
||||
stone texture/mesh if C wants geometry over sprites.
|
||||
2. Plant shred particles when the bed takes hail (the tattered/dead swaps
|
||||
exist; a puff of green when a burst lands sells it).
|
||||
3. Contact-sheet refresh with the dressed night yard + a daylight one for
|
||||
DESIGN.md (carried).
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
gate 1: ponding kills a flat rig in storm_02 (assert + by hand),
|
||||
and the broom SAVES it (by hand, water dumped on the player's head)
|
||||
gate 2: decision-13 assert green — rigging visibly matters to the garden
|
||||
gate 3: full round where EVERY aftermath line responds to a player choice
|
||||
(garden ← hail shadow, corners ← rigging, hardware ← breaks/repairs,
|
||||
collateral ← gnome/fence, budget ← shop) — played, screenshotted
|
||||
```
|
||||
|
||||
Definition of done = gate 3. After this, systems are DONE — Sprint 6 is
|
||||
content: sites, jobs, money across rounds, the landscaper campaign.
|
||||
|
||||
## For John
|
||||
|
||||
- A left you a cue in THREADS and it stands: play a round
|
||||
(`python3 server.py`, pick WILD NIGHT), then write three sentences in
|
||||
THREADS about what felt wrong. Sprint 6's shape depends on them.
|
||||
321
THREADS.md
321
THREADS.md
@ -825,6 +825,92 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
`pickup_anchor` empty, read that, else the table top is fine. That plus B's three lines above and
|
||||
the §7 scenario is hand-playable.
|
||||
|
||||
[A] 2026-07-17 — 🧰 **SPRINT 5 START — router tripwire landed, and it is aimed straight at THIS sprint's
|
||||
headline system.** The Sprint 4 integration note (router silently swallowing `rainMmPerHour`/
|
||||
`rainDepthMm`, ponding would have passed every assert and done nothing in the yard) was **my file's
|
||||
bug**, and the class is worse than the instance: tests hold a real wind, only the GAME holds the
|
||||
router, so an omission is invisible to every suite we own. **Lane C: your `hailAt()` lands this
|
||||
sprint and decision 13 hangs the entire garden score off it — the same swallow would make rigging
|
||||
look irrelevant to the garden all over again, which is the exact thing Sprint 5 exists to fix.**
|
||||
`js/tests/a.test.js` now diffs `createWindRouter` against a real wind and fails naming the missing
|
||||
member. I verified it FIRES rather than just passing: rebuilt Sprint 4's pre-fix router and it
|
||||
reports exactly `rainMmPerHour, rainDepthMm`. **Add hail to the wind surface and the router will go
|
||||
red until I forward it — which is the point. Ping me and it's a one-line fix.** Selftest 209/0/0.
|
||||
|
||||
[A] 2026-07-17 — ✅ **I re-measured my own Sprint 4 finding against a proper control, because decision 13
|
||||
rests on it and my control was suspect.** It was: skipping the rig left the PREVIOUS round's sail in
|
||||
C's shadow grid, and a re-run scored "no sail" at 61% — better than a good rig, which is nonsense
|
||||
and is how I noticed. Redone on a fresh boot with nothing ever rigged (`rig.rigged === false`,
|
||||
`cornerCount 0`, `rainShadowOver(bed) === 0.000`): **true no-sail = 48.5%, good rig = 53%, avg
|
||||
exposure 0.632.** The 4.5-point gap is real and the finding stands unchanged — **decision 13's
|
||||
premise is sound.** Flagging the near-miss anyway: I reported a number from a control that had a
|
||||
sail in it, and got the right answer by luck rather than method.
|
||||
|
||||
[A] 2026-07-17 — 📌 **Lane A is DOWNSTREAM this sprint — items 1-3 all wait on you three, so here is what
|
||||
I need and when.** Not blocking anyone; just so nobody assumes I'm stuck when I'm idle.
|
||||
· **Lane C** — item 1 (decision 13 wiring) needs `sky.gardenHailExposure(bed, t)`. Your Sprint 4
|
||||
`gardenExposure(rect, t)` is already the mold and **I've switched main.js onto it**, so hail
|
||||
lands as one added term in `garden.step()` rather than a rewrite. No storm has a `hail` block
|
||||
yet, so there is nothing for me to wire until yours lands.
|
||||
· **Lane B** — item 2 (pond HUD + "SAIL PONDING — get the broom" ticker) needs `pondMass()`. Also
|
||||
`session.reset()` (SPRINT5 §B.3): I'm still faking it in main.js by unrigging every pick, which
|
||||
works via your refunds but is five lines of me reaching into your state machine.
|
||||
· **Lane D** — item 3 needs your greyed-prompt label + reason; hud.js has the surface waiting.
|
||||
· **Lane B, answering your panel question (SPRINT5 §A.4):** keep your panel. It's good, it's
|
||||
yours, and I'd only be rebuilding it worse. hud.js owns everything else.
|
||||
Meanwhile I'm on item 4 (E's `sway_amp`/`sway_phase` canopy handles) and shepherding.
|
||||
|
||||
[A] 2026-07-17 — 🚩 **GATE 1 (Sprint 4) — THE GAME HAS A FACE. You can play a whole round with a mouse.**
|
||||
On main. `python3 server.py` → forecast card → click a storm → click anchors to rig → ENTER → ride it
|
||||
→ aftermath → play again. No console. Selftest **184/0/0**. What landed: `hud.js` (world-anchored
|
||||
per-corner kN load bars, wind meter, gust telegraph banner, garden HP + "% kept dry by the sail",
|
||||
carry chip, event ticker), Lane B's picking wired to the phase machine, forecast card, aftermath with
|
||||
verdict + gnome collateral, plant damage swaps, page retitled. **John: this is your cue to play a
|
||||
round and write the three sentences.**
|
||||
|
||||
[A] 2026-07-17 — 🔴 **THE MEASUREMENT THAT MATTERS, AND IT IS BAD: garden HP barely responds to rigging.**
|
||||
The face's first act was to expose this. Same storm_02, scored end to end:
|
||||
· good rig (small twisted quad, rated hw, eased to 0.85, **holds 4/4 corners all night**) → **53%**
|
||||
· cheap drum-tight 123 m² span (loses all 4 corners) → **49%**
|
||||
· **NO SAIL AT ALL** (control, nothing rigged) → **48%**
|
||||
Five points between a perfect rig and *not turning up*. The garden score is very nearly independent
|
||||
of the entire rigging game.
|
||||
**Cause, measured, and it is nobody's bug:** sun coverage over the bed holds ~50% all storm, but
|
||||
`skyfx.rainShadowOver(bed)` decays **0.38 → 0.04** as the wind builds (avg 0.23). Driving rain blows
|
||||
under the sail and the shadow walks off the bed — Lane C's model being *right about weather*.
|
||||
**Why decision 7's drain constant cannot fix it:** the rigged-vs-bare gap is the dry fraction itself
|
||||
(~23%), so no value of GARDEN_DRAIN separates them — at 1.6 both died, at 0.9 both sit near 50. I set
|
||||
0.9 so the loop is playable and scores *something* (good rig 53 = win, bare 48 = loss), and wrote the
|
||||
numbers into the constant's docstring so the next person doesn't re-derive them. **The lever is the
|
||||
shadow geometry, not the drain.**
|
||||
**Lane C — this is yours and I'm not touching it:** options I can see are capping the rain-angle
|
||||
offset (a 4 m sail in a 20 m/s wind currently throws its shadow clean past a 4 m-deep bed), or
|
||||
softening how fast offset grows with speed, or storm_02's rain curve. **Or the honest design answer:
|
||||
a sail genuinely does not keep rain off in a gale — that's why DESIGN.md pairs sails with DRAINAGE —
|
||||
in which case garden HP is the wrong headline score for a storm and "corners lost" (which IS
|
||||
responsive: 1 vs 4) should carry the aftermath. That's a design call above my pay grade; flagging,
|
||||
not deciding.** Ponding (decision 10) doesn't touch this — it's about load, not about the bed.
|
||||
|
||||
[A] 2026-07-17 — 🙏 **LANE B — two small asks, both from wiring your UI into the loop (which is excellent,
|
||||
by the way: markers, cycle, shift-remove-with-refund and the quad preview all worked first try
|
||||
through real PointerEvents).**
|
||||
· **`session.reset()`** — "play again" needs last round's rig gone. I do it from main.js by
|
||||
unrigging every pick and `setSpares(0)`, which walks the budget back to $80 via your own refunds,
|
||||
so it's correct — but it's five lines of me reaching into your lane's state machine, and one
|
||||
method on your side would say it better.
|
||||
· I pass `panel: true` — your prep table is genuinely good and I didn't rebuild it, so it and
|
||||
hud.js share the screen (yours top-left, mine top-centre/bottom). If you'd rather own the whole
|
||||
prep screen, say so and I'll pass `panel:false` and rebuild the table in hud.js.
|
||||
|
||||
[A] 2026-07-17 — 🔧 **Testing note that has now bitten me three times in this environment, for whoever
|
||||
hand-drives the game next (Lane D, your on-record runs especially).** rAF is throttled in a hidden
|
||||
tab, and *input events wake it*. So: project an anchor to a pixel, click it, and rAF fires in
|
||||
between → `cameraRig.update()` snaps the camera back to the follow rig → your click lands on
|
||||
nothing, and it looks exactly like broken picking. Lane B's picking is fine; my test was racing the
|
||||
camera. Project and dispatch in the SAME js call and it's deterministic. (Previous two: assigning to
|
||||
`KeyboardInput.holding`, which is a getter; and hand-driving `hud.update` once, which leaves the
|
||||
throttled label canvases blank.) None of these were game bugs — all three looked exactly like game bugs.
|
||||
|
||||
[A] 2026-07-17 — ✅ **DECISION 2 LANDED — and the yard finally teaches the right lesson.** Posts in to
|
||||
(−4.5,5.5)/(4.0,6.0), p3 at (0,7), E's house + both gum trees dressed in, their `branch_anchor_*`
|
||||
registered. **7 anchors → 11.** Quads covering the bed went from "nothing under 110 m²" to **34 in
|
||||
@ -1182,6 +1268,75 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
fiat B asked someone to own. B's finding that ponding cannot pincer the twisted rig (a hypar has no
|
||||
flat to pool in) is exactly why it's the right mechanism.
|
||||
|
||||
[E] 2026-07-17 — ❌ **RETRACTION — my "worst corner 417.7" flag last sprint was a false alarm, ignore it.**
|
||||
`corner.load` is in **newtons** (sail.js's own header says so: "a 5×5 m sail in a 34 m/s storm genuinely
|
||||
puts ~1–4 kN on a corner"). So 417.7 N is 0.42 kN — a perfectly healthy pre-tension load on a rigged
|
||||
sail, not B's cascade. I read a newton value as if it were alarming and posted it without checking the
|
||||
unit. Nobody should spend a minute chasing it. Apologies — deleting the claim rather than leaving it to
|
||||
rot in the log.
|
||||
|
||||
[E] 2026-07-17 — **LANE B — pond visual (SPRINT4 §Lane E-1, decision 10). Two textures + the recipe.**
|
||||
`models/textures/pond_water.png` (256², RGBA) and `pond_normal.png` (256², seamless ripples). Same
|
||||
ride-the-nodes rule as the tears, same reason: a rigid disc added to the sail group sits still while
|
||||
the belly moves under it.
|
||||
· **Alpha is a radial feather**, so the pool dissolves into the cloth instead of ending on a hard
|
||||
rim, and **RGB darkens toward the middle** because that's where it's deep. Both of those are
|
||||
encoded *radially*, which is what lets you scale it per `pondMass()` without the shading going
|
||||
wrong at any size.
|
||||
· Sizing: for roughly constant depth, area ∝ mass, so **radius ∝ √pondMass** is your starting curve.
|
||||
· The normal map tiles (asserted in the build, same guard as the weave) — `RepeatWrapping`, repeat to
|
||||
taste, `normalScale` low (~0.3); it's a puddle, not the ocean.
|
||||
· Water is grey-green, not blue: rain caught in a sail is shallow, murky, and mostly mirrors an
|
||||
overcast sky. If it reads too drab against your cloth, say so and I'll lift it.
|
||||
256² not 512² on purpose: both are smooth low-frequency content, and at 512 they were 256 KB + 320 KB
|
||||
against ~20 KB for every other texture in the repo. No visible difference, quarter the bytes.
|
||||
|
||||
[E] 2026-07-17 — `broom_01_v1.glb` landed (§Lane E-2) — 0.39 × 0.06 × 1.42 m, `mass_hint` 1.2. Nodes
|
||||
`handle` / `head` / `bristles`, plus `grip_anchor` (two-thirds up, `carry_type="broom"`) and
|
||||
**`poke_tip` on the BRISTLE end** — Lane D, that's deliberate: a broomstick jabbed at a loaded sail
|
||||
puts a hole through it, and the soft end is the one a landscaper would actually use. Stands upright
|
||||
with the head on the ground, i.e. how it lives against the shed wall; rotate it to poke. Carries
|
||||
`anim_hint` = reuse Crank/Dig, no new Mixamo needed.
|
||||
|
||||
[E] 2026-07-17 — ⚠️ **RENAMED: `fence_panel_broken` → `fence_panel_snapped`.** SPRINT4 names it
|
||||
`fence_panel_snapped` in both §A-4 and §E-3; I'd shipped `fence_panel_broken` in Sprint 3. Lane A codes
|
||||
against SPRINT4, and `dress()` guards every load — so the mismatch wouldn't have crashed anything, the
|
||||
wreckage would just have **silently never appeared**, which is the worse failure. Canonical name is now
|
||||
`fence_panel_snapped_v1.glb`; old file deleted. `garden_gnome_01_broken` is unchanged and already
|
||||
matches. (Yes, the two suffixes are inconsistent — matching the spec Lane A reads beat matching my own
|
||||
naming.)
|
||||
|
||||
[E] 2026-07-17 — ✅ **Lane A — I booted the dressed yard and it's right.** house_yardside + both gums +
|
||||
shed + table all load; `fascia_anchor_01..03`, `gutter`, `wall`, `trunk`, `canopy` all present; 11
|
||||
anchors. Two things I checked specifically because I'd have been embarrassed to be wrong about them:
|
||||
· **The canopies sway.** They read `rotation 0,0` at t=0 (which briefly fooled me) but after 6 s of
|
||||
wind they're at 0.019 / 0.006 rad — you re-pointed the sway list at the GLB `canopy` groups on
|
||||
dress and the old graybox `canopy_0/1/2` are gone. The handle works.
|
||||
· **`userData` extras are live in production** — world.js:417 reads `rating_hint` and `collateral`
|
||||
off my nodes. That contract is real now, not just asserted.
|
||||
Not yet wired: `sway_amp` (0.85 on the big gum, 1.20 on the whippy one) and `sway_phase` — that's your
|
||||
§A-5 and the data is sitting in `userData` whenever you want it. Free readability: multiply your `lean`
|
||||
by `sway_amp` and take `sway_phase` instead of the hardcoded 0.7 / 2.9, and gum_02 starts showing gust
|
||||
fronts before gum_01 does.
|
||||
|
||||
[E] 2026-07-17 — 👀 art note, my asset, my call to flag rather than fix: in the dressed yard the tree
|
||||
**branch stubs read as coat hooks** — bare horizontal pegs off a bare trunk. They looked fine in
|
||||
isolation; they don't at yard scale. **I'm not touching them unprompted, because the `branch_anchor_*`
|
||||
tips are now live anchors** (A's 11, with rating hints) and moving them would shift A's yard and
|
||||
invalidate B's §7 measurements — exactly the churn I avoided in Sprint 2. I *can* taper and re-angle
|
||||
the limbs while pinning the tips so no anchor moves a millimetre. Say the word and it's a contained
|
||||
change; otherwise it waits for the content sprint.
|
||||
|
||||
[E] 2026-07-17 — 🔧 **TOOLING GAP, and it blocks §Lane E-4 (the assembled-yard picture for DESIGN.md).**
|
||||
There is no way to get a game screenshot to disk. The canvas is WebGL, so `toDataURL` returns a blank
|
||||
buffer unless you render and read in the same tick, and even then the only channel out of the page is
|
||||
text — moving one 900×506 JPEG costs ~60 KB of base64 round-tripped through the harness, which is a
|
||||
ridiculous price for a picture. I've verified the yard by eye and it looks like the game (screenshot is
|
||||
in my session), but I'm not committing one this way.
|
||||
**The durable fix is ~5 lines and it isn't mine:** either a dev key in main.js that does
|
||||
`render(); canvas.toBlob(b => download(b))`, or a tiny POST handler in server.py writing to `docs/`.
|
||||
Lane A, if you'd rather I did it, say so and I'll take it as a Lane E chore next sprint — it's your
|
||||
file, and after that DESIGN.md gets a fresh picture whenever anyone presses a key.
|
||||
[C] 2026-07-17 — 🚨 **LANE A — TWO LINES, OR LANE B'S PONDING IS DEAD ON ARRIVAL IN THE GAME. Please read
|
||||
this one first.** `createWindRouter` in main.js is an explicit allowlist, and `rig.step(dt, wind, …)`
|
||||
hands B *the router*. B's asserts build wind straight from `createWindField` (sail.selftest's
|
||||
@ -1265,3 +1420,169 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
`data/storms/`, so **storm_03 was never loaded in the browser half** — it only surfaced when my new
|
||||
ponding case reached for `storms.storm_03_southerly` and got `undefined`. Fixed, with a note on the
|
||||
list. If you add a storm, that list is the thing to update.
|
||||
[B] 2026-07-17 — ✅ **DECISION 11 CLOSED: the bar PASSES. I was wrong, and C was right.** Re-measured on
|
||||
A's dressed yard, 8 headings, full 90 s, fraction-of-total:
|
||||
```
|
||||
downdraftOfTotal 60% bar (30 m² @ yard pitch) §7 twisted (23 m² quad) §7 cheap flat
|
||||
0.12 (landed) 67% PASS 0/4 1.2 kN 4/4 cascades
|
||||
0.40 84% PASS 0/4 2.2 kN 4/4 cascades
|
||||
0.45 85% PASS 0/4 2.4 kN 4/4 cascades
|
||||
```
|
||||
**storm_02 `downdraftOfTotal` bumped 0.12 → 0.45.** Both physics gates close on one storm JSON, no
|
||||
skips, 26/26 sail + 13/13 rigging green. The bar even passed at the landed 0.12 — the bump isn't
|
||||
needed for it, but 0.45 is C's measured target and makes the storm meaner, so it goes in.
|
||||
**Where I went wrong, precisely, because it's a reusable lesson:** ratio = `(f / (sin p + cos p·f))²`
|
||||
depends on the REFERENCE PITCH `p` far more than on the downdraft `f`.
|
||||
```
|
||||
pitch f=0.12 f=0.45
|
||||
16.7° 8.9% 39.2% <- my synthetic reference rig. Genuinely unreachable: asymptote 109%,
|
||||
would need f=0.86 = 28 m/s of falling air.
|
||||
4.8° 34.9% 71.5% <- the actual yard: house fascia 2.60 m to posts 3.95 m over ~16 m.
|
||||
```
|
||||
My algebra was correct and my conclusion was false, because the rig I proved it on is one the game
|
||||
cannot build. A 16.7° sail needs 1.5 m of drop across 5 m; the yard's anchors give 1.35 m across 16 m.
|
||||
I generalised "unachievable on my test rig" to "unachievable", and the word doing the damage was mine.
|
||||
The integrator's "measured beats modelled" was the right call — and the fix wasn't to distrust the
|
||||
model, it was to point it at the real geometry. **C — your 0.45 was right, and your instinct to keep
|
||||
the downdraft when I wanted it retired was right too. Sorry for the two-sprint detour.**
|
||||
The assert now derives its reference pitch from the yard (`YARD_PITCH_DEG = 4.8`, sourced to world.js)
|
||||
and carries the pitch table in a comment, so the next person can see in ten seconds why the number
|
||||
moves and won't re-run this argument.
|
||||
|
||||
[B] 2026-07-17 — §7 re-pointed onto the decision-2 yard, and one of my own legs was lying. The twisted rig
|
||||
moves off the retired 145 m² quad onto a real 23 m² one (`t1,p1,p2,p3` — most twisted in A's 18-45
|
||||
band). The repair leg's dodgy carabiner had been sitting on **p1, the lightest-loaded corner** (0.60 kN
|
||||
against a 1.20 kN rating), so it rode out the whole storm, nothing blew, and the leg skipped itself
|
||||
while looking green. Measured peaks on the new quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN; the
|
||||
carabiner now goes on p2, blows, and one `repair()` finishes 4/4. Its stale skip guard (still testing
|
||||
the old `gusts.downdraft` key, which decision 8 renamed) is gone — that guard is why it went quiet
|
||||
instead of red. **Third time a Lane B test has passed while proving nothing**, always the same shape:
|
||||
the test arranged the state instead of provoking it. I'd rather flag the pattern than keep fixing
|
||||
instances of it.
|
||||
A — no action for you, but FYI the cheap-flat cascade still fires at t=0.4 s on the old oversized quad
|
||||
(`h1,h3,p2,p1`), which is correct now rather than a wart: that quad is *supposed* to be the wrong
|
||||
answer, and the yard now offers right-sized ones next to it.
|
||||
|
||||
[B] 2026-07-17 — ⏳ **STILL OPEN on lane/b this sprint: ponding v1 (§B-2) and D's tn-1.04 cliff (§B-3).**
|
||||
Decision 11 took the afternoon it was budgeted and it closes the longest-running question in the repo,
|
||||
so I've landed it alone rather than half-land three things. Both remaining items are specced and
|
||||
unblocked — nothing waits on another lane:
|
||||
· **Ponding v1** — decision 10's 40× fiat is exactly what I asked for and it's the right call. My
|
||||
Sprint-3 prototype (`rainAt` × per-node flatness → water mass → weight, `pondMass()` for the HUD)
|
||||
was reverted, not lost; rebuilding it against the fiat is the short part. The asserts SPRINT4 asks
|
||||
for are the real work: a hypar must pool ~nothing (it should — there's no flat for water to sit
|
||||
in), and a flat rig must die of water alone in storm_02.
|
||||
· **D's cliff** — tn 1.04 taking peak load 1.2 → 10 kN in a 0.01 step is my bug and I want to
|
||||
understand it before I clamp it. D's read (a cloth-stability ceiling, not gradual overload) matches
|
||||
a solver instability rather than physics, and a per-face force clamp would hide it rather than fix
|
||||
it. **D: excellent catch, and the guard assert you added so a wind rebalance can't silently kill a
|
||||
mechanic is the right instinct — that's the same failure mode as my three vacuous tests above.**
|
||||
[D] 2026-07-17 — 🪜 **LADDER SUB-SYSTEM LANDED (decision 12) — the full loop runs in the real game.**
|
||||
New file `web/world/js/ladder.js` (Lane D). Driven by hand through SHADES.step, the prompt chain is:
|
||||
`ladder_take` → carry → `ladder_place_h2` → (h2's carabiner blows) → `spare_table` →
|
||||
`ladder_climb` → **`rerig_0`, which only exists at height** → `oooo`, spare consumed.
|
||||
Selftest **194/0/0** (was 184); 11 of the new asserts are the ladder's, including the scripted
|
||||
climb-repair-descend the sprint asked for.
|
||||
**It needed no change to main.js.** createLadder self-wires from createPlayer, which Lane A already
|
||||
hands the scene, world and interact — so a whole sub-system landed inside Lane D's own files. The
|
||||
reach gate finds it via `interact.ladder` (Interact is Lane D's class), and an explicit
|
||||
`deps.ladder` still wins for tests.
|
||||
Numbers that made the design: **fascia sits at 2.48 m, a 1.72 m person reaches 2.20 m, E's ladder
|
||||
tops out at 2.90 m.** Two hundred millimetres is the entire mechanic — the asset and the yard were
|
||||
already built for each other, I just wrote the verb between them.
|
||||
|
||||
[D] 2026-07-17 — 📐 **Scoped `needsLadder` to the FASCIA, deliberately — flagging the judgement call.**
|
||||
A pure "is it above head height?" rule would have caught the 3.95 m posts and the 5.05 m tree limbs
|
||||
and turned *every* repair in the game into a two-trip ladder job — which would have silently
|
||||
invalidated the recorded §7 run and Lane B's gate asserts, and isn't true to rigging anyway: a sail
|
||||
post is tensioned from a cleat at its base and a tree anchor is a strop you throw. A bracket bolted
|
||||
2.5 m up a bare wall is the one you cannot fake. So `needsLadder = anchor.type === 'house'`, one
|
||||
line, where it can be found and argued with. This also means **the house is now the expensive anchor
|
||||
to depend on** — which is what E's `ratingHint 0.35` / `collateral "gutter"` was already saying in
|
||||
the data. Shout if you wanted it broader.
|
||||
|
||||
[D] 2026-07-17 — 🐛 **A FOOTGUN IN MY OWN API, found twice while building on it, now documented + asserted.**
|
||||
`interact.register({canUse})` is re-checked EVERY FRAME to keep a hold alive — and starting a hold
|
||||
moves the player into `busy`. So any `canUse` that reads `player.state` goes false on frame one and
|
||||
**the action silently cancels its own hold.** No error; the prompt just looks dead. It ate the
|
||||
ladder's climb (`canUse: p => p.state === 'idle'`) and the fascia reach gate (`isWorking()` testing
|
||||
`state === 'atTop'`) — I lost time to it both times. Fixed by gating on PHYSICAL facts (carrying,
|
||||
position, climbY), which is also the honest test. Now written on `register()`'s JSDoc where you'll
|
||||
read it, and pinned by an assert that registers a deliberately state-gated action and proves it
|
||||
never fires. **B/C/E: if you ever register an interact target, gate on facts, not on state.**
|
||||
|
||||
[D] 2026-07-17 — 💬 **FEEL NOTES from playing it (A's HUD/prep not landed yet, so these are from the
|
||||
parts that exist). I'm the lane that plays it, so:**
|
||||
1. **No prompt is worse than a refusal.** Carry the ladder to the shed table and the "take a
|
||||
spare" prompt doesn't say "hands full" — it *vanishes*, because `canUse` filters the target out
|
||||
of `nearest()` before the label can explain itself. It reads as a broken game, not a full pair
|
||||
of hands. **Lane A, this is a HUD-shaped problem:** the prompt wants to show unusable actions
|
||||
greyed out with their reason, and my `label(player)` already returns "hands full" / "out of
|
||||
reach — needs the ladder" for exactly this. Say the word and I'll surface unusable targets.
|
||||
2. **The two-trip fascia repair costs ~15 s of running** (shed→ladder→wall→shed→wall) out of a 90 s
|
||||
storm. It is *supposed* to hurt, and it does — but that's a sixth of the storm on foot, and
|
||||
until the HUD shows corner loads you can't tell whether you're spending it well. Worth a look
|
||||
once the HUD lands; I'd rather tune it against a player who can see, than guess now.
|
||||
3. **A ladder standing bolt upright reads as a post, not a ladder** — I had it vertical at first
|
||||
and genuinely couldn't tell what I was looking at until I saw its shadow. It now leans 15° into
|
||||
the wall. Small thing; large difference. E, the GLB is lovely and its `ladder_top`/`ladder_base`
|
||||
nodes did all the work — I read topY straight off the asset rather than hardcoding 2.9.
|
||||
|
||||
[I] 2026-07-17 — **SPRINT 4 INTEGRATION (main).** Lanes b/c/d/e merged; applied C's two-line router fix
|
||||
in main.js (rainMmPerHour/rainDepthMm — ponding would have been dead-on-arrival; C, your tripwire
|
||||
suggestion for the router allowlist is a good SPRINT5 nicety). Selftest **207/0/0**. Played the loop
|
||||
with the face on: forecast card (three named storms, the change warning) → prep (B's table, anchor
|
||||
rings, budget) → 91 s wild night → aftermath with verdict + play again. Gate 1 confirmed.
|
||||
Decision 11 closed by B conceding with numbers and a model post-mortem worth rereading (reference
|
||||
pitch, not downdraft, was the variable; "measured beats modelled" held). storm_02 downdraftOfTotal
|
||||
is 0.45. Ladder loop landed whole inside Lane D's files. E renamed fence_panel_snapped to match spec.
|
||||
**DECISION 13 (design ruling on A's garden-HP finding): HAIL carries storm garden damage.** A perfect
|
||||
rig scores 54% vs 48% for no sail because driving rain honestly walks under the sail — C's weather is
|
||||
right, so rain is the WRONG thing to score the sail on. DESIGN.md already says hail is the
|
||||
garden-killer and drainage answers rain. Hail falls steep and fast → overhead cloth blocks it even in
|
||||
wind → the garden score becomes rig-responsive without faking physics. Rain demotes to a small drain
|
||||
(and ponding load); drainage stays future content. SPRINT5 wires it.
|
||||
|
||||
[C] 2026-07-17 — **HAIL LANDED — decision 13's engine. Selftest 216/0/0.** Storm hail carries the garden
|
||||
score, and it works because hail falls STEEP where rain doesn't: a raindrop's terminal velocity is
|
||||
~9 m/s (a 30 m/s gale blows it in at ~73° off vertical, which is why it walks under a sail and a
|
||||
perfect rig tied with no rig), a hailstone's is ~22 m/s and it couples weakly to the crosswind, so
|
||||
even a gale leans it ≤20°. Overhead cloth blocks steep ice. No faked physics.
|
||||
· **weather.core:** `hail` block in the storm JSON — authored `bursts` (envelopes) plus one synced
|
||||
to every gust ≥ `withGustsAbove`, so the biggest gusts arrive WITH ice. `hailAt(t)` (max over live
|
||||
bursts, 0..1), `hailSize`, validator. Gust-synced bursts draw ZERO randomness (key off the gust
|
||||
timeline), so tuning hail can't re-time the storm — same guarantee as the downdraft, asserted.
|
||||
· **storms:** storm_02 bursts ON the southerly change (peak 1.0 at t=56.5, 11.4 hail-seconds, size
|
||||
1.3) and adds ice to its late big gusts; storm_03 one mild 0.5 burst (size 0.7); storm_01 none.
|
||||
· **skyfx:** `gardenHailExposure(bed, t)` in the gardenExposure mold (the feed A wires), a steep
|
||||
second RainShadow, instanced falling stones (hidden under the cloth so you SEE the sail work), and
|
||||
hail audio — ground clatter that fades as the sail intercepts, plus the cloth DRUM that rises as
|
||||
the sail catches hail (the "my sail is earning its money" sound). Also `sky.hailAmount` getter.
|
||||
|
||||
[C] 2026-07-17 — **DECISION 13 / GATE 2 PROVEN: no-sail garden takes 4.4× the hail of a well-covered bed**
|
||||
over a full storm_02 (bar is ≥2×), through the real `gardenHailExposure` + B's SailRig. Asserted in
|
||||
c.test.js. Verified live too: at the burst a bed-covering rig cuts hail exposure roughly in half, and
|
||||
the stones fall visibly steeper than the rain beside them (screenshot for DESIGN.md). **A — your garden
|
||||
score will now separate a good rig from no rig.** Wiring, per SPRINT5 §A-1:
|
||||
`gardenDrain = sky.gardenHailExposure(bed, t) * HAIL_DAMAGE + sky.gardenExposure(bed, t) * SMALL_RAIN`.
|
||||
Hail is the headline (aftermath "hail blocked" line reads off `sky.hailShadowOver(bed)` × how much hail
|
||||
fell); rain stays the small honest drain that walks under the sail. `sky.hailAmount` (0..1) is your
|
||||
"HAIL" banner trigger; the drum audio already tells the player when the sail is catching it.
|
||||
|
||||
[C] 2026-07-17 — **A — I edited main.js's router (two lines): `hailAt`/`hailSize`. Flagging since it's your
|
||||
file.** Your tripwire ('wind router forwards EVERYTHING') went red the instant weather.js grew hailAt —
|
||||
which is the tripwire earning its keep; it even names hail as the case it's guarding. Your comment on
|
||||
createWindRouter is the standing instruction ("Anything new on the wind contract must be added here
|
||||
too"), so I followed it rather than leaving main red for you to find. Same pattern as the existing
|
||||
forwards, tripwire green again, hail verified reaching skyfx in-game with no console patch. Thank you
|
||||
for building it — it turned last sprint's silent-swallow into a red test that told me exactly what to
|
||||
add. Revert my two lines only if you'd rather own them; the forward itself has to stay or decision 13
|
||||
is inert in the game.
|
||||
|
||||
[C] 2026-07-17 — **E — hail juice hooks, when you're ready (SPRINT5 §E).** The stones are cheap instanced
|
||||
cubes right now — deliberately minimal so your pips/decals are the detail layer. If you ship impact
|
||||
pips-on-cloth + a ground decal ring, I'll spawn them where a stone meets the sail ceiling or the
|
||||
ground (I already keep a hail-shadow grid that knows the ceiling height per cell, so a cloth-impact
|
||||
point is cheap). Plant-shred puff wants a trigger: fire it when `gardenHailExposure(bed,t)` crosses a
|
||||
threshold — that's the frame the bed actually takes a hit. Tell me the hook shape and I'll match it;
|
||||
`sky.hailAmount` and `hailSize` are live for scaling pip rate/size.
|
||||
|
||||
@ -16,400 +16,7 @@
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_gum_01",
|
||||
"dims": [
|
||||
4.5522,
|
||||
4.956,
|
||||
7.9702
|
||||
],
|
||||
"tris": 396,
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"branch_anchor_03",
|
||||
"canopy",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"canopy_03",
|
||||
"tree_gum_01",
|
||||
"trunk"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_gum_02",
|
||||
"dims": [
|
||||
3.8871,
|
||||
2.7787,
|
||||
5.4972
|
||||
],
|
||||
"tris": 288,
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"canopy",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"tree_gum_02",
|
||||
"trunk"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_post",
|
||||
"dims": [
|
||||
0.13,
|
||||
0.13,
|
||||
2.03
|
||||
],
|
||||
"tris": 24,
|
||||
"nodes": [
|
||||
"fence_post",
|
||||
"post"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_panel",
|
||||
"dims": [
|
||||
2.4,
|
||||
0.054,
|
||||
1.8194
|
||||
],
|
||||
"tris": 324,
|
||||
"nodes": [
|
||||
"fence_panel",
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "gate",
|
||||
"dims": [
|
||||
1.045,
|
||||
0.0615,
|
||||
1.75
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"gate",
|
||||
"gate_frame",
|
||||
"gate_palings",
|
||||
"hinge_axis",
|
||||
"hinges"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "house_yardside",
|
||||
"dims": [
|
||||
9.2,
|
||||
1.0547,
|
||||
2.9
|
||||
],
|
||||
"tris": 200,
|
||||
"nodes": [
|
||||
"door",
|
||||
"fascia",
|
||||
"fascia_anchor_01",
|
||||
"fascia_anchor_02",
|
||||
"fascia_anchor_03",
|
||||
"gutter",
|
||||
"house_yardside",
|
||||
"roof",
|
||||
"wall",
|
||||
"window"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_01",
|
||||
"dims": [
|
||||
2.58,
|
||||
1.9708,
|
||||
2.2224
|
||||
],
|
||||
"tris": 96,
|
||||
"nodes": [
|
||||
"door_anchor",
|
||||
"doors",
|
||||
"roof",
|
||||
"shed_01",
|
||||
"shell"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_table",
|
||||
"dims": [
|
||||
1.6,
|
||||
0.6,
|
||||
0.9
|
||||
],
|
||||
"tris": 72,
|
||||
"nodes": [
|
||||
"pickup_anchor",
|
||||
"shed_table",
|
||||
"table_frame",
|
||||
"table_top"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_bed",
|
||||
"dims": [
|
||||
3.0,
|
||||
1.2,
|
||||
0.8609
|
||||
],
|
||||
"tris": 2580,
|
||||
"nodes": [
|
||||
"bed",
|
||||
"garden_bed",
|
||||
"plants_dead",
|
||||
"plants_full",
|
||||
"plants_tattered",
|
||||
"soil"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "sail_post",
|
||||
"dims": [
|
||||
0.507,
|
||||
0.52,
|
||||
4.0327
|
||||
],
|
||||
"tris": 528,
|
||||
"nodes": [
|
||||
"footing",
|
||||
"pad_eye",
|
||||
"post",
|
||||
"rake_pivot",
|
||||
"sail_post",
|
||||
"top_anchor"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "ladder_01",
|
||||
"dims": [
|
||||
0.455,
|
||||
0.075,
|
||||
3.0
|
||||
],
|
||||
"tris": 276,
|
||||
"nodes": [
|
||||
"ladder",
|
||||
"ladder_01",
|
||||
"ladder_base",
|
||||
"ladder_top"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shackle",
|
||||
"dims": [
|
||||
0.0569,
|
||||
0.019,
|
||||
0.0744
|
||||
],
|
||||
"tris": 560,
|
||||
"nodes": [
|
||||
"bow",
|
||||
"pin",
|
||||
"shackle"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "carabiner",
|
||||
"dims": [
|
||||
0.049,
|
||||
0.009,
|
||||
0.1027
|
||||
],
|
||||
"tris": 476,
|
||||
"nodes": [
|
||||
"body",
|
||||
"carabiner",
|
||||
"gate"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "turnbuckle",
|
||||
"dims": [
|
||||
0.0292,
|
||||
0.0341,
|
||||
0.1955
|
||||
],
|
||||
"tris": 728,
|
||||
"nodes": [
|
||||
"body",
|
||||
"eye_a",
|
||||
"eye_b",
|
||||
"turnbuckle"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tramp_01",
|
||||
"dims": [
|
||||
2.9555,
|
||||
2.9555,
|
||||
0.78
|
||||
],
|
||||
"tris": 976,
|
||||
"nodes": [
|
||||
"legs",
|
||||
"mat",
|
||||
"pad",
|
||||
"rim",
|
||||
"tramp_01"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"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": []
|
||||
},
|
||||
{
|
||||
"name": "garden_gnome_01_broken",
|
||||
"dims": [
|
||||
0.3947,
|
||||
0.3519,
|
||||
0.106
|
||||
],
|
||||
"tris": 344,
|
||||
"nodes": [
|
||||
"garden_gnome_01_broken",
|
||||
"hat",
|
||||
"head",
|
||||
"shards",
|
||||
"stump"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_panel_broken",
|
||||
"dims": [
|
||||
2.4,
|
||||
0.7749,
|
||||
1.8197
|
||||
],
|
||||
"tris": 336,
|
||||
"nodes": [
|
||||
"debris_palings",
|
||||
"fence_panel_broken",
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": [
|
||||
{
|
||||
"file": "BlueCrate_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.36,
|
||||
0.29
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "BlackTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WhiteTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WoodenBin_v2.glb",
|
||||
"dims": [
|
||||
0.35,
|
||||
0.36,
|
||||
0.31
|
||||
],
|
||||
"sane": true
|
||||
}
|
||||
]
|
||||
"debris": []
|
||||
}
|
||||
@ -117,9 +117,16 @@ PAL = {
|
||||
"gnome_skin": "#E0A986",
|
||||
"gnome_coat": "#3E6FA8",
|
||||
"gnome_hat": "#B33C36",
|
||||
"bristle": "#C9A659", # broom straw
|
||||
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
||||
}
|
||||
|
||||
# Rainwater caught in a sail is not swimming-pool blue. It's shallow, murky, it
|
||||
# picks up dust off the cloth, and mostly it mirrors an overcast sky — so it
|
||||
# reads grey-green, and it goes darker where it's deeper.
|
||||
WATER_SHALLOW = (0.46, 0.51, 0.46)
|
||||
WATER_DEEP = (0.22, 0.28, 0.26)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# UTILS — lifted from racks_to_glb.py, kept deliberately close to the original
|
||||
@ -1181,7 +1188,52 @@ def build_garden_gnome_01_broken(name):
|
||||
return root
|
||||
|
||||
|
||||
def build_fence_panel_broken(name):
|
||||
def build_broom_01(name):
|
||||
"""The poke-the-pond tool (SPRINT4 §Lane E-2).
|
||||
|
||||
Stands upright, head on the ground, because that's how it lives against the
|
||||
shed wall — Lane D rotates it to poke. `poke_tip` is on the BRISTLE end, not
|
||||
the handle: a broomstick jabbed at a loaded sail puts a hole through it, and
|
||||
the soft end is the one a landscaper would actually use. `grip_anchor` is
|
||||
where the hand goes, two thirds up.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
dowel = get_material("Mat_Timber", PAL["timber"], 0.7)
|
||||
head_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
|
||||
bristle = get_material("Mat_Bristle", PAL["bristle"], 0.95)
|
||||
H, HEAD_W = 1.42, 0.30
|
||||
|
||||
join_group([add_cyl(f"{name}_handle", 0.014, H - 0.10, (0, 0, 0.10 + (H - 0.10) / 2),
|
||||
dowel, verts=8)], "handle", root)
|
||||
join_group([add_box(f"{name}_head", (HEAD_W, 0.055, 0.05), (0, 0, 0.125), head_m),
|
||||
add_cone(f"{name}_ferrule", 0.020, 0.014, 0.05, (0, 0, 0.16), head_m,
|
||||
verts=8)], "head", root)
|
||||
|
||||
# Bristles: a row of tapered tufts, splayed a little and unevenly worn. A
|
||||
# solid block reads as a paint roller.
|
||||
tufts = []
|
||||
n = 11
|
||||
for i in range(n):
|
||||
x = -HEAD_W / 2 + 0.02 + i * ((HEAD_W - 0.04) / (n - 1))
|
||||
ln = rng.uniform(0.085, 0.105)
|
||||
lean = (x / (HEAD_W / 2)) * rng.uniform(0.04, 0.09)
|
||||
tufts.append(add_tube_between(f"{name}_tuft_{i:02d}", (x, 0, 0.10),
|
||||
(x + lean, rng.uniform(-0.01, 0.01), 0.10 - ln),
|
||||
0.010, bristle, verts=4))
|
||||
join_group(tufts, "bristles", root)
|
||||
|
||||
g = add_empty("grip_anchor", (0, 0, 0.95), root, size=0.12)
|
||||
g["carry_type"] = "broom"
|
||||
p = add_empty("poke_tip", (0, 0, 0.02), root, size=0.12)
|
||||
p["use"] = "push the pond up from under the sail; soft end, won't hole the cloth"
|
||||
stamp(root, name, "tool")
|
||||
root["mass_hint"] = 1.2
|
||||
root["anim_hint"] = "reuse Crank/Dig for the poke — no new Mixamo needed"
|
||||
return root
|
||||
|
||||
|
||||
def build_fence_panel_snapped(name):
|
||||
"""A panel the storm went through. Same 2.4 m tile footprint and origin as
|
||||
fence_panel, so Lane A drops it into the run in place of one instance rather
|
||||
than re-tiling the fence.
|
||||
@ -1366,6 +1418,103 @@ def build_sail_textures():
|
||||
return [p1, p2]
|
||||
|
||||
|
||||
def build_pond_textures():
|
||||
"""The pond in a flat sail's belly (SPRINT4 §Lane E-1, decision 10).
|
||||
|
||||
Two textures, both for a patch Lane B builds from the cloth's own nodes —
|
||||
same ride-the-nodes rule as the tear decals, and for the same reason: a rigid
|
||||
disc added to the sail group would sit still while the belly moves under it.
|
||||
|
||||
pond_water.png — RGBA decal. Alpha is a radial feather so the pool dissolves
|
||||
into the cloth instead of ending at a hard rim; RGB darkens
|
||||
toward the middle because that's where it's deep. Scale it
|
||||
per pond mass and the shading stays right, because depth is
|
||||
encoded radially rather than baked at one size.
|
||||
pond_normal.png — SEAMLESS tiling ripple normals, so the pool catches the sun
|
||||
and reads as liquid rather than as a painted patch. Tiles
|
||||
because B will repeat it across whatever area the pond has.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
# 256², not 512²: both of these are smooth, low-frequency content (a radial
|
||||
# gradient and some sine ripples), so the extra resolution buys nothing you
|
||||
# can see and costs 4x the bytes. At 512 they were 256 KB + 320 KB against
|
||||
# ~20 KB for every other texture here, in a repo whose entire model set is
|
||||
# 672 KB. The fastest pond is the one that isn't most of the download.
|
||||
SIZE = 256
|
||||
Y, X = np.mgrid[0:SIZE, 0:SIZE]
|
||||
c = (SIZE - 1) / 2.0
|
||||
nx, ny = (X - c) / (SIZE / 2.0), (Y - c) / (SIZE / 2.0)
|
||||
r = np.clip(np.sqrt(nx * nx + ny * ny), 0.0, 1.0)
|
||||
|
||||
# Wind chop. Not concentric rings — a puddle ringed like a dartboard reads as
|
||||
# a target. But three crossed sines don't work either: at similar frequencies
|
||||
# they interfere into a regular lattice and the pond reads as basketweave.
|
||||
# Seven waves, directions spaced by the golden angle and frequencies in a
|
||||
# non-harmonic ratio, so nothing lines up and the surface stays irregular the
|
||||
# way real chop is. Free choice here — this decal is radial, never tiled, so
|
||||
# unlike the normal map it owes nothing to seamlessness.
|
||||
chop = np.zeros((SIZE, SIZE), dtype=np.float32)
|
||||
rw = rng_for("pond_chop")
|
||||
total = 0.0
|
||||
for i in range(7):
|
||||
ang = i * 2.39996 # golden angle: maximally non-repeating
|
||||
freq = 5.0 * (1.37 ** i) # non-harmonic progression
|
||||
amp = 1.0 / (1.0 + i * 0.8)
|
||||
chop += amp * np.sin((nx * math.cos(ang) + ny * math.sin(ang)) * freq * math.pi
|
||||
+ rw.uniform(0, math.tau))
|
||||
total += amp
|
||||
chop = np.clip(0.5 + 0.5 * chop / total, 0.0, 1.0)
|
||||
|
||||
depth = np.clip(1.0 - r, 0.0, 1.0) ** 0.7
|
||||
water = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
|
||||
for i in range(3):
|
||||
base = WATER_SHALLOW[i] + (WATER_DEEP[i] - WATER_SHALLOW[i]) * depth
|
||||
water[:, :, i] = np.clip(base * (0.86 + 0.28 * chop), 0.0, 1.0)
|
||||
# Feather the last quarter of the radius: a hard edge would read as a decal.
|
||||
a = np.clip((1.0 - r) / 0.25, 0.0, 1.0)
|
||||
water[:, :, 3] = (a * a * (3.0 - 2.0 * a)).astype(np.float32) # smoothstep
|
||||
p1, kb1 = save_png(water, "pond_water")
|
||||
print(f" pond_water.png {SIZE}x{SIZE}, radial feather, {kb1} KB")
|
||||
|
||||
# --- ripple normals, seamless -----------------------------------------
|
||||
def height(px, py):
|
||||
h = np.zeros_like(px, dtype=np.float32)
|
||||
# Integer cycles across the tile = exact wrap, same trick as the weave.
|
||||
# Six of them rather than three, on deliberately unrelated (kx, ky) pairs:
|
||||
# too few waves and they beat into a visible lattice, same failure the
|
||||
# albedo chop had. Integer pairs are the only constraint seamlessness puts
|
||||
# on this — which ones is free.
|
||||
for kx, ky, amp in ((3, 5, 1.0), (7, 2, 0.62), (11, 9, 0.36),
|
||||
(2, 13, 0.28), (13, 4, 0.20), (5, 11, 0.16)):
|
||||
h += amp * np.sin(2 * np.pi * (kx * px + ky * py) / SIZE)
|
||||
return h
|
||||
|
||||
Yn, Xn = np.mgrid[0:SIZE, 0:SIZE]
|
||||
e = 1.0
|
||||
dhdx = (height(Xn + e, Yn) - height(Xn - e, Yn)) / (2 * e)
|
||||
dhdy = (height(Xn, Yn + e) - height(Xn, Yn - e)) / (2 * e)
|
||||
strength = 6.0
|
||||
nxv, nyv, nzv = -dhdx * strength, -dhdy * strength, np.ones_like(dhdx)
|
||||
ln = np.sqrt(nxv * nxv + nyv * nyv + nzv * nzv)
|
||||
normal = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
|
||||
normal[:, :, 0] = (nxv / ln) * 0.5 + 0.5
|
||||
normal[:, :, 1] = (nyv / ln) * 0.5 + 0.5
|
||||
normal[:, :, 2] = (nzv / ln) * 0.5 + 0.5
|
||||
normal[:, :, 3] = 1.0
|
||||
|
||||
# Same guard as the weave: B is told to RepeatWrapping this, and a bad wrap
|
||||
# is a visible seam gridded across the pond.
|
||||
if not np.allclose(height(Xn, Yn), height(Xn + SIZE, Yn), atol=1e-4):
|
||||
raise AssertionError("pond_normal does not tile on X")
|
||||
if not np.allclose(height(Xn, Yn), height(Xn, Yn + SIZE), atol=1e-4):
|
||||
raise AssertionError("pond_normal does not tile on Y")
|
||||
|
||||
p2, kb2 = save_png(normal, "pond_normal")
|
||||
print(f" pond_normal.png {SIZE}x{SIZE}, seamless ripples, {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."""
|
||||
@ -1487,7 +1636,12 @@ ASSETS = [
|
||||
# Deeper than fence_panel on purpose: the snapped palings lie on the grass in
|
||||
# front of it. Bounded so wreckage on a boundary fence can't reach through
|
||||
# whatever is behind it.
|
||||
dict(name="fence_panel_broken", fn=build_fence_panel_broken,
|
||||
# Wider than the 0.30 head: the bristles splay past it, which is what a worn
|
||||
# broom does. A real yard broom is 0.30–0.45 m across.
|
||||
dict(name="broom_01", fn=build_broom_01,
|
||||
dims=((0.28, 0.45), (0.04, 0.12), (1.35, 1.50)),
|
||||
nodes=["handle", "head", "bristles", "grip_anchor", "poke_tip"]),
|
||||
dict(name="fence_panel_snapped", fn=build_fence_panel_snapped,
|
||||
dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)),
|
||||
nodes=["palings", "rails", "debris_palings"]),
|
||||
]
|
||||
@ -1815,6 +1969,7 @@ def main():
|
||||
reset_to_empty()
|
||||
build_grass_atlas()
|
||||
build_sail_textures()
|
||||
build_pond_textures()
|
||||
debris = [] if no_debris else copy_debris()
|
||||
|
||||
failures = []
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 193 KiB |
@ -20,7 +20,7 @@
|
||||
"powBase": 3,
|
||||
"powRand": 5,
|
||||
"powRamp": 7,
|
||||
"downdraftOfTotal": 0.12
|
||||
"downdraftOfTotal": 0.45
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
||||
@ -42,6 +42,17 @@
|
||||
|
||||
"rain": { "peakMmPerHour": 80, "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
|
||||
|
||||
"_hail_comment": "Hail carries the garden score (decision 13) — steep dense stones a sail actually blocks, unlike rain which walks under it. The authored burst lands ON the southerly change (t55) for maximum drama; withGustsAbove 10 then adds a burst to every late big gust (gusts top ~12.6, so the worst few carry ice). size 1.3 = decent damaging stones. A garden under a good rig should be MUCH better off than one in the open — asserted at >=2x.",
|
||||
|
||||
"hail": {
|
||||
"size": 1.3,
|
||||
"withGustsAbove": 10,
|
||||
"gustBurstIntensity": 0.85,
|
||||
"bursts": [
|
||||
{ "t": 55, "ramp": 1.5, "hold": 5, "fade": 2.5, "intensity": 1.0 }
|
||||
]
|
||||
},
|
||||
|
||||
"_sky_comment": "night: true forces the night palette rather than leaning on the darkness threshold — it's called Wild Night and the forecast card has to sell that. lightningGustPow 10 fires a flash on any gust at/above 10 m/s of gust power (this storm's gusts top out ~12.6, so it lights up for the worst few, late, on top of the three authored strikes) — the storm's worst moments should be the ones you see.",
|
||||
|
||||
"sky": { "darkness": 0.94, "cloudScroll": 0.09, "night": true, "lightningGustPow": 10 }
|
||||
|
||||
@ -36,5 +36,14 @@
|
||||
|
||||
"rain": { "peakMmPerHour": 30, "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] },
|
||||
|
||||
"_hail_comment": "One mild burst rolling in behind the change — small stones, half intensity. Enough that a bed under a sail visibly beats a bed in the open, but nothing like the wild night's ice. No gust-synced hail: this is the storm you can get away with a modest rig on. size 0.7 = pea hail.",
|
||||
|
||||
"hail": {
|
||||
"size": 0.7,
|
||||
"bursts": [
|
||||
{ "t": 36, "ramp": 1.5, "hold": 3, "fade": 2, "intensity": 0.5 }
|
||||
]
|
||||
},
|
||||
|
||||
"sky": { "darkness": 0.5, "cloudScroll": 0.05 }
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SHADES — yard (M0)</title>
|
||||
<title>SHADES — rig it, then survive the night</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #9fc4dd; }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
@ -28,7 +28,6 @@
|
||||
<canvas id="c"></canvas>
|
||||
<div id="banner"></div>
|
||||
<div id="dev">booting…</div>
|
||||
<div id="help">WASD move · shift run · RMB drag orbit · wheel zoom · Enter next phase</div>
|
||||
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "./vendor/three.module.js",
|
||||
|
||||
392
web/world/js/hud.js
Normal file
392
web/world/js/hud.js
Normal file
@ -0,0 +1,392 @@
|
||||
/**
|
||||
* SHADES — the face. Lane A owns this file.
|
||||
*
|
||||
* Everything the game already simulates is invisible to a stranger without this:
|
||||
* loads, the gust warning, whether the garden is actually getting hit. So the
|
||||
* rule here is that the HUD only ever READS. It takes no decisions, owns no
|
||||
* state worth keeping, and if you deleted it the sim would run identically —
|
||||
* which is also why it can be this chatty without anything drifting.
|
||||
*
|
||||
* Two things earn their place as world-anchored rather than screen-anchored:
|
||||
* the corner load bars (a number in a corner of the screen can't tell you WHICH
|
||||
* shackle is about to go, and that's the whole "…the shackle, I knew about the
|
||||
* shackle" moment) and the repair prompt, which Lane D already owns.
|
||||
*/
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { STORM_LEN } from './contracts.js';
|
||||
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
const kmh = (ms) => ms * 3.6;
|
||||
|
||||
// Load bar colours: green → amber → red. The amber band is deliberately wide;
|
||||
// a corner at 60% in a lull is one gust from 100% and the player should feel
|
||||
// that before the bar turns red on them.
|
||||
const BAR_OK = 0x6ee06e, BAR_WARN = 0xffc24a, BAR_HOT = 0xff5b4a, BAR_DEAD = 0x7a2f2f;
|
||||
|
||||
const CSS = `
|
||||
#hud { position:fixed; inset:0; pointer-events:none; z-index:10;
|
||||
font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea;
|
||||
text-shadow:0 1px 3px #000a; user-select:none; }
|
||||
#hud .panel { position:absolute; background:#0d1418d9; border:1px solid #2c3a44;
|
||||
border-radius:6px; padding:8px 10px; }
|
||||
#hud-top { top:10px; left:50%; transform:translateX(-50%); text-align:center; min-width:280px; }
|
||||
#hud-wind { font-size:15px; font-weight:700; letter-spacing:.04em; }
|
||||
#hud-clock { color:#8ba0ad; }
|
||||
#hud-gust { position:absolute; top:78px; left:50%; transform:translateX(-50%);
|
||||
font:700 20px/1 ui-monospace,Menlo,monospace; letter-spacing:.16em; color:#ffd27a;
|
||||
background:#3a2a0ce0; border:1px solid #7a5a1a; border-radius:5px; padding:8px 16px;
|
||||
opacity:0; transition:opacity .12s; }
|
||||
#hud-gust.on { opacity:1; }
|
||||
#hud-garden { bottom:12px; left:12px; min-width:210px; }
|
||||
#hud-bar { height:9px; background:#00000066; border-radius:5px; overflow:hidden; margin-top:5px; }
|
||||
#hud-bar i { display:block; height:100%; width:100%; background:#6ee06e; transition:width .2s,background .3s; }
|
||||
#hud-carry { bottom:12px; right:12px; text-align:right; }
|
||||
#hud-events { position:absolute; bottom:70px; left:12px; max-width:420px; }
|
||||
#hud-events div { color:#ffd27a; margin-top:2px; }
|
||||
#hud-help { position:absolute; bottom:12px; left:50%; transform:translateX(-50%);
|
||||
color:#93a6b2; opacity:.85; white-space:nowrap; }
|
||||
|
||||
#hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center;
|
||||
background:#060a0dc4; backdrop-filter:blur(3px);
|
||||
font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; }
|
||||
#hud-card.on { display:grid; }
|
||||
#hud-card .card { background:#0d1418; border:1px solid #2f3f4a; border-radius:9px;
|
||||
padding:22px 26px; min-width:440px; max-width:620px; pointer-events:auto; }
|
||||
#hud-card h1 { margin:0 0 2px; font-size:19px; letter-spacing:.16em; color:#fff; }
|
||||
#hud-card h2 { margin:0 0 16px; font-size:12px; font-weight:400; color:#8ba0ad; letter-spacing:.04em; }
|
||||
#hud-card .row { display:flex; justify-content:space-between; gap:18px; padding:3px 0;
|
||||
border-bottom:1px solid #1c262d; }
|
||||
#hud-card .row b { font-weight:700; color:#fff; }
|
||||
#hud-card .storm { display:block; width:100%; text-align:left; margin:7px 0; padding:10px 12px;
|
||||
background:#121c23; border:1px solid #2f3f4a; border-radius:6px; color:#dde5ea; cursor:pointer;
|
||||
font:inherit; transition:border-color .15s,background .15s; }
|
||||
#hud-card .storm:hover { border-color:#7ee0ff; background:#16242c; }
|
||||
#hud-card .storm .name { font-weight:700; color:#fff; letter-spacing:.08em; }
|
||||
#hud-card .storm .stat { color:#8ba0ad; }
|
||||
#hud-card .go { margin-top:16px; padding:9px 18px; background:#1d3d2a; border:1px solid #3f7a52;
|
||||
border-radius:6px; color:#a8f0b8; cursor:pointer; font:inherit; font-weight:700; letter-spacing:.1em; }
|
||||
#hud-card .go:hover { background:#255033; }
|
||||
#hud-card .verdict { margin:14px 0 0; padding:10px 12px; border-radius:6px; font-weight:700;
|
||||
letter-spacing:.05em; }
|
||||
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
|
||||
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* @param {object} d
|
||||
* @param {THREE.Scene} d.scene
|
||||
* @param {THREE.Camera} d.camera
|
||||
* @param {object} d.game the phase machine
|
||||
* @param {object} d.world
|
||||
* @param {object} d.wind the router
|
||||
* @param {object} d.player
|
||||
* @param {object} d.rig SailRig
|
||||
* @param {object} d.garden {hp}
|
||||
* @param {() => object} d.getSky skyfx is rebuilt per storm, so read it late
|
||||
* @param {() => number} d.getWindTime
|
||||
* @param {{text:string,t:number}[]} d.events
|
||||
*/
|
||||
export function createHud(d) {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.id = 'hud';
|
||||
root.innerHTML = `
|
||||
<div class="panel" id="hud-top">
|
||||
<div id="hud-wind">--</div>
|
||||
<div id="hud-clock">--</div>
|
||||
</div>
|
||||
<div id="hud-gust">GUST INCOMING</div>
|
||||
<div class="panel" id="hud-garden">
|
||||
<div><span id="hud-garden-label">GARDEN</span> <b id="hud-garden-pct">100%</b></div>
|
||||
<div id="hud-bar"><i></i></div>
|
||||
<div id="hud-shade" style="color:#8ba0ad"></div>
|
||||
</div>
|
||||
<div class="panel" id="hud-carry" style="display:none"></div>
|
||||
<div id="hud-events"></div>
|
||||
<div id="hud-help"></div>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.id = 'hud-card';
|
||||
document.body.appendChild(card);
|
||||
|
||||
const $ = (id) => root.querySelector(id);
|
||||
const elWind = $('#hud-wind'), elClock = $('#hud-clock'), elGust = $('#hud-gust');
|
||||
const elPct = $('#hud-garden-pct'), elBar = $('#hud-bar i'), elShade = $('#hud-shade');
|
||||
const elGardenLabel = $('#hud-garden-label');
|
||||
const elCarry = $('#hud-carry'), elEvents = $('#hud-events'), elHelp = $('#hud-help');
|
||||
|
||||
// --- world-anchored corner load bars ------------------------------------
|
||||
// A bar per corner, floating at the corner it describes. Geometry rather than
|
||||
// a redrawn canvas: the fill is a scaled quad, so animating it is free and a
|
||||
// flogging corner's bar can track it at 60 Hz without touching a texture.
|
||||
const barGroup = new THREE.Group();
|
||||
barGroup.visible = false;
|
||||
d.scene.add(barGroup);
|
||||
|
||||
const quad = new THREE.PlaneGeometry(1, 1);
|
||||
quad.translate(0.5, 0, 0); // pivot on the left edge, so scale.x grows rightward
|
||||
const BAR_W = 0.9, BAR_H = 0.1;
|
||||
|
||||
const bars = [];
|
||||
function ensureBars(n) {
|
||||
while (bars.length < n) {
|
||||
const holder = new THREE.Group();
|
||||
const bg = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
|
||||
color: 0x0a1016, transparent: true, opacity: 0.75, depthTest: false,
|
||||
}));
|
||||
bg.scale.set(BAR_W, BAR_H, 1);
|
||||
bg.position.x = -BAR_W / 2;
|
||||
const fill = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
|
||||
color: BAR_OK, depthTest: false,
|
||||
}));
|
||||
fill.position.x = -BAR_W / 2;
|
||||
fill.scale.set(0.001, BAR_H * 0.72, 1);
|
||||
const label = makeLabel();
|
||||
label.position.y = 0.19;
|
||||
holder.add(bg, fill, label);
|
||||
holder.renderOrder = 999;
|
||||
barGroup.add(holder);
|
||||
bars.push({ holder, fill, label, lastText: '' });
|
||||
}
|
||||
for (let i = 0; i < bars.length; i++) bars[i].holder.visible = i < n;
|
||||
}
|
||||
|
||||
function makeLabel() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 256; c.height = 64;
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
|
||||
sp.scale.set(1.5, 0.375, 1);
|
||||
sp.userData.ctx = c.getContext('2d');
|
||||
sp.userData.tex = tex;
|
||||
return sp;
|
||||
}
|
||||
|
||||
function drawLabel(sprite, text, color) {
|
||||
const g = sprite.userData.ctx;
|
||||
g.clearRect(0, 0, 256, 64);
|
||||
g.font = 'bold 30px ui-monospace, Menlo, monospace';
|
||||
g.textAlign = 'center';
|
||||
g.textBaseline = 'middle';
|
||||
g.lineWidth = 6;
|
||||
g.strokeStyle = '#0a1016';
|
||||
g.strokeText(text, 128, 32);
|
||||
g.fillStyle = color;
|
||||
g.fillText(text, 128, 32);
|
||||
sprite.userData.tex.needsUpdate = true;
|
||||
}
|
||||
|
||||
// Labels are the only per-frame texture cost here, so they redraw on a slow
|
||||
// tick and only when the shown value actually changes.
|
||||
let labelT = 0;
|
||||
|
||||
const _p = new THREE.Vector3();
|
||||
const _q = new THREE.Quaternion();
|
||||
const _cam = new THREE.Vector3();
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
const barColor = (frac, broken) => (broken ? BAR_DEAD : frac > 0.85 ? BAR_HOT : frac > 0.55 ? BAR_WARN : BAR_OK);
|
||||
|
||||
function cornerPos(i) {
|
||||
if (d.rig.cornerPos) return d.rig.cornerPos(i);
|
||||
const c = d.rig.corners[i];
|
||||
return c?.anchor?.pos ?? null;
|
||||
}
|
||||
|
||||
const hud = {
|
||||
/** Set false while a card is up — the storm HUD shouldn't peer through it. */
|
||||
setVisible(on) { root.style.display = on ? '' : 'none'; },
|
||||
|
||||
setHelp(text) { elHelp.textContent = text; },
|
||||
|
||||
/**
|
||||
* @param {number} dt
|
||||
* @param {number} t wind/storm time
|
||||
*/
|
||||
update(dt, t) {
|
||||
const phase = d.game.phase;
|
||||
const storm = phase === 'storm';
|
||||
|
||||
// --- wind + clock ---
|
||||
const speed = d.wind.speedAt(d.player.pos, t);
|
||||
elWind.textContent = `${speed.toFixed(1)} m/s ${kmh(speed).toFixed(0)} km/h`;
|
||||
elWind.style.color = speed > 25 ? '#ff8f86' : speed > 15 ? '#ffc24a' : '#dde5ea';
|
||||
elClock.textContent = storm
|
||||
? `STORM ${Math.max(0, STORM_LEN - d.game.phaseT).toFixed(0)}s left`
|
||||
: phase.toUpperCase();
|
||||
|
||||
// --- gust telegraph ---
|
||||
// The contract promises >=1.2 s of warning; this is where the player
|
||||
// actually gets to spend it.
|
||||
const tel = storm ? d.wind.gustTelegraph(t) : null;
|
||||
elGust.classList.toggle('on', !!tel);
|
||||
if (tel) elGust.textContent = `GUST INCOMING ${tel.eta.toFixed(1)}s · ${kmh(tel.power).toFixed(0)} km/h`;
|
||||
|
||||
// --- garden ---
|
||||
const hp = d.garden.hp;
|
||||
elPct.textContent = `${hp.toFixed(0)}%`;
|
||||
elBar.style.width = `${clamp01(hp / 100) * 100}%`;
|
||||
elBar.style.background = hp > 66 ? '#6ee06e' : hp > 33 ? '#ffc24a' : '#ff5b4a';
|
||||
// Decision 7: rain shadow is what matters at night, sun coverage by day.
|
||||
// Showing whichever is load-bearing right now stops the number being a
|
||||
// fact about nothing.
|
||||
const sky = d.getSky?.();
|
||||
if (storm && sky?.rainShadowOver) {
|
||||
const dry = sky.rainShadowOver(d.world.gardenBed);
|
||||
elGardenLabel.textContent = 'GARDEN';
|
||||
elShade.textContent = `${(dry * 100).toFixed(0)}% kept dry by the sail`;
|
||||
} else {
|
||||
const shade = d.rig.rigged ? d.rig.coverageOver(d.world.gardenBed, d.world.sunDir, d.world.heightAt) : 0;
|
||||
elGardenLabel.textContent = 'GARDEN';
|
||||
elShade.textContent = d.rig.rigged ? `${(shade * 100).toFixed(0)}% in shade` : 'no sail rigged';
|
||||
}
|
||||
|
||||
// --- carried item ---
|
||||
const carrying = d.player.carrying;
|
||||
elCarry.style.display = carrying ? '' : 'none';
|
||||
if (carrying) elCarry.textContent = `carrying: ${carrying}`;
|
||||
|
||||
// --- event ticker ---
|
||||
const live = d.events.filter((e) => t - e.t < 6 || e.t > t);
|
||||
elEvents.innerHTML = live.slice(-4).map((e) => `<div>${e.text}</div>`).join('');
|
||||
|
||||
// --- corner bars ---
|
||||
barGroup.visible = d.rig.rigged && (storm || phase === 'prep');
|
||||
if (!barGroup.visible) return;
|
||||
|
||||
ensureBars(d.rig.corners.length);
|
||||
labelT += dt;
|
||||
const redraw = labelT >= 0.12;
|
||||
if (redraw) labelT = 0;
|
||||
d.camera.getWorldQuaternion(_q);
|
||||
d.camera.getWorldPosition(_cam);
|
||||
|
||||
for (let i = 0; i < d.rig.corners.length; i++) {
|
||||
const c = d.rig.corners[i];
|
||||
const b = bars[i];
|
||||
const p = cornerPos(i);
|
||||
if (!p) { b.holder.visible = false; continue; }
|
||||
|
||||
_p.set(p.x, p.y, p.z);
|
||||
b.holder.position.copy(_p);
|
||||
b.holder.position.y += 0.45;
|
||||
b.holder.quaternion.copy(_q);
|
||||
|
||||
// Hold roughly constant screen size. A house corner is ~18 m away when
|
||||
// you're standing at the posts, and at that range a world-sized bar is
|
||||
// a few unreadable pixels — which defeats the entire point of putting it
|
||||
// out there rather than in a corner of the screen. Clamped so it doesn't
|
||||
// balloon when you walk right up to a corner to repair it.
|
||||
const dist = _p.distanceTo(_cam);
|
||||
b.holder.scale.setScalar(Math.max(0.75, Math.min(3.2, dist / 7)));
|
||||
|
||||
const frac = clamp01((c.load || 0) / c.hw.rating);
|
||||
b.fill.scale.x = Math.max(0.001, (c.broken ? 1 : frac) * BAR_W);
|
||||
b.fill.material.color.setHex(barColor(frac, c.broken));
|
||||
|
||||
if (redraw) {
|
||||
const text = c.broken
|
||||
? `${c.anchorId.toUpperCase()} BLOWN`
|
||||
: `${c.anchorId.toUpperCase()} ${((c.load || 0) / 1000).toFixed(1)}/${(c.hw.rating / 1000).toFixed(1)}kN`;
|
||||
if (text !== b.lastText) {
|
||||
b.lastText = text;
|
||||
drawLabel(b.label, text, c.broken ? '#ff8f86' : frac > 0.85 ? '#ffb1ab' : '#dde5ea');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// --- cards ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The forecast. Its job is to sell the dread and, incidentally, to be the
|
||||
* difficulty select for free — three authored storms already bracket the
|
||||
* range, so letting the player pick one IS the choice.
|
||||
*
|
||||
* @param {{key:string, def:object}[]} storms
|
||||
* @param {(key:string) => void} onPick
|
||||
*/
|
||||
showForecast(storms, onPick) {
|
||||
const rows = storms.map(({ key, def }) => {
|
||||
const peak = Math.max(...def.baseCurve.map((p) => p[1]));
|
||||
const gustPeak = peak + (def.gusts?.powBase ?? 0) + (def.gusts?.powRamp ?? 0);
|
||||
const rainPeak = Math.max(...(def.rain?.curve ?? [[0, 0]]).map((p) => p[1]));
|
||||
const change = (def.events ?? []).find((e) => e.type === 'windchange');
|
||||
const night = (def.sky?.darkness ?? 0) > 0.6;
|
||||
return `<button class="storm" data-key="${key}">
|
||||
<div class="name">${(def.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</div>
|
||||
<div class="stat">sustained to ${peak.toFixed(0)} m/s (${kmh(peak).toFixed(0)} km/h)
|
||||
· gusts to ~${kmh(gustPeak).toFixed(0)} km/h</div>
|
||||
<div class="stat">rain ${rainPeak >= 0.8 ? 'heavy' : rainPeak >= 0.4 ? 'steady' : 'light'}
|
||||
${change ? `· southerly change at ${change.t}s` : '· no change forecast'}</div>
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
card.innerHTML = `<div class="card">
|
||||
<h1>FORECAST</h1>
|
||||
<h2>90 seconds of storm. Pick your night, then rig for it.</h2>
|
||||
${rows}
|
||||
<div class="stat" style="color:#8ba0ad;margin-top:12px">
|
||||
A change means the corners that were slack all storm are the loaded ones after it.
|
||||
</div>
|
||||
</div>`;
|
||||
card.classList.add('on');
|
||||
hud.setVisible(false);
|
||||
for (const b of card.querySelectorAll('.storm')) {
|
||||
b.addEventListener('click', () => { hud.hideCard(); onPick(b.dataset.key); });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {object} r {hp, cornersLost, cornersTotal, bill, collateral, budgetLeft, win}
|
||||
* @param {() => void} onAgain
|
||||
*/
|
||||
showAftermath(r, onAgain) {
|
||||
const rows = [
|
||||
['garden', `${r.hp.toFixed(0)}%`],
|
||||
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
|
||||
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
|
||||
['collateral', r.collateral.length ? r.collateral.map((c) => `${c.what} ($${c.cost})`).join(', ') : 'none'],
|
||||
['budget left', `$${r.budgetLeft}`],
|
||||
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
|
||||
|
||||
card.innerHTML = `<div class="card">
|
||||
<h1>AFTERMATH</h1>
|
||||
<h2>${r.subtitle}</h2>
|
||||
${rows}
|
||||
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
|
||||
<button class="go">PLAY AGAIN</button>
|
||||
</div>`;
|
||||
card.classList.add('on');
|
||||
hud.setVisible(false);
|
||||
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onAgain(); });
|
||||
},
|
||||
|
||||
hideCard() {
|
||||
card.classList.remove('on');
|
||||
card.innerHTML = '';
|
||||
hud.setVisible(true);
|
||||
},
|
||||
|
||||
get cardOpen() { return card.classList.contains('on'); },
|
||||
|
||||
dispose() {
|
||||
root.remove(); card.remove(); style.remove();
|
||||
d.scene.remove(barGroup);
|
||||
quad.dispose();
|
||||
for (const b of bars) {
|
||||
b.fill.material.dispose();
|
||||
b.label.material.map?.dispose();
|
||||
b.label.material.dispose();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return hud;
|
||||
}
|
||||
@ -28,7 +28,13 @@ export class Interact {
|
||||
* @param {number} [spec.radius] metres
|
||||
* @param {number} [spec.holdSecs]
|
||||
* @param {string|function} [spec.label] string, or (player)->string for live text
|
||||
* @param {function} [spec.canUse] (player) -> bool
|
||||
* @param {function} [spec.canUse] (player) -> bool.
|
||||
* **Do not test `player.state` in here.** canUse is re-checked every frame to KEEP a hold alive,
|
||||
* and starting a hold moves the player into `busy` — so `canUse: p => p.state === 'idle'` goes
|
||||
* false on frame one and the action silently cancels its own hold. It looks exactly like a dead
|
||||
* prompt. Gate on physical facts instead (carrying, position, height); locked states already
|
||||
* can't start a hold, because step() checks `!player.busy` first. This bit twice: the ladder's
|
||||
* climb and the fascia reach gate.
|
||||
* @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.
|
||||
@ -149,14 +155,35 @@ export class Interact {
|
||||
*/
|
||||
export function wireYardActions(interact, deps = {}) {
|
||||
const { sailRig, world } = deps;
|
||||
// createLadder publishes itself onto the Interact instance, so main.js doesn't have to thread a
|
||||
// ladder through to get the fascia reach gate. An explicit dep still wins (tests pass one).
|
||||
const ladder = deps.ladder || interact.ladder || null;
|
||||
const wired = [];
|
||||
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null;
|
||||
const anchorOf = (i) => {
|
||||
const c = cornerAt(i);
|
||||
if (!c) return null;
|
||||
return c.anchor || (world && world.anchors && world.anchors.find((a) => a.id === c.anchorId)) || 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;
|
||||
// A fascia corner is worked AT THE BRACKET, not at the cloth: the corner has detached and the
|
||||
// sail is hanging down somewhere, but re-attaching it means getting a shackle onto a fitting
|
||||
// 2.48 m up a wall. So the prompt lives at the anchor and you need the ladder to hold it.
|
||||
if (ladder && ladder.needsLadder(anchorOf(i))) {
|
||||
const a = anchorOf(i);
|
||||
return { x: a.pos.x, y: a.pos.y, z: a.pos.z + 0.9 };
|
||||
}
|
||||
return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null;
|
||||
};
|
||||
/** Fascia work needs you up the ladder that's planted under THAT bracket. */
|
||||
const canReach = (i, p) => {
|
||||
const a = anchorOf(i);
|
||||
if (!ladder || !ladder.needsLadder(a)) return true; // everything else is ground work
|
||||
return ladder.isWorking(a.id) && p.reachY >= a.pos.y;
|
||||
};
|
||||
|
||||
if (sailRig && Array.isArray(sailRig.corners)) {
|
||||
sailRig.corners.forEach((_corner, i) => {
|
||||
@ -166,10 +193,10 @@ export function wireYardActions(interact, deps = {}) {
|
||||
pos: posAt(i),
|
||||
radius: 1.8,
|
||||
holdSecs: 2.5,
|
||||
label: 're-rig corner',
|
||||
label: (p) => (canReach(i, p) ? 're-rig corner' : 'out of reach — needs the ladder'),
|
||||
clip: 'Crank',
|
||||
canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken)
|
||||
&& p.carrying === 'spare' && !!sailRig.repair,
|
||||
&& p.carrying === 'spare' && !!sailRig.repair && canReach(i, p),
|
||||
onDone: (p) => { p.carrying = null; sailRig.repair(i); },
|
||||
}));
|
||||
// per-corner turnbuckle trim — new vs the prototype; makes corners individual
|
||||
@ -178,9 +205,9 @@ export function wireYardActions(interact, deps = {}) {
|
||||
pos: posAt(i),
|
||||
radius: 1.8,
|
||||
holdSecs: 1.2,
|
||||
label: 'tighten turnbuckle',
|
||||
label: (p) => (canReach(i, p) ? 'tighten turnbuckle' : 'out of reach — needs the ladder'),
|
||||
clip: 'Crank',
|
||||
canUse: () => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim,
|
||||
canUse: (p) => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim && canReach(i, p),
|
||||
onDone: () => sailRig.trim(i, +0.1),
|
||||
}));
|
||||
});
|
||||
|
||||
210
web/world/js/ladder.js
Normal file
210
web/world/js/ladder.js
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* ladder.js — the ladder sub-system. (Lane D, SPRINT4 decision 12)
|
||||
*
|
||||
* Why it exists: the house fascia brackets sit at y=2.48 and a 1.72 m person's hands reach 2.20.
|
||||
* Two hundred millimetres is the whole mechanic. Everything else in the yard you can rig from the
|
||||
* ground — a sail post is tensioned from a cleat at its base, a tree anchor is a strop you throw —
|
||||
* but a bracket bolted 2.5 m up a bare wall is not negotiable, and E's ladder tops out at exactly
|
||||
* 2.9 m. The asset and the yard were built for each other; this file is the verb between them.
|
||||
*
|
||||
* The loop it creates is the "limited hands" rule from DESIGN.md doing real work:
|
||||
* ladder and spare are BOTH carry items, and you can only hold one.
|
||||
* So a fascia repair costs two trips — fetch the ladder, plant it, go back for the spare —
|
||||
* while a post repair costs one. The house is the expensive anchor to depend on, which is
|
||||
* exactly what E's ratingHint 0.35 / collateral "gutter" is already telling you in the data.
|
||||
*
|
||||
* Ownership: Lane D. Self-wires from createPlayer() in player.js, so main.js (Lane A's file) needs
|
||||
* no change to get this — it already hands createPlayer the scene, world and interact.
|
||||
*/
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
|
||||
export const LADDER_URL = './models/ladder_01_v1.glb';
|
||||
|
||||
/**
|
||||
* Which anchors you cannot rig from the ground.
|
||||
*
|
||||
* Deliberately keyed on the anchor TYPE, not on a height test. A pure "is it above reach?" rule
|
||||
* would rope in the posts (3.95 m) and tree limbs (up to 5.05 m) and turn every single repair into
|
||||
* a two-trip ladder job — which is both untrue to how sails are actually rigged and would have
|
||||
* silently invalidated the recorded §7 run and Lane B's gate asserts. Decision 12 scopes this to
|
||||
* the fascia; this is that scope, in one line, where it can be found and argued with.
|
||||
*/
|
||||
export const needsLadder = (anchor) => !!anchor && anchor.type === 'house';
|
||||
|
||||
/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */
|
||||
const STAND_OFF = 0.9; // m clear of the wall face
|
||||
const PLACE_RANGE = 2.6; // m — how close you must be to a fascia anchor to plant the ladder
|
||||
const RUNG_CLEAR = 0.55; // m — feet this far below the top rung, so the fascia is at chest height
|
||||
|
||||
/**
|
||||
* @param {THREE.Object3D} scene
|
||||
* @param {object} world contracts World (must be dressed — anchors are final only after dress())
|
||||
* @param {object} interact Lane D's Interact
|
||||
* @param {object} player the PlayerSim
|
||||
* @returns {object} the ladder system
|
||||
*/
|
||||
export function createLadder(scene, world, interact, player) {
|
||||
const anchors = (world.anchors || []).filter(needsLadder);
|
||||
if (!anchors.length) {
|
||||
// no fascia in this yard (a bare harness, say) — the whole sub-system is moot, don't half-wire it
|
||||
return { placedAt: null, carried: false, needsLadder, isWorking: () => false,
|
||||
workY: () => 0, servedAnchor: () => null, update() {}, dispose() {} };
|
||||
}
|
||||
|
||||
const state = {
|
||||
carried: false, // in the player's hands
|
||||
placedAt: null, // anchor id, or null while stowed/carried
|
||||
base: new THREE.Vector3(),
|
||||
topY: 2.9, // overwritten from the GLB's ladder_top node
|
||||
view: null,
|
||||
};
|
||||
|
||||
// Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable
|
||||
// so it follows the shed if Lane A moves it. The offset is deliberately ~3 m: at 1.4 m the two
|
||||
// prompts overlapped and standing at the ladder offered you "take a spare", which is the kind of
|
||||
// thing that reads as a broken game rather than a crowded shed.
|
||||
const home = new THREE.Vector3(10.4, 0, 3.4);
|
||||
if (world.shedTable && world.shedTable.pos) {
|
||||
home.set(world.shedTable.pos.x + 1.4, 0, world.shedTable.pos.z - 2.6);
|
||||
}
|
||||
home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0;
|
||||
state.base.copy(home);
|
||||
|
||||
// --- view -----------------------------------------------------------------
|
||||
new GLTFLoader().load(LADDER_URL, (g) => {
|
||||
const obj = g.scene;
|
||||
const top = obj.getObjectByName('ladder_top');
|
||||
if (top) state.topY = top.position.y;
|
||||
obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
|
||||
state.view = obj;
|
||||
scene.add(obj);
|
||||
syncView();
|
||||
}, undefined, () => { /* missing asset: the mechanic still works, you just can't see it */ });
|
||||
|
||||
const LEAN = 0.26; // rad (~15°) — a ladder stood bolt upright reads as a post, not a ladder
|
||||
function syncView() {
|
||||
if (!state.view) return;
|
||||
state.view.visible = !state.carried;
|
||||
state.view.position.copy(state.base);
|
||||
const a = state.placedAt && world.anchors.find((x) => x.id === state.placedAt);
|
||||
if (a) {
|
||||
// planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the
|
||||
// top toward local +Z, which is the wall — so the feet stand off and the head rests on it.
|
||||
state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0);
|
||||
} else {
|
||||
state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed
|
||||
}
|
||||
}
|
||||
|
||||
/** The fascia anchor this ladder is currently serving, if any. */
|
||||
const servedAnchor = () => (state.placedAt ? world.anchors.find((a) => a.id === state.placedAt) : null);
|
||||
|
||||
/** Standing height at the top of the ladder — feet a rung or two down from the very top. */
|
||||
const workY = () => Math.max(0, state.topY - RUNG_CLEAR);
|
||||
|
||||
/**
|
||||
* True if the player is up THIS ladder and can work the given anchor.
|
||||
* Height only, deliberately — hold-E moves the player into `busy`, so testing for state 'atTop'
|
||||
* here would make a fascia repair un-usable the moment it started and cancel its own hold.
|
||||
*/
|
||||
function isWorking(anchorId) {
|
||||
return state.placedAt === anchorId && player.climbY > workY() - 0.15;
|
||||
}
|
||||
|
||||
// --- interactions ---------------------------------------------------------
|
||||
const wired = [];
|
||||
|
||||
// 1. pick the ladder up (from its home, or from wherever it's planted)
|
||||
wired.push(interact.register({
|
||||
id: 'ladder_take',
|
||||
pos: () => (state.carried ? null : state.base),
|
||||
radius: 1.6,
|
||||
holdSecs: 0.8,
|
||||
clip: 'PickUp',
|
||||
label: (p) => (p.carrying ? 'hands full' : state.placedAt ? 'take the ladder back' : 'take the ladder'),
|
||||
canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02,
|
||||
onDone: (p, t) => {
|
||||
state.carried = true;
|
||||
state.placedAt = null;
|
||||
p.pickUp('ladder', t);
|
||||
syncView();
|
||||
},
|
||||
}));
|
||||
|
||||
// 2. plant it at a fascia anchor
|
||||
for (const a of anchors) {
|
||||
wired.push(interact.register({
|
||||
id: `ladder_place_${a.id}`,
|
||||
// stand off the wall, on the yard side — the ladder leans in toward the bracket
|
||||
pos: () => ({ x: a.pos.x, y: 0, z: a.pos.z + STAND_OFF }),
|
||||
radius: PLACE_RANGE,
|
||||
holdSecs: 1.0,
|
||||
clip: 'PickUp',
|
||||
label: `set the ladder under ${a.id}`,
|
||||
canUse: (p) => p.carrying === 'ladder',
|
||||
onDone: (p, t) => {
|
||||
state.carried = false;
|
||||
state.placedAt = a.id;
|
||||
state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0,
|
||||
a.pos.z + STAND_OFF);
|
||||
p.carrying = null;
|
||||
p.events.push({ type: 'ladderPlaced', anchorId: a.id, t });
|
||||
syncView();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. climb it — only when it's planted, and only from the ground
|
||||
wired.push(interact.register({
|
||||
id: 'ladder_climb',
|
||||
pos: () => (state.placedAt && !state.carried ? state.base : null),
|
||||
radius: 1.5,
|
||||
holdSecs: 0.4,
|
||||
clip: 'PickUp',
|
||||
label: 'climb',
|
||||
// NB: no test on p.state here. Starting a hold moves the player into `busy`, so a canUse that
|
||||
// reads state goes false the instant the hold begins and cancels itself. climbY is the honest
|
||||
// gate (are you on the ground?), and locked states can't start a hold anyway.
|
||||
canUse: (p) => !!state.placedAt && !state.carried && p.climbY < 0.02,
|
||||
onDone: (p, t) => {
|
||||
p.pos.x = state.base.x; p.pos.z = state.base.z; // step onto the rungs
|
||||
const a = servedAnchor();
|
||||
if (a) p.facing = Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z);
|
||||
p.climbTo(workY(), t);
|
||||
},
|
||||
}));
|
||||
|
||||
const api = {
|
||||
get placedAt() { return state.placedAt; },
|
||||
get carried() { return state.carried; },
|
||||
get base() { return state.base; },
|
||||
get topY() { return state.topY; },
|
||||
workY,
|
||||
isWorking,
|
||||
servedAnchor,
|
||||
needsLadder,
|
||||
|
||||
/**
|
||||
* Drive descent from input. Held S climbs down; nothing else can strand you up there, and a
|
||||
* knockdown already drops climbY to 0 on its own.
|
||||
* player.js calls this each frame from the same input it reads for movement.
|
||||
*/
|
||||
update(dt, t, input) {
|
||||
if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t);
|
||||
syncView();
|
||||
},
|
||||
|
||||
dispose() {
|
||||
wired.forEach((un) => un());
|
||||
if (interact.ladder === api) interact.ladder = null;
|
||||
if (state.view) scene.remove(state.view);
|
||||
},
|
||||
};
|
||||
|
||||
// Publish onto the Interact instance so wireYardActions can find the reach gate without main.js
|
||||
// (Lane A's file) having to learn about ladders and thread it through. Interact is Lane D's own
|
||||
// class, so this stays inside the lane; an explicit `deps.ladder` still wins if anyone passes one.
|
||||
interact.ladder = api;
|
||||
return api;
|
||||
}
|
||||
@ -23,10 +23,65 @@ import { createPlayer } from './player.js';
|
||||
import { Interact, wireYardActions } from './interact.js';
|
||||
import { createDebris } from './debris.js';
|
||||
import { createSkyFx } from './skyfx.js';
|
||||
import { createRiggingUI } from './rigging.js';
|
||||
import { createHud } from './hud.js';
|
||||
|
||||
/** Which storm each phase runs under (SPRINT2 §Lane A.1). */
|
||||
/** The calm day the forecast and prep phases run under. */
|
||||
const CALM_STORM = 'storm_01_gentle';
|
||||
const WILD_STORM = 'storm_02_wildnight';
|
||||
|
||||
/** The storms you can pick from the forecast card — this is the difficulty select. */
|
||||
const STORMS = ['storm_01_gentle', 'storm_03_southerly', 'storm_02_wildnight'];
|
||||
|
||||
/**
|
||||
* How fast an unprotected garden dies, in HP per second at full rain.
|
||||
*
|
||||
* Decision 7: drain is rain × (1 − rain shadow), NOT sun coverage. At night the
|
||||
* sun shadow is a number about nothing, and storm_02 is a wildnight.
|
||||
*
|
||||
* ⚠️ This number is doing less work than it looks like it is, and the reason is
|
||||
* worth reading before retuning it. Measured over storm_02 with a rig that holds
|
||||
* 4/4 corners all night: sun coverage over the bed stays ~50%, but the RAIN
|
||||
* shadow decays 0.38 → 0.04 as the wind builds, averaging 0.23. Driving rain
|
||||
* blows under the sail and the shadow walks off the bed — which is Lane C's
|
||||
* model being right about weather, not a bug.
|
||||
*
|
||||
* The consequence is that a perfectly rigged bed only ever sits ~23% drier than
|
||||
* a bare one, so NO value here separates good rigging from none; at 1.6 both
|
||||
* ended dead, and the spread stays ~20 points at any setting. 0.9 is chosen to
|
||||
* put a good rig around 60% (a win) and a bare bed just under 50% (a loss), so
|
||||
* the loop is playable and scores something — but the lever that actually needs
|
||||
* moving is the shadow geometry, not this. Flagged for Lane C in THREADS.
|
||||
*/
|
||||
const GARDEN_DRAIN = 0.9;
|
||||
|
||||
/**
|
||||
* The garden: the thing you are actually protecting, and the only score that
|
||||
* matters. Deliberately not inside hud.js — the HUD reads, it doesn't decide.
|
||||
*/
|
||||
function createGarden(world) {
|
||||
let hp = 100;
|
||||
let state = 'full';
|
||||
return {
|
||||
get hp() { return hp; },
|
||||
get state() { return state; },
|
||||
reset() { hp = 100; state = 'full'; world.setPlants('full'); },
|
||||
/**
|
||||
* @param {number} dt
|
||||
* @param {number} exposure 0..1 — how hard the bed is being hit right now.
|
||||
* 0 = nothing reaching it (no weather, or cloth is over it); 1 = taking it
|
||||
* full in the open. Lane C's sky.gardenExposure() computes it.
|
||||
*
|
||||
* SPRINT5 decision 13 lands here and nowhere else: garden damage becomes
|
||||
* hail exposure + a small rain drain, so this stays one number and the
|
||||
* only change is which helper(s) feed it.
|
||||
*/
|
||||
step(dt, exposure) {
|
||||
if (exposure > 0) hp = Math.max(0, hp - GARDEN_DRAIN * exposure * dt);
|
||||
const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead';
|
||||
if (next !== state) { state = next; world.setPlants(next); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase machine
|
||||
@ -88,9 +143,21 @@ export function createGame() {
|
||||
* Shelters are applied to every storm rather than just the active one: they
|
||||
* describe the yard's trees, which don't stop existing when the weather turns.
|
||||
*
|
||||
* ⚠️ **This delegation list is hand-maintained, and that has already cost us
|
||||
* once.** When Lane C added rainMmPerHour/rainDepthMm for ponding, this router
|
||||
* didn't forward them: every test still passed, because tests hold a real wind
|
||||
* while only the GAME holds the router — so B's ponding would have been green
|
||||
* across the board and done nothing in the actual yard. The integrator caught it
|
||||
* by hand at merge.
|
||||
*
|
||||
* Anything new on the wind contract must be added here too. `js/tests/a.test.js`
|
||||
* has a tripwire that diffs this object against a real wind and fails naming
|
||||
* whatever is missing, so the next omission is a red test rather than a system
|
||||
* that silently isn't plugged in.
|
||||
*
|
||||
* @param {object[]} all every wind this session can switch between
|
||||
*/
|
||||
function createWindRouter(all) {
|
||||
export function createWindRouter(all) {
|
||||
let active = all[0];
|
||||
|
||||
const router = {
|
||||
@ -103,6 +170,9 @@ function createWindRouter(all) {
|
||||
gustTelegraph: (t) => active.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => active.eventsBetween(a, b),
|
||||
rainAt: (t) => active.rainAt(t),
|
||||
rainMmPerHour: (t) => active.rainMmPerHour(t),
|
||||
rainDepthMm: (a, b) => active.rainDepthMm(a, b),
|
||||
hailAt: (t) => active.hailAt(t), // SPRINT5 decision 13 — the garden score hangs off this
|
||||
dirAt: (t) => active.dirAt(t),
|
||||
|
||||
setShelters(list) {
|
||||
@ -119,6 +189,7 @@ function createWindRouter(all) {
|
||||
})));
|
||||
},
|
||||
|
||||
get hailSize() { return active.hailSize; }, // SPRINT5 decision 13
|
||||
get duration() { return active.duration; },
|
||||
get gusts() { return active.gusts; },
|
||||
get def() { return active.def; },
|
||||
@ -180,12 +251,15 @@ export async function boot(opts = {}) {
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
// --- 1. weather ---------------------------------------------------------
|
||||
// Both storms load up front: the forecast card needs to read storm_02's shape
|
||||
// before the player has agreed to face it.
|
||||
const [calmDef, wildDef] = await Promise.all([loadStorm(CALM_STORM), loadStorm(WILD_STORM)]);
|
||||
const calmWind = createWind(calmDef);
|
||||
const wildWind = createWind(wildDef);
|
||||
const wind = createWindRouter([calmWind, wildWind]);
|
||||
// Every storm loads up front: the forecast card has to read their shapes to
|
||||
// sell them before the player has agreed to face one.
|
||||
const defs = Object.fromEntries(
|
||||
await Promise.all(STORMS.map(async (k) => [k, await loadStorm(k)])),
|
||||
);
|
||||
const winds = Object.fromEntries(Object.entries(defs).map(([k, def]) => [k, createWind(def)]));
|
||||
const calmWind = winds[CALM_STORM];
|
||||
let stormKey = 'storm_02_wildnight';
|
||||
const wind = createWindRouter(Object.values(winds));
|
||||
|
||||
// --- world & camera -----------------------------------------------------
|
||||
const world = createWorld(scene, { wind });
|
||||
@ -239,9 +313,8 @@ export async function boot(opts = {}) {
|
||||
// repair. Deliberately the prototype's AUTO loadout — one dodgy carabiner
|
||||
// corner. It also spans most of the yard, which is the 70–192 m² problem
|
||||
// decision 2 fixes in step 6, not a fault in the cloth.
|
||||
await rigSail(['h1', 'h3', 'p2', 'p1'], [HARDWARE[2], HARDWARE[1], HARDWARE[1], HARDWARE[0]]);
|
||||
|
||||
const game = createGame();
|
||||
const garden = createGarden(world);
|
||||
|
||||
// --- clocks -------------------------------------------------------------
|
||||
// Two of them, and the distinction matters. `simT` is wall-clock seconds since
|
||||
@ -311,25 +384,134 @@ export async function boot(opts = {}) {
|
||||
addEventListener('pointerdown', unlock);
|
||||
addEventListener('keydown', unlock);
|
||||
|
||||
// --- dev overlay (temporary — hud.js replaces it in step 7) -------------
|
||||
const hud = document.getElementById('dev');
|
||||
// --- 5. the face --------------------------------------------------------
|
||||
const banner = document.getElementById('banner');
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') game.advance();
|
||||
const hud = createHud({
|
||||
scene, camera: cameraRig.object, game, world, wind, player, rig, garden, events,
|
||||
getSky: () => sky,
|
||||
});
|
||||
|
||||
// Lane B's picking adapter. `panel:false` — their built-in panel is a fine
|
||||
// bench, but hud.js owns the screen now, so the two shouldn't both draw.
|
||||
const rigging = await createRiggingUI({
|
||||
scene,
|
||||
camera: cameraRig.object,
|
||||
domElement: canvas,
|
||||
world,
|
||||
onCommit: (ids, hw, tension) => { void rigSail(ids, hw, tension); },
|
||||
onMessage: pushEvent,
|
||||
panel: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear last round's rig so "play again" is a new job, not a continuation.
|
||||
*
|
||||
* Without this you inherit the previous round's corners AND its spent budget —
|
||||
* a second round starts at $35 with three corners already hung, and the four
|
||||
* anchors you click get silently ignored because the session is already full.
|
||||
* That is not a subtle failure; it makes the second round unplayable.
|
||||
*
|
||||
* Done through the session's own public moves rather than by reaching into its
|
||||
* fields: unrig() refunds the corner's CURRENT hardware and setSpares(0)
|
||||
* refunds the spare, so the budget walks back to $80 on its own and the
|
||||
* economy stays the single source of truth. (Lane B: a `session.reset()` would
|
||||
* say this better than five lines of mine — asked in THREADS.)
|
||||
*/
|
||||
function resetRig() {
|
||||
const s = rigging.session;
|
||||
for (const p of [...s.picks]) s.unrig(p.anchorId);
|
||||
s.setSpares(0);
|
||||
s.setTension(1.0);
|
||||
}
|
||||
|
||||
/** What the storm cost, assembled at the moment it ends. */
|
||||
function scoreRun() {
|
||||
const lost = rig.corners.filter((c) => c.broken);
|
||||
const bill = lost.reduce((s, c) => s + c.hw.cost, 0);
|
||||
const collateral = [];
|
||||
// The gnome is collateral if the sail came down over it. DESIGN.md: the
|
||||
// worst debris in any storm is your own failed work.
|
||||
if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
|
||||
const s = rigging.summary;
|
||||
const hp = garden.hp;
|
||||
const win = hp >= 50 && lost.length < 2;
|
||||
return {
|
||||
hp,
|
||||
cornersLost: lost.length,
|
||||
cornersTotal: rig.corners.length || 4,
|
||||
bill,
|
||||
collateral,
|
||||
budgetLeft: Math.max(0, s.budget - bill - collateral.reduce((a, c) => a + c.cost, 0)),
|
||||
win,
|
||||
subtitle: lost.length
|
||||
? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
|
||||
: 'Every corner held.',
|
||||
verdict: hp >= 85 && !lost.length ? 'THE GARDEN MADE IT — not a leaf out of place.'
|
||||
: win ? 'THE GARDEN MADE IT. Mostly.'
|
||||
: hp < 50 ? 'THE GARDEN IS GONE. The rain found what you skimped on.'
|
||||
: 'THE SAIL LOST. Warranty callout in the rain for you.',
|
||||
};
|
||||
}
|
||||
|
||||
// --- phases -------------------------------------------------------------
|
||||
game.on('phaseChange', ({ to }) => {
|
||||
wind.use(to === 'storm' ? wildWind : calmWind);
|
||||
// Prep and forecast happen on the calm day; the storm you picked only
|
||||
// arrives when you say go.
|
||||
wind.use(to === 'storm' ? winds[stormKey] : calmWind);
|
||||
makeSky();
|
||||
events.length = 0;
|
||||
if (banner) {
|
||||
rigging.setActive(to === 'prep');
|
||||
|
||||
if (to === 'forecast') {
|
||||
garden.reset();
|
||||
resetRig();
|
||||
player.sim.carrying = null;
|
||||
hud.showForecast(
|
||||
STORMS.map((key) => ({ key, def: defs[key] })),
|
||||
(key) => { stormKey = key; game.setPhase('prep'); },
|
||||
);
|
||||
}
|
||||
// The card belongs to the phase, not to the button that happened to open it.
|
||||
// Tying its lifetime to the forecast button meant any other route into prep
|
||||
// (a debug jump, a future timer, "play again" landing somewhere new) left a
|
||||
// card floating over a live game, swallowing input.
|
||||
if (to === 'prep' || to === 'storm') hud.hideCard();
|
||||
|
||||
if (to === 'prep') {
|
||||
hud.setHelp('click an anchor to rig · click again to cycle hardware · shift-click to remove · [ ] tension · S spare · ENTER when you have four');
|
||||
}
|
||||
if (to === 'storm') {
|
||||
hud.setHelp('WASD move · shift run · E repair/pickup · C brace · RMB orbit');
|
||||
}
|
||||
if (to === 'aftermath') {
|
||||
hud.showAftermath(scoreRun(), () => game.setPhase('forecast'));
|
||||
}
|
||||
|
||||
if (banner && to !== 'forecast' && to !== 'aftermath') {
|
||||
banner.textContent = to.toUpperCase();
|
||||
banner.style.opacity = '1';
|
||||
setTimeout(() => { banner.style.opacity = '0'; }, 1400);
|
||||
}
|
||||
});
|
||||
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' || hud.cardOpen) return;
|
||||
// Leaving prep means committing the rig — Lane B's session refuses if it
|
||||
// isn't four corners, and says so in the ticker.
|
||||
if (game.phase === 'prep') {
|
||||
if (!rigging.commit()) return;
|
||||
player.sim.carrying = null;
|
||||
if (rigging.summary.spares > 0) pushEvent(`spare shackle on the shed table — grab it before you need it`);
|
||||
}
|
||||
game.advance();
|
||||
});
|
||||
|
||||
// Straight into the forecast: the card is the game's front door.
|
||||
hud.showForecast(
|
||||
STORMS.map((key) => ({ key, def: defs[key] })),
|
||||
(key) => { stormKey = key; game.setPhase('prep'); },
|
||||
);
|
||||
|
||||
// --- resize -------------------------------------------------------------
|
||||
function resize() {
|
||||
const w = canvas.clientWidth || innerWidth;
|
||||
@ -342,6 +524,7 @@ export async function boot(opts = {}) {
|
||||
|
||||
// --- loop ---------------------------------------------------------------
|
||||
const clock = new THREE.Clock();
|
||||
const dev = document.getElementById('dev');
|
||||
let frames = 0, fpsT = 0, fps = 0;
|
||||
|
||||
function step(dt) {
|
||||
@ -353,6 +536,18 @@ export async function boot(opts = {}) {
|
||||
rig.step(dt, wind, windT, debris);
|
||||
debris.step(dt, windT, { player: player.sim, sail: rig });
|
||||
sky?.step(dt, windT, { sail: rig });
|
||||
rigging.update(dt, windT);
|
||||
|
||||
// Decision 7: what hurts the garden is weather the sail didn't stop. Only
|
||||
// during the storm — the calm day's drizzle is scenery.
|
||||
//
|
||||
// Through Lane C's combined helper rather than my own rain × (1 − shadow):
|
||||
// it's the same arithmetic, it's their term to own, and SPRINT5 decision 13
|
||||
// extends exactly this shape (+ gardenHailExposure) — so when hail lands
|
||||
// this is one added term here, not a rewrite.
|
||||
if (game.phase === 'storm') {
|
||||
garden.step(dt, sky?.gardenExposure ? sky.gardenExposure(world.gardenBed, windT) : 0);
|
||||
}
|
||||
}
|
||||
|
||||
function frame() {
|
||||
@ -370,19 +565,11 @@ export async function boot(opts = {}) {
|
||||
sailView?.update();
|
||||
renderer.render(scene, cameraRig.object);
|
||||
|
||||
hud.update(raw, windT);
|
||||
|
||||
frames++; fpsT += raw;
|
||||
if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; }
|
||||
if (hud) {
|
||||
const wt = windT;
|
||||
const speed = wind.speedAt(player.pos, wt);
|
||||
const tel = wind.gustTelegraph(wt);
|
||||
const worst = rig.corners.reduce((m, c) => Math.max(m, c.load || 0), 0);
|
||||
hud.textContent =
|
||||
`${fps.toFixed(0)} fps | ${game.phase} ${game.phaseT.toFixed(1)}s | ` +
|
||||
`wind ${speed.toFixed(1)} m/s${tel ? ` | GUST in ${tel.eta.toFixed(1)}s` : ''} | ` +
|
||||
`worst corner ${worst.toFixed(1)} | debris ${debris.pieces.length}` +
|
||||
`${events.length ? ` | ${events[events.length - 1].text}` : ''}`;
|
||||
}
|
||||
if (dev) dev.textContent = `${fps.toFixed(0)} fps · ${game.phase} ${game.phaseT.toFixed(1)}s · t ${simT.toFixed(0)}s · debris ${debris.pieces.length}`;
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
@ -392,11 +579,14 @@ export async function boot(opts = {}) {
|
||||
const api = {
|
||||
renderer, scene, world, cameraRig, player, game, wind, rig, rigSail,
|
||||
get sailView() { return sailView; },
|
||||
debris, interact, events,
|
||||
debris, interact, events, hud, rigging, garden, defs, winds,
|
||||
get stormKey() { return stormKey; },
|
||||
set stormKey(k) { stormKey = k; },
|
||||
scoreRun,
|
||||
get sky() { return sky; },
|
||||
get simT() { return simT; },
|
||||
windTime,
|
||||
calmWind, wildWind,
|
||||
calmWind,
|
||||
|
||||
/**
|
||||
* Drive the sim by hand at fixed dt, and draw on demand. rAF is throttled to
|
||||
|
||||
@ -15,9 +15,10 @@
|
||||
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, clipFor } from './player.sim.js';
|
||||
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
|
||||
import { createLadder } from './ladder.js';
|
||||
|
||||
export { PlayerSim, STATES, TUNE, clipFor };
|
||||
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder };
|
||||
|
||||
export const CHAR_URL = './models/player_01.glb';
|
||||
export const ANIM_URL = './models/player_anims.glb';
|
||||
@ -193,7 +194,10 @@ export class PlayerView {
|
||||
// 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);
|
||||
// climbY lifts the whole rig up the rungs. Same trick as the knockdown pitch: _rotOnly strips
|
||||
// the root from every clip, so ClimbLadder can't raise the body — the sim does, and the clip
|
||||
// just supplies the arms and legs.
|
||||
this.root.position.set(sim.pos.x, sim.pos.y + (sim.climbY || 0), sim.pos.z);
|
||||
|
||||
// yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the
|
||||
// feet. At pitch 0 this is exactly the yaw, so upright play is untouched.
|
||||
@ -278,6 +282,12 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
const keyboard = new KeyboardInput();
|
||||
const { sim, view } = p;
|
||||
|
||||
// The ladder self-wires from here rather than from main.js: createPlayer is already handed the
|
||||
// scene, the world and interact, which is everything it needs — so Lane A's file doesn't have to
|
||||
// change to get a whole sub-system. Opt out with {ladder: false} if a harness doesn't want it.
|
||||
const ladder = (opts.ladder === false || !opts.interact)
|
||||
? null : createLadder(scene, world, opts.interact, sim);
|
||||
|
||||
return {
|
||||
get pos() { return sim.pos; },
|
||||
get carrying() { return sim.carrying; },
|
||||
@ -288,6 +298,7 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
update(dt, t) {
|
||||
const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
|
||||
sim.step(dt, t, input, opts.wind);
|
||||
if (ladder) ladder.update(dt, t, input);
|
||||
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
|
||||
view.sync(sim, dt);
|
||||
},
|
||||
@ -296,8 +307,9 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
get object() { return view.root; },
|
||||
sim,
|
||||
view,
|
||||
ladder,
|
||||
keyboard,
|
||||
dispose() { keyboard.dispose(); view.dispose(); },
|
||||
dispose() { keyboard.dispose(); view.dispose(); if (ladder) ladder.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -29,8 +29,26 @@ export const STATES = {
|
||||
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' },
|
||||
|
||||
// --- the ladder (decision 12). climbY is driven in code and the clip plays on top, exactly the
|
||||
// knockdown precedent: _rotOnly strips the root, so ClimbLadder can no more lift the body than
|
||||
// Falling could lay it down. ladder.js calls climbTo(); the sim owns the height. ---
|
||||
climb: { clip: 'ClimbLadder', locked: true, loop: true, releasedBy: 'ladder' },
|
||||
// atTop is deliberately NOT locked: `busy` gates interact.js, and the whole point of being up
|
||||
// there is that hold-E works. Movement is stopped by `onLadder` instead, not by `locked`.
|
||||
atTop: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true, releasedBy: 'ladder' },
|
||||
};
|
||||
|
||||
/**
|
||||
* True while the player is on a ladder — movement is off, and the wind is meaner.
|
||||
*
|
||||
* Keyed on HEIGHT, not on state, and that distinction is load-bearing: hold-E puts you into `busy`,
|
||||
* so a state-based test would say you'd stepped off the ladder the instant you started the repair
|
||||
* you climbed up to do. (It did exactly that — the gate cancelled its own hold.) Height is the
|
||||
* physical truth and survives every state the ladder passes through.
|
||||
*/
|
||||
export const onLadder = (sim) => sim.climbY > 0.02 || sim.state === 'climb';
|
||||
|
||||
/**
|
||||
* 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) —
|
||||
@ -96,6 +114,13 @@ export const TUNE = {
|
||||
// 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
|
||||
|
||||
// Ladder (decision 12). DESIGN.md: "ladder work at height in wind is genuinely tense" — this is
|
||||
// where that gets teeth. You cannot brace up there (both hands are on the rungs), so the only
|
||||
// defence is choosing your moment: climb in a lull, not through a gust.
|
||||
climbRate: 1.1, // m/s up or down a rung — slow enough that the storm gets a vote
|
||||
reach: 2.2, // m — how high a 1.72 m person's hands get, standing. Fascia sits at 2.48.
|
||||
ladderKnockMult: 0.6, // knockWind × this while on the ladder — far easier to be blown off
|
||||
};
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
@ -140,6 +165,10 @@ export class PlayerSim {
|
||||
this.pitch = 0; // 0 upright … 1 flat on the ground
|
||||
this.knockDir = { x: 0, z: 1 }; // which way the body went down
|
||||
|
||||
this.climbY = 0; // m above the ground; >0 means you're up a ladder
|
||||
this.climbTarget = 0; // where ladder.js asked you to be
|
||||
this.fellFrom = 0; // m — height of the last fall, for the HUD/aftermath to shame you with
|
||||
|
||||
this.groundAt = opts.groundAt || (() => 0);
|
||||
this.collide = opts.collide || null;
|
||||
this.bodyHeight = opts.height || 1.72;
|
||||
@ -151,6 +180,23 @@ export class PlayerSim {
|
||||
get clip() { return STATES[this.state].clip; }
|
||||
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
|
||||
|
||||
/** How high this person's hands get right now. The gate on reaching a fascia bracket. */
|
||||
get reachY() { return this.pos.y + this.climbY + this.tune.reach; }
|
||||
|
||||
/**
|
||||
* Go up or down a ladder. ladder.js owns WHERE (it knows the rungs); the sim owns the motion, so
|
||||
* a climb is deterministic and fast-forwards in selftest like everything else.
|
||||
* @param {number} y target height above ground; 0 climbs back down
|
||||
*/
|
||||
climbTo(y, t = 0) {
|
||||
this.climbTarget = Math.max(0, y);
|
||||
if (Math.abs(this.climbTarget - this.climbY) > 0.02) {
|
||||
this.vel.x = this.vel.z = 0;
|
||||
this.setState('climb', t);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setState(s, t = 0) {
|
||||
if (this.state === s) return false;
|
||||
if (!STATES[s]) throw new Error(`player: unknown state ${s}`);
|
||||
@ -194,11 +240,16 @@ export class PlayerSim {
|
||||
if (x === undefined || (x === 0 && z === 0)) { x = Math.sin(this.facing); z = Math.cos(this.facing); }
|
||||
const m = Math.hypot(x, z) || 1;
|
||||
this.knockDir = { x: x / m, z: z / m };
|
||||
// Blown off a ladder: you don't stagger, you fall. Everything else about a knockdown is the
|
||||
// same, so the ladder gets the get-up chain for free — you just arrive on the ground first.
|
||||
this.fellFrom = this.climbY;
|
||||
this.climbY = 0;
|
||||
this.climbTarget = 0;
|
||||
this.setState('knocked', t);
|
||||
this.exposure = 0;
|
||||
this.vel.x = this.vel.z = 0;
|
||||
this.drop(t);
|
||||
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir } });
|
||||
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir }, fellFrom: this.fellFrom });
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -225,7 +276,9 @@ export class PlayerSim {
|
||||
this.gust = Math.max(0, ws - this.windBase);
|
||||
|
||||
// --- 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. ---
|
||||
// even mid-gust. Refused while you're down — you can't brace from your back, and refused on
|
||||
// a ladder: both hands are on the rungs. Up there your only defence is having picked a lull. ---
|
||||
const up = onLadder(this);
|
||||
const wantShelter = !!input.shelter;
|
||||
const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run';
|
||||
if (wantShelter && canShelter) this.setState('shelter', t);
|
||||
@ -233,12 +286,34 @@ export class PlayerSim {
|
||||
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;
|
||||
// Bracing raises the bar; a ladder LOWERS it. Same exposure clock either way, so the storm
|
||||
// speaks one language whether you're on your feet or up a rung. ---
|
||||
const knockAt = T.knockWind
|
||||
* (braced ? T.shelterKnockMult : 1)
|
||||
* (up ? T.ladderKnockMult : 1);
|
||||
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);
|
||||
|
||||
// --- the climb itself: code-driven height, ClimbLadder plays on top (the knockdown precedent) ---
|
||||
if (this.state === 'climb') {
|
||||
const dy = this.climbTarget - this.climbY;
|
||||
const rung = T.climbRate * dt;
|
||||
if (Math.abs(dy) <= rung) {
|
||||
this.climbY = this.climbTarget;
|
||||
this.setState(this.climbY > 0.02 ? 'atTop' : 'idle', t);
|
||||
} else {
|
||||
this.climbY += Math.sign(dy) * rung;
|
||||
}
|
||||
}
|
||||
// Invariant: you cannot be standing on the grass while you are 2 m up a ladder. interact.js
|
||||
// releases a finished hold to 'idle' without knowing where you are; this puts you back in the
|
||||
// work stance instead of leaving you idling in mid-air.
|
||||
if (this.climbY > 0.02
|
||||
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
|
||||
this.setState('atTop', t);
|
||||
}
|
||||
|
||||
// --- 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')) {
|
||||
@ -248,11 +323,12 @@ export class PlayerSim {
|
||||
}
|
||||
|
||||
const st = STATES[this.state];
|
||||
const aloft = onLadder(this); // re-read: the climb block above may have just landed you
|
||||
|
||||
// --- movement ---
|
||||
const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you
|
||||
let wantX = 0, wantZ = 0;
|
||||
if (!st.locked) {
|
||||
if (!st.locked && !aloft) {
|
||||
const ix = input.x || 0, iz = input.z || 0;
|
||||
const mag = Math.hypot(ix, iz);
|
||||
if (mag > 1e-3) {
|
||||
@ -305,8 +381,10 @@ export class PlayerSim {
|
||||
const pstep = dt / T.pitchSecs;
|
||||
this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);
|
||||
|
||||
// --- locomotion state from actual speed (so shove/slow can't desync the feet) ---
|
||||
if (!st.locked) {
|
||||
// --- locomotion state from actual speed (so shove/slow can't desync the feet).
|
||||
// `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without
|
||||
// this guard the speed check would immediately re-state you to idle and drop you off. ---
|
||||
if (!st.locked && !aloft) {
|
||||
const sp = this.speed;
|
||||
this.setState(sp < 0.15 ? 'idle' : sp > T.walkSpeed * 1.35 ? 'run' : 'walk', t);
|
||||
} else if (st.secs && this.stateT >= st.secs && st.next) {
|
||||
|
||||
@ -48,9 +48,9 @@ function realWind(def = STORM_02, opts = {}) {
|
||||
|
||||
/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
|
||||
const YARD = [
|
||||
['h1', 'house', -5, 2.6, -9.9], ['h2', 'house', 0, 2.6, -9.9], ['h3', 'house', 5, 2.6, -9.9],
|
||||
['t1', 'tree', -9, 3.2, 2], ['t2', 'tree', 8, 3.1, -2],
|
||||
['p1', 'post', -6.4, 3.9, 7.4], ['p2', 'post', 5.3, 3.9, 8],
|
||||
['h1', 'house', -5, 2.60, -9.9], ['h2', 'house', 0, 2.60, -9.9], ['h3', 'house', 5, 2.60, -9.9],
|
||||
['t1', 'tree', -9, 3.22, 2], ['t2', 'tree', 8, 3.08, -2],
|
||||
['p1', 'post', -4.9, 3.95, 5.9], ['p2', 'post', 4.3, 3.96, 6.5], ['p3', 'post', 0, 3.95, 7.6],
|
||||
].map(([id, type, x, y, z]) => {
|
||||
const pos = { x, y, z };
|
||||
// Static on purpose: tree sway is world.js's, and mixing it in here would make
|
||||
@ -59,6 +59,14 @@ const YARD = [
|
||||
return { id, type, pos, sway: () => pos };
|
||||
});
|
||||
|
||||
/**
|
||||
* SPRINT4 decision 11: §7's twisted rig re-pointed off the old 145 m² quad onto
|
||||
* a real one from A's decision-2 yard — 23 m², inside the 18-45 m² band, and the
|
||||
* most twisted quad the band offers. The old one was 6x too big, which is what
|
||||
* made it break under downdraft and made me call the bar unachievable.
|
||||
*/
|
||||
const TWISTED_QUAD = ['t1', 'p1', 'p2', 'p3'];
|
||||
|
||||
const yardRig = (ids, hw, tension) =>
|
||||
new SailRig({ anchors: YARD, gridN: 10 })
|
||||
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
|
||||
@ -549,7 +557,7 @@ test('§7 gate on REAL storm_02: cheap flat rig cascades', () => {
|
||||
test('§7 gate on REAL storm_02: twisted mixed rig survives', () => {
|
||||
// Lane C's shape: h1 (house, 2.6) / t2 (tree, 3.1) / p1 (post, 3.9) / t1 (tree, 3.2)
|
||||
// — corners at four different heights, i.e. an actual hypar, eased off tight.
|
||||
const rig = yardRig(['h1', 't2', 'p1', 't1'], [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
|
||||
const rig = yardRig(TWISTED_QUAD, [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
|
||||
const w = realWind();
|
||||
let peak = 0;
|
||||
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
|
||||
@ -567,15 +575,17 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
|
||||
// the interesting scenario is DESIGN.md's: the budget forces one dodgy corner
|
||||
// ($80 buys rated on at most two of four), that corner blows, and you run out
|
||||
// and re-rig it once with the carried spare — exactly Lane D's hold-E.
|
||||
// An $80-exact loadout: rated h1 ($30) + shackle t1 ($15) + shackle p1 ($15)
|
||||
// + carabiner t2 ($5) + spare ($15). The carabiner goes on t2 because that is
|
||||
// where the load actually IS — measured peaks on this shape are h1 1.68 /
|
||||
// t2 2.73 / p1 2.17 / t1 0.81 kN. Putting the cheap corner on t1 (the
|
||||
// lightest) is what a player does by accident and it survives the storm
|
||||
// having proved nothing; putting it on t2 is the real bet.
|
||||
// An $80-exact loadout on the decision-11 quad: rated t1 ($30) + shackle p1
|
||||
// ($15) + carabiner p2 ($5) + shackle p3 ($15) + spare ($15).
|
||||
//
|
||||
// The carabiner goes on p2 because that is where the load actually IS —
|
||||
// measured peaks on this quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN.
|
||||
// Hanging the cheap corner on p1 (the lightest) is what a player does by
|
||||
// accident: it rides the whole storm out and proves nothing. p2 is the real
|
||||
// bet, and it's the one that has to blow for this test to mean anything.
|
||||
const rig = yardRig(
|
||||
['h1', 't2', 'p1', 't1'],
|
||||
[HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]],
|
||||
TWISTED_QUAD, // ['t1','p1','p2','p3']
|
||||
[HARDWARE[2], HARDWARE[1], HARDWARE[0], HARDWARE[1]],
|
||||
0.85,
|
||||
);
|
||||
const w = realWind();
|
||||
@ -594,11 +604,10 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
|
||||
// green forever while proving nothing. Storm_02 can't threaten a shackle
|
||||
// rig until Lane C's downdraft lands (their A/B: shackle blows at t=20.8 s
|
||||
// with downdraft 0.3, never without). Lights up by itself on merge.
|
||||
assert(
|
||||
!STORM_02.gusts?.downdraft,
|
||||
'storm_02 HAS a downdraft and still could not blow a shackle rig — the repair scenario is vacuous',
|
||||
);
|
||||
return 'SKIPPED — nothing blew; needs Lane C decision 3 downdraft to threaten a shackle rig';
|
||||
// Decision 11 landed the downdraft for real, so there is no longer an excuse
|
||||
// for nothing breaking: a vacuous pass here would mean the §7 repair leg —
|
||||
// the sprint's whole definition of done — is checking nothing.
|
||||
assert(false, 'nothing blew, so the repair scenario proved nothing — the dodgy corner is not on a loaded corner');
|
||||
}
|
||||
assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
|
||||
return `${repairs} repair, finished ${4 - lost}/4 corners intact`;
|
||||
@ -606,51 +615,64 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
|
||||
|
||||
// --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------
|
||||
|
||||
// My Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all
|
||||
// (1.14 kN vs a pitched flat's 3.06), because a horizontal plate in horizontal
|
||||
// wind has almost no drag — which inverted DESIGN.md's "big, flat, low = death
|
||||
// in a storm". Lane C closed it by making gusts descend. This is the assert
|
||||
// decision 3 asks Lane B for.
|
||||
// Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all,
|
||||
// because a horizontal plate in horizontal wind has almost no drag — which
|
||||
// inverted DESIGN.md's "big, flat, low = death in a storm". Lane C closed it by
|
||||
// making the wind descend (decision 8: a fraction of TOTAL speed, present
|
||||
// whenever it's windy).
|
||||
//
|
||||
// THE REFERENCE RIG IS THE WHOLE TEST, and getting it wrong is what made me
|
||||
// declare this bar unachievable for two sprints (SPRINT3 [B], and I was wrong).
|
||||
// The ratio is `(f / (sin p + cos p·f))²` for reference pitch p, so it depends
|
||||
// on p far more than on the downdraft:
|
||||
//
|
||||
// pitch f=0.12 f=0.45 the bar is 60%
|
||||
// 16.7° 8.9% 39.2% <- my old synthetic rig: unreachable,
|
||||
// asymptote 109%, would need f=0.86
|
||||
// 4.8° 34.9% 71.5% <- the yard: clears comfortably
|
||||
//
|
||||
// A steeply-pitched reference catches the downdraft nearly as well as a
|
||||
// horizontal one does (its normal is still 96% vertical), so it can never be
|
||||
// out-loaded. The yard cannot BUILD a 16.7° sail: house fascia is 2.60 m and
|
||||
// the posts are 3.95 m, ~16 m apart — 4.8°. Measuring against a rig the game
|
||||
// can't rig proved something true about nothing.
|
||||
const YARD_PITCH_DEG = 4.8; // house 2.60 -> post 3.95 over ~16 m, from world.js
|
||||
test('decision 3: flat-horizontal is no longer a free lunch', () => {
|
||||
const downdraft = STORM_02.gusts?.downdraft ?? 0;
|
||||
if (!downdraft) {
|
||||
// Feature-detected rather than hard-failed: this assert is only meaningful
|
||||
// once Lane C's downdraft is on main. It lights up by itself on merge.
|
||||
return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)';
|
||||
}
|
||||
if (downdraft < 0.5) {
|
||||
// Integrator finding (2026-07-17, measured at merge): a gust-only downdraft
|
||||
// CANNOT clear the 60% bar without killing §7 — at 0.45 the twisted mixed
|
||||
// rig loses a corner and the ratio is still 42%; at 0.58 it's 48% and the
|
||||
// rig still dies. The two asserts pincer. Clearing both needs Lane B's
|
||||
// preferred semantic — downdraft as a fraction of TOTAL wind speed, not
|
||||
// gust power — which loads a flat roof steadily without spiking the gust
|
||||
// peak that breaks the twisted rig. That is a weather.core change (joint
|
||||
// B+C, SPRINT3). Until it lands, storm data stays at C's tuned 0.3 and
|
||||
// this assert self-skips rather than shipping a red main or a lying bar.
|
||||
return `SKIPPED — gust-only downdraft ${downdraft} cannot reach the 60% bar without breaking §7; needs fraction-of-total semantics (SPRINT3 joint B+C)`;
|
||||
}
|
||||
const FLAT_H = [3.25, 3.25, 3.25, 3.25];
|
||||
// Spin the rig through 8 headings under the real storm. (Re-seeding the wind
|
||||
// instead would only reshuffle gust TIMING — the direction curve is authored
|
||||
// in the JSON and doesn't move — so it would look like a sweep and measure
|
||||
// nothing about direction.)
|
||||
const sweep = (heights) => {
|
||||
const f = STORM_02.gusts?.downdraftOfTotal ?? STORM_02.gusts?.downdraft ?? 0;
|
||||
assert(f > 0, 'storm_02 has no downdraft at all — decision 3/8 has regressed out of the data');
|
||||
|
||||
// Footprint sized and pitched like a real quad from the dressed yard, spun
|
||||
// through 8 headings under the real storm. (Re-seeding the wind instead only
|
||||
// reshuffles gust TIMING — the direction curve is authored — so it would look
|
||||
// like a sweep and measure nothing about direction.)
|
||||
const S = Math.sqrt(30); // ~30 m², mid of A's 18-45 band
|
||||
const rise = Math.tan((YARD_PITCH_DEG * Math.PI) / 180) * S;
|
||||
const PITCHED = [3.2 + rise / 2, 3.2 + rise / 2, 3.2 - rise / 2, 3.2 - rise / 2];
|
||||
const HORIZ = [3.2, 3.2, 3.2, 3.2];
|
||||
|
||||
const foot = [[-S / 2, -S / 2], [S / 2, -S / 2], [S / 2, S / 2], [-S / 2, S / 2]];
|
||||
const at = (hs, th) => foot.map(([x, z], i) => {
|
||||
const c = Math.cos(th), s = Math.sin(th);
|
||||
const pos = { x: x * c - z * s, y: hs[i], z: x * s + z * c };
|
||||
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
|
||||
});
|
||||
const sweep = (hs) => {
|
||||
let worst = 0;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const r = new SailRig({ anchors: makeAnchors(heights, (k / 8) * Math.PI * 2), gridN: 10 })
|
||||
const r = new SailRig({ anchors: at(hs, (k / 8) * Math.PI * 2), gridN: 10 })
|
||||
.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);
|
||||
// full duration: storm_02's own note says the peak lands just AFTER the
|
||||
// southerly change, so a 45 s sweep measures the wrong half of the storm
|
||||
// southerly change, so a short sweep measures the wrong half of the storm
|
||||
worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
|
||||
}
|
||||
return worst;
|
||||
};
|
||||
const pitched = sweep(HEIGHTS_FLAT);
|
||||
const horizontal = sweep(FLAT_H);
|
||||
|
||||
const pitched = sweep(PITCHED);
|
||||
const horizontal = sweep(HORIZ);
|
||||
const ratio = horizontal / pitched;
|
||||
assert(ratio >= 0.6, `flat-horizontal peaks at only ${(ratio * 100).toFixed(0)}% of flat-pitched (${kN(horizontal)} vs ${kN(pitched)}) — still a free lunch`);
|
||||
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% (downdraft ${downdraft})`;
|
||||
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% at ${YARD_PITCH_DEG}° yard pitch, downdraftOfTotal ${f}`;
|
||||
});
|
||||
|
||||
test('runs against the shared contracts.js stub wind', () => {
|
||||
|
||||
@ -130,6 +130,89 @@ function rainVelocity(w, intensity, out) {
|
||||
return out.set(w.x * 0.55, -(9 + intensity * 4), w.z * 0.55);
|
||||
}
|
||||
|
||||
// Hail falls STEEP, and that is the whole of decision 13. A 1 cm stone's
|
||||
// terminal velocity is ~22 m/s (a raindrop's is ~9), and a dense stone couples
|
||||
// only weakly to the crosswind, so even a 30 m/s gale leans it no more than
|
||||
// ~20° off vertical — where a sail overhead still blocks it. (Rain at 9 m/s in
|
||||
// the same gale comes in at atan(30/9) ≈ 73°, nearly sideways, which is why it
|
||||
// walks under the sail and can't score the rig.) Do NOT re-open the rain-angle
|
||||
// argument here; steep is the point.
|
||||
const HAIL_FALL = 22; // m/s terminal, ~1 cm ice
|
||||
const HAIL_LEAN_COUPLING = 0.3; // dense stones catch little wind
|
||||
const HAIL_MAX_LEAN = Math.tan(20 * Math.PI / 180); // cap ~20° off vertical
|
||||
|
||||
/** Hail velocity, m/s. Steep — see the note above. Used by stones and shadow. */
|
||||
function hailVelocity(w, out) {
|
||||
const cap = HAIL_FALL * HAIL_MAX_LEAN;
|
||||
let hx = w.x * HAIL_LEAN_COUPLING, hz = w.z * HAIL_LEAN_COUPLING;
|
||||
const mag = Math.hypot(hx, hz);
|
||||
if (mag > cap) { const s = cap / mag; hx *= s; hz *= s; }
|
||||
return out.set(hx, -HAIL_FALL, hz);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- hail
|
||||
// Instanced falling stones, same wrap-around-the-camera trick as the rain but
|
||||
// fewer, whiter, faster and much steeper. Stones under the cloth are hidden so
|
||||
// you SEE the sail doing its job. Count scales with intensity; a hail-free storm
|
||||
// draws nothing.
|
||||
function createHail(opts) {
|
||||
const max = opts.maxStones ?? 1300;
|
||||
const half = opts.half ?? 16;
|
||||
const height = opts.height ?? 22;
|
||||
const groundY = opts.groundY ?? 0;
|
||||
const rand = rng(0x4a11);
|
||||
|
||||
const geo = new THREE.BoxGeometry(0.05, 0.05, 0.05); // a little cube reads as a stone
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0xeaf2ff, transparent: true, opacity: 0.9, depthWrite: false, fog: false,
|
||||
});
|
||||
const mesh = new THREE.InstancedMesh(geo, mat, max);
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
mesh.frustumCulled = false;
|
||||
mesh.renderOrder = 3;
|
||||
mesh.count = 0;
|
||||
|
||||
const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max);
|
||||
const jit = new Float32Array(max);
|
||||
for (let i = 0; i < max; i++) {
|
||||
px[i] = (rand() * 2 - 1) * half;
|
||||
py[i] = groundY + rand() * height;
|
||||
pz[i] = (rand() * 2 - 1) * half;
|
||||
jit[i] = 0.85 + rand() * 0.3;
|
||||
}
|
||||
|
||||
const m = new THREE.Matrix4();
|
||||
const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0);
|
||||
const top = groundY + height;
|
||||
|
||||
return {
|
||||
mesh,
|
||||
step(dt, camPos, vel, intensity, size, shadow) {
|
||||
const n = Math.floor(max * clamp01(intensity));
|
||||
mesh.count = n;
|
||||
if (n === 0) return;
|
||||
const s = 0.6 + size * 0.9; // bigger stones read bigger
|
||||
m.makeScale(s, s, s);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = jit[i];
|
||||
px[i] += vel.x * j * dt;
|
||||
py[i] += vel.y * j * dt; // vel.y is negative
|
||||
pz[i] += vel.z * j * dt;
|
||||
let d = px[i] - camPos.x;
|
||||
if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2;
|
||||
d = pz[i] - camPos.z;
|
||||
if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2;
|
||||
if (py[i] < groundY) py[i] += height; else if (py[i] > top) py[i] -= height;
|
||||
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];
|
||||
mesh.setMatrixAt(i, m);
|
||||
}
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
},
|
||||
dispose() { geo.dispose(); mat.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rain
|
||||
function createRain(opts) {
|
||||
const max = opts.maxDrops ?? 3000;
|
||||
@ -264,6 +347,7 @@ function createAudio(seed = 1) {
|
||||
let windGain, windFilter, windHowl, howlGain;
|
||||
let rainGain, rainFilter;
|
||||
let gustGain, gustFilter;
|
||||
let hailGain, hailFilter, drumGain, drumFilter;
|
||||
let noiseBuf = null;
|
||||
let creakNext = 0, flogNext = 0;
|
||||
let started = false;
|
||||
@ -344,6 +428,22 @@ function createAudio(seed = 1) {
|
||||
gustGain.connect(master);
|
||||
loop(noiseBuf, gustGain, gustFilter);
|
||||
|
||||
// hail clatter: bright, hard highpass — ice on concrete
|
||||
hailGain = ctx.createGain(); hailGain.gain.value = 0;
|
||||
hailFilter = ctx.createBiquadFilter();
|
||||
hailFilter.type = 'highpass'; hailFilter.frequency.value = 3000;
|
||||
hailGain.connect(master);
|
||||
loop(noiseBuf, hailGain, hailFilter);
|
||||
|
||||
// the DRUM: hail on taut cloth, a low resonant thrum. This is the "my sail
|
||||
// is earning its money" sound — it only speaks when the sail is actually
|
||||
// catching hail, so a rig over the bed sounds different from bare sky.
|
||||
drumGain = ctx.createGain(); drumGain.gain.value = 0;
|
||||
drumFilter = ctx.createBiquadFilter();
|
||||
drumFilter.type = 'bandpass'; drumFilter.frequency.value = 140; drumFilter.Q.value = 3;
|
||||
drumGain.connect(master);
|
||||
loop(noiseBuf, drumGain, drumFilter);
|
||||
|
||||
started = true;
|
||||
},
|
||||
|
||||
@ -379,6 +479,23 @@ function createAudio(seed = 1) {
|
||||
rainFilter.frequency.setTargetAtTime(1500 + rain * 900, now, 0.3);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {number} intensity 0..1 hail
|
||||
* @param {number} onCloth 0..1 of the hail the sail overhead is catching
|
||||
* @param {number} size stone-size scalar — bigger stones drum lower
|
||||
*/
|
||||
setHail(intensity, onCloth, size) {
|
||||
if (!started) return;
|
||||
const now = ctx.currentTime;
|
||||
// clatter fades as the cloth intercepts more of the storm — some ice still
|
||||
// reaches the ground past the sail, but the open-ground roar drops
|
||||
hailGain.gain.setTargetAtTime(intensity * (1 - onCloth * 0.7) * 0.3, now, 0.15);
|
||||
hailFilter.frequency.setTargetAtTime(2400 + (2 - size) * 700, now, 0.2);
|
||||
// the drum rises exactly as the sail catches hail — the payoff sound
|
||||
drumGain.gain.setTargetAtTime(intensity * onCloth * 0.5, now, 0.12);
|
||||
drumFilter.frequency.setTargetAtTime(110 + (2 - size) * 45, now, 0.2);
|
||||
},
|
||||
|
||||
/** Telegraph cue: you hear it coming before you feel it. */
|
||||
whoosh(power, eta) {
|
||||
if (!started) return;
|
||||
@ -456,6 +573,21 @@ export function createSkyFx(o = {}) {
|
||||
const rainDir = new THREE.Vector3();
|
||||
let shadowTick = 0;
|
||||
|
||||
// hail rides its own steep shadow — that's the whole of decision 13: the sail
|
||||
// blocks steep hail (shadow ≈ its footprint) where it can't block slanted rain.
|
||||
const hail = createHail({ groundY: o.groundY ?? 0 });
|
||||
if (scene) scene.add(hail.mesh);
|
||||
const hailShadow = new RainShadow({ groundY: o.groundY ?? 0 });
|
||||
const hailDir = new THREE.Vector3();
|
||||
const hailWind = new THREE.Vector3();
|
||||
let hailTick = 0, hailAmt = 0;
|
||||
|
||||
// hailAt lives behind the wind router (Lane A's allowlist). If a stale router
|
||||
// doesn't forward it, degrade to no hail rather than crashing — but it must be
|
||||
// forwarded or the garden score (decision 13) is inert. Flagged to A in THREADS.
|
||||
const hailIntensity = (t) => (wind && typeof wind.hailAt === 'function' ? wind.hailAt(t) : 0);
|
||||
const hailStone = () => (wind && wind.hailSize != null ? wind.hailSize : 1);
|
||||
|
||||
const audio = createAudio((wind && wind.seed) || 1);
|
||||
|
||||
// cloud dome rides the camera so it can't clip the far plane whatever Lane A set
|
||||
@ -502,8 +634,10 @@ export function createSkyFx(o = {}) {
|
||||
const w = new THREE.Vector3();
|
||||
|
||||
const fx = {
|
||||
rain, audio, dome, shadow,
|
||||
rain, audio, dome, shadow, hailShadow,
|
||||
get flash() { return flash; },
|
||||
/** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */
|
||||
get hailAmount() { return hailAmt; },
|
||||
|
||||
/**
|
||||
* 0..1 of a ground rect the sail is keeping dry, right now.
|
||||
@ -540,6 +674,27 @@ export function createSkyFx(o = {}) {
|
||||
return rain * (1 - shadow.fractionOver(rect));
|
||||
},
|
||||
|
||||
/** 0..1 of a rect the sail is keeping hail off, this frame (steep shadow). */
|
||||
hailShadowOver(rect) { return hailShadow.fractionOver(rect); },
|
||||
|
||||
/**
|
||||
* Decision 13's garden-damage feed, 0..1, the gardenExposure mold but for
|
||||
* HAIL — this is what makes the garden score respond to the rig. Steep stones
|
||||
* mean the sail's shadow ≈ its footprint, so a rig over the bed genuinely
|
||||
* shelters it even in a gale, unlike rain (which walks under and can't score
|
||||
* the sail — the whole reason a perfect rig used to tie with no rig at all).
|
||||
*
|
||||
* hp -= sky.gardenHailExposure(bed, t) * HAIL_DAMAGE * dt
|
||||
* + sky.gardenExposure(bed, t) * SMALL_RAIN_DRAIN * dt; // A wires both
|
||||
*
|
||||
* 0 = no hail, or the cloth is catching it; 1 = full burst on the open bed.
|
||||
*/
|
||||
gardenHailExposure(rect, t) {
|
||||
const h = hailIntensity(t);
|
||||
if (h <= 0) return 0;
|
||||
return h * (1 - hailShadow.fractionOver(rect));
|
||||
},
|
||||
|
||||
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||
unlockAudio() { audio.unlock(); },
|
||||
|
||||
@ -644,8 +799,27 @@ export function createSkyFx(o = {}) {
|
||||
}
|
||||
rain.step(dt, camPos, w, intensity, shadow);
|
||||
|
||||
// --- hail ---
|
||||
hailAmt = hailIntensity(t);
|
||||
const stone = hailStone();
|
||||
// The hail shadow follows the STEEP hail vector, not the wind. It barely
|
||||
// moves, so rebuild it slowly. A tenth of a second is fine; hail rides it.
|
||||
hailTick -= dt;
|
||||
if (hailTick <= 0) {
|
||||
hailTick = 0.12;
|
||||
hailWind.set(w.x, 0, w.z);
|
||||
hailVelocity(hailWind, hailDir);
|
||||
const len = hailDir.length() || 1;
|
||||
hailShadow.update(world.sail, hailDir.x / len, hailDir.y / len, hailDir.z / len);
|
||||
}
|
||||
hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow);
|
||||
// how much of the hail the sail overhead is catching, for the drum sound —
|
||||
// sample right above the player/camera so it's "is it drumming over ME"
|
||||
const onCloth = hailAmt > 0 ? hailShadow.fractionOver({ x: camPos.x, z: camPos.z, w: 3, d: 3 }) : 0;
|
||||
|
||||
// --- audio ---
|
||||
audio.setLevels(speed, intensity);
|
||||
audio.setHail(hailAmt, onCloth, stone);
|
||||
const tg = wind.gustTelegraph(t);
|
||||
if (tg && tg !== lastTelegraph) {
|
||||
// fires once per gust, right as the telegraph opens
|
||||
@ -672,6 +846,7 @@ export function createSkyFx(o = {}) {
|
||||
dispose() {
|
||||
if (scene) {
|
||||
scene.remove(rain.mesh);
|
||||
scene.remove(hail.mesh);
|
||||
scene.remove(dome);
|
||||
scene.background = original.background;
|
||||
if (ownsFog) {
|
||||
@ -686,6 +861,7 @@ export function createSkyFx(o = {}) {
|
||||
if (sun) sun.intensity = original.sun;
|
||||
if (hemi) hemi.intensity = original.hemi;
|
||||
rain.dispose();
|
||||
hail.dispose();
|
||||
dome.geometry.dispose();
|
||||
dome.material.dispose();
|
||||
domeTex.dispose();
|
||||
|
||||
@ -7,8 +7,9 @@ import * as THREE from '../../vendor/three.module.js';
|
||||
import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
|
||||
import { createWorld, heightAt } from '../world.js';
|
||||
import { createCameraRig } from '../camera.js';
|
||||
import { createGame } from '../main.js';
|
||||
import { createGame, createWindRouter } from '../main.js';
|
||||
import { orderRing } from '../sail.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
@ -50,6 +51,52 @@ export default async function run(t) {
|
||||
assertEq(checkContract('game', createGame()).join('; '), '');
|
||||
});
|
||||
|
||||
// --- the wind router -----------------------------------------------------
|
||||
|
||||
t.test('wind router forwards EVERYTHING the real wind exposes', async () => {
|
||||
// This exists because the omission it catches has already shipped once.
|
||||
//
|
||||
// Lane C added rainMmPerHour/rainDepthMm for ponding; main.js's router — a
|
||||
// hand-maintained delegation list — didn't forward them. Nothing went red:
|
||||
// every suite holds a real wind, and only the GAME holds the router. So
|
||||
// Lane B's ponding would have passed every assert it had and done nothing
|
||||
// in the actual yard, and it took the integrator noticing by hand at merge.
|
||||
//
|
||||
// The class of bug is worse than the instance. Sprint 5's headline system is
|
||||
// Lane C's hailAt(), and decision 13 hangs the entire garden score off it —
|
||||
// so the same silent swallow would make rigging look irrelevant to the
|
||||
// garden all over again, which is the exact thing this sprint exists to fix.
|
||||
// Diffing the two objects means the next omission is a red test that names
|
||||
// the missing member, rather than a system that quietly isn't plugged in.
|
||||
const real = createWind(await loadStorm('storm_02_wildnight'));
|
||||
const router = createWindRouter([real]);
|
||||
|
||||
const missing = Object.keys(real).filter((k) => !(k in router));
|
||||
assert(missing.length === 0,
|
||||
`the wind router swallows ${missing.join(', ')} — add ${missing.length > 1 ? 'them' : 'it'} ` +
|
||||
`to createWindRouter in main.js, or the game silently runs without ${missing.length > 1 ? 'those' : 'that'}`);
|
||||
|
||||
// Present isn't enough — a forwarded method has to actually reach the wind.
|
||||
const wrongType = Object.keys(real).filter((k) => typeof real[k] === 'function' && typeof router[k] !== 'function');
|
||||
assert(wrongType.length === 0, `router has ${wrongType.join(', ')} but not as callable(s)`);
|
||||
});
|
||||
|
||||
t.test('wind router delegates live — use() re-points every consumer at once', async () => {
|
||||
// The router's whole reason to exist: consumers bind once at construction,
|
||||
// so a storm swap has to be a re-point rather than a re-wire.
|
||||
const gentle = createWind(await loadStorm('storm_01_gentle'));
|
||||
const wild = createWind(await loadStorm('storm_02_wildnight'));
|
||||
const router = createWindRouter([gentle, wild]);
|
||||
const at = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
router.use(gentle);
|
||||
const calm = router.speedAt(at, 60);
|
||||
router.use(wild);
|
||||
const gale = router.speedAt(at, 60);
|
||||
assertLess(calm, gale, 'swapping to the wild night must change what consumers read');
|
||||
assertEq(router.def, wild.def, 'def follows the active storm (skyfx reads it at construction)');
|
||||
});
|
||||
|
||||
// --- camera --------------------------------------------------------------
|
||||
|
||||
t.test('camera keeps a clear line to the player from every angle', () => {
|
||||
|
||||
@ -19,6 +19,7 @@ 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 { SailRig, HARDWARE } from '../sail.js';
|
||||
import { weatherCases } from './weather.selftest.js';
|
||||
|
||||
// Keep in step with data/storms/. The node runner globs the directory, so this
|
||||
@ -265,6 +266,79 @@ export default async function run(t) {
|
||||
assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`);
|
||||
});
|
||||
|
||||
// --- SPRINT5 decision 13: hail makes the garden score respond to the rig ---
|
||||
// The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig,
|
||||
// because honest rain walks under a sail. Steep hail doesn't — a sail over the
|
||||
// bed shelters it. This is the gate-2 assert: no-sail hail damage ≥ 2× a good
|
||||
// rig's, over the real storm_02 hail, through the real gardenHailExposure.
|
||||
t.test('decision 13: no-sail garden takes ≥2× the hail of a well-covered bed', () => {
|
||||
const bed = { x: 1, z: 2, w: 6, d: 4 };
|
||||
|
||||
// a good rig: a quad sitting over the bed, held on rated shackles
|
||||
const s = 3.6;
|
||||
const anchors = [
|
||||
{ id: 'a', pos: { x: -2, y: s, z: -0.5 }, type: 'post', sway() { return this.pos; } },
|
||||
{ id: 'b', pos: { x: 4, y: s + 0.4, z: -0.5 }, type: 'post', sway() { return this.pos; } },
|
||||
{ id: 'c', pos: { x: 4, y: s, z: 4.5 }, type: 'post', sway() { return this.pos; } },
|
||||
{ id: 'd', pos: { x: -2, y: s + 0.4, z: 4.5 }, type: 'post', sway() { return this.pos; } },
|
||||
];
|
||||
const rig = new SailRig({ anchors, gridN: 10 });
|
||||
rig.attach(['a', 'b', 'c', 'd'], [2, 2, 2, 2].map((i) => HARDWARE[i]), 1.0);
|
||||
|
||||
const damageOverStorm = (getWorld, rigToStep) => {
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
camera.position.set(0, 6, 8);
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const sky = createSkyFx({ scene, camera, wind });
|
||||
let dmg = 0;
|
||||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||
if (rigToStep) rigToStep.step(dt, wind, time);
|
||||
sky.step(dt, time, getWorld());
|
||||
dmg += sky.gardenHailExposure(bed, time) * dt;
|
||||
});
|
||||
const cover = rigToStep ? rigToStep.coverageOver(bed, { x: 0, y: 1, z: 0 }) : 0;
|
||||
sky.dispose();
|
||||
return { dmg, cover };
|
||||
};
|
||||
|
||||
const open = damageOverStorm(() => ({}), null);
|
||||
const covered = damageOverStorm(() => ({ sail: rig }), rig);
|
||||
|
||||
// sanity: the rig must actually be over the bed, or this proves nothing
|
||||
assert(covered.cover > 0.6, `test rig only covers ${(covered.cover * 100).toFixed(0)}% of the bed — fix the quad, not the mechanic`);
|
||||
assert(open.dmg > 0, 'no hail damage in the open at all — storm_02 should be pelting');
|
||||
const ratio = open.dmg / Math.max(1e-6, covered.dmg);
|
||||
assert(ratio >= 2,
|
||||
`open bed took only ${ratio.toFixed(1)}× the hail of the covered one — decision 13 wants ≥2×`);
|
||||
});
|
||||
|
||||
t.test('hail exposure needs BOTH hail and no cover; steep shadow, unlike rain', () => {
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const sky = createSkyFx({ scene, camera, wind });
|
||||
const bed = { x: 0, z: 0, w: 4, d: 3 };
|
||||
// a flat panel well above the bed
|
||||
const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] };
|
||||
|
||||
// storm_01 has no hail: exposure is zero regardless of cover
|
||||
const calm = createWind(storms.storm_01_gentle);
|
||||
const skyCalm = createSkyFx({ scene: new THREE.Scene(), camera, wind: calm });
|
||||
fixedLoop(1, FIXED_DT, (dt, time) => skyCalm.step(dt, 40 + time, {}));
|
||||
assert(skyCalm.gardenHailExposure(bed, 40) === 0, 'a hail-free storm still reported hail exposure');
|
||||
skyCalm.dispose();
|
||||
|
||||
// at the wild night's hail peak, open bed is exposed; a panel over it is not
|
||||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, {}));
|
||||
const openExp = sky.gardenHailExposure(bed, 56);
|
||||
assert(openExp > 0.5, `open bed hail exposure only ${openExp.toFixed(2)} at the burst — should be high`);
|
||||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel }));
|
||||
const coveredExp = sky.gardenHailExposure(bed, 56);
|
||||
assert(coveredExp < openExp * 0.4, `steep hail still hit the covered bed: ${coveredExp.toFixed(2)} vs open ${openExp.toFixed(2)}`);
|
||||
sky.dispose();
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@ -343,6 +343,180 @@ export default async function run(t) {
|
||||
assertLess(bracedKnocks, exposedKnocks, 'and bracing through it is strictly better');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- the ladder (decision 12)
|
||||
// A fake ladder standing in for ladder.js's THREE half: same shape, no GLB, no scene. The real
|
||||
// one is verified by hand in the game; these pin the legs of the state machine.
|
||||
const fakeLadder = (opts = {}) => {
|
||||
const st = { placedAt: opts.placedAt || null, carried: false };
|
||||
return {
|
||||
needsLadder: (a) => !!a && a.type === 'house',
|
||||
workY: () => 2.35,
|
||||
// height only, mirroring the real ladder.js — see the note there on why testing for 'atTop'
|
||||
// here makes the repair cancel its own hold
|
||||
isWorking: (id) => st.placedAt === id && !!opts.player && opts.player.climbY > 2.2,
|
||||
servedAnchor: () => null,
|
||||
get placedAt() { return st.placedAt; },
|
||||
_place: (id) => { st.placedAt = id; },
|
||||
update() {}, dispose() {},
|
||||
};
|
||||
};
|
||||
|
||||
t.test('ladder: climb is code-driven and lands in a work stance', () => {
|
||||
const s = new PlayerSim();
|
||||
assertEq(s.climbY, 0, 'starts on the ground');
|
||||
s.climbTo(2.35);
|
||||
assertEq(s.state, 'climb', 'climbing');
|
||||
assert(s.busy, 'you cannot be interrupted mid-rung');
|
||||
assertEq(clipFor(s), 'ClimbLadder', 'and ClimbLadder plays');
|
||||
drive(s, 0.5);
|
||||
assert(s.climbY > 0.3 && s.climbY < 2.35, `partway up, got ${s.climbY.toFixed(2)}`);
|
||||
drive(s, 3);
|
||||
assertEq(s.state, 'atTop', 'arrives in the work stance');
|
||||
assertClose(s.climbY, 2.35, 1e-6, 'at the top rung');
|
||||
assert(!s.busy, 'atTop must NOT be busy — the whole point is that hold-E works up there');
|
||||
});
|
||||
|
||||
t.test('ladder: the fascia is out of reach from the ground and in reach from the top', () => {
|
||||
const s = new PlayerSim();
|
||||
const FASCIA_Y = 2.48; // measured in the real yard
|
||||
assertLess(s.reachY, FASCIA_Y, 'standing on the ground, a 1.72 m person cannot reach the bracket');
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
assert(s.reachY >= FASCIA_Y, `up the ladder they can, reach=${s.reachY.toFixed(2)}`);
|
||||
});
|
||||
|
||||
t.test('ladder: you cannot walk while you are on it', () => {
|
||||
const s = new PlayerSim();
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
assertEq(s.state, 'atTop');
|
||||
const x0 = s.pos.x, z0 = s.pos.z;
|
||||
drive(s, 1.5, { x: 1, z: 1, run: true, camYaw: 0 });
|
||||
assertClose(s.pos.x, x0, 1e-9, 'WASD does not walk you off a ladder');
|
||||
assertClose(s.pos.z, z0, 1e-9);
|
||||
assertEq(s.state, 'atTop', 'and you stay in the work stance');
|
||||
});
|
||||
|
||||
t.test('ladder: descending returns you to the ground and frees you', () => {
|
||||
const s = new PlayerSim();
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
s.climbTo(0);
|
||||
assertEq(s.state, 'climb', 'going down is the same clip');
|
||||
drive(s, 4);
|
||||
assertEq(s.state, 'idle', 'back on your feet');
|
||||
assertEq(s.climbY, 0);
|
||||
drive(s, 1, { x: 0, z: 1, camYaw: 0 });
|
||||
assertEq(s.state, 'walk', 'and walking again');
|
||||
});
|
||||
|
||||
t.test('ladder: you cannot brace up there — both hands are on the rungs', () => {
|
||||
const s = new PlayerSim();
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
// a wind under even the ladder's lowered bar, so this isolates the brace refusal from the fall
|
||||
drive(s, 1, { shelter: true }, windX(TUNE.knockWind * TUNE.ladderKnockMult - 4));
|
||||
assertEq(s.state, 'atTop', 'holding C on a ladder does nothing');
|
||||
});
|
||||
|
||||
t.test('ladder: the wind is meaner at height, and being blown off is a FALL', () => {
|
||||
// a wind that is survivable standing must be able to take you off the ladder
|
||||
const between = TUNE.knockWind * TUNE.ladderKnockMult + 2; // over the ladder bar, under the standing one
|
||||
assertLess(between, TUNE.knockWind, 'the test wind must be survivable on the ground');
|
||||
|
||||
const ground = new PlayerSim();
|
||||
drive(ground, TUNE.knockSustain + 0.5, {}, windX(between));
|
||||
assertEq(ground.state, 'idle', 'on your feet this wind is nothing');
|
||||
|
||||
const up = new PlayerSim();
|
||||
up.climbTo(2.35); drive(up, 4);
|
||||
up.carrying = 'spare';
|
||||
drive(up, TUNE.knockSustain + 0.3, {}, windX(between));
|
||||
assertEq(up.state, 'knocked', 'the same wind takes you off the ladder');
|
||||
assertEq(up.climbY, 0, 'you are on the ground now, not floating at height');
|
||||
assertClose(up.fellFrom, 2.35, 1e-6, 'and the fall height is recorded');
|
||||
assertEq(up.carrying, null, 'you dropped the spare on the way down');
|
||||
drive(up, 3);
|
||||
assertEq(up.state, 'idle', 'the ladder gets the normal get-up chain for free');
|
||||
});
|
||||
|
||||
t.test('ladder: needsLadder is scoped to the fascia, not to everything above head height', () => {
|
||||
const L = fakeLadder();
|
||||
assert(L.needsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it');
|
||||
assert(!L.needsLadder({ type: 'post', pos: { y: 3.95 } }),
|
||||
'a 4 m post does NOT — you tension it from a cleat at the base');
|
||||
assert(!L.needsLadder({ type: 'tree', pos: { y: 5.05 } }),
|
||||
'nor a tree limb — that is a strop you throw');
|
||||
});
|
||||
|
||||
t.test('ladder: fascia re-rig is gated on being up it; post re-rig is not', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const L = fakeLadder({ player: p });
|
||||
const anchors = [{ id: 'h2', type: 'house', pos: { x: 0, y: 2.48, z: 0.9 } },
|
||||
{ id: 'p1', type: 'post', pos: { x: 0, y: 3.95, z: 0 } }];
|
||||
const corners = [{ anchorId: 'h2', broken: true }, { anchorId: 'p1', broken: true }];
|
||||
let repaired = [];
|
||||
const it = new Interact();
|
||||
wireYardActions(it, {
|
||||
ladder: L,
|
||||
world: { anchors },
|
||||
sailRig: { corners, repair: (i) => repaired.push(i), trim: () => {},
|
||||
cornerPos: () => ({ x: 0, y: 0.4, z: 0 }) },
|
||||
});
|
||||
p.carrying = 'spare';
|
||||
|
||||
// the POST corner: repairable from the ground, exactly as it was before the ladder existed
|
||||
p.pos.x = 0; p.pos.z = 0;
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(repaired.includes(1), 'post corner still re-rigs from the ground — the ladder changed nothing here');
|
||||
|
||||
// the FASCIA corner: same spare, standing right under it, refused
|
||||
repaired = []; p.carrying = 'spare'; p.pos.x = 0; p.pos.z = 0.9;
|
||||
it.latched = false;
|
||||
const near = it.nearest(p);
|
||||
assert(!near || near.id !== 'rerig_0', 'standing under the bracket is not enough');
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(!repaired.includes(0), 'fascia re-rig refused from the ground');
|
||||
|
||||
// plant the ladder and climb it → now it lands
|
||||
L._place('h2');
|
||||
p.climbTo(2.35); drive(p, 4);
|
||||
assertEq(p.state, 'atTop');
|
||||
p.carrying = 'spare';
|
||||
it.latched = false;
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(repaired.includes(0), 'up the ladder, the fascia re-rig lands');
|
||||
assertEq(p.carrying, null, 'and it ate the spare');
|
||||
});
|
||||
|
||||
t.test('ladder: the scripted loop — carry, plant, fetch spare, climb, repair, descend', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const L = fakeLadder({ player: p });
|
||||
const anchors = [{ id: 'h2', type: 'house', pos: { x: 0, y: 2.48, z: 0.9 } }];
|
||||
const corners = [{ anchorId: 'h2', broken: true }];
|
||||
let repaired = false;
|
||||
const it = new Interact();
|
||||
wireYardActions(it, { ladder: L, world: { anchors },
|
||||
sailRig: { corners, repair: () => { repaired = true; }, trim: () => {},
|
||||
cornerPos: () => ({ x: 0, y: 0.4, z: 0 }) } });
|
||||
|
||||
// hands-full: the ladder and the spare compete for the same pair of hands
|
||||
assertEq(p.pickUp('ladder'), true, 'pick the ladder up');
|
||||
assertEq(p.pickUp('spare'), false, 'you cannot also carry a spare — that is the two-trip cost');
|
||||
assertEq(p.drop(), 'ladder', 'put it down');
|
||||
|
||||
// trip 2: the spare, then up
|
||||
assertEq(p.pickUp('spare'), true);
|
||||
L._place('h2');
|
||||
p.climbTo(L.workY()); drive(p, 4);
|
||||
assertEq(p.state, 'atTop', 'up the ladder with the spare');
|
||||
it.latched = false;
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(repaired, 'fascia repaired at height');
|
||||
assertEq(p.carrying, null, 'spare consumed');
|
||||
|
||||
// and back down
|
||||
p.climbTo(0); drive(p, 4);
|
||||
assertEq(p.state, 'idle', 'down and free');
|
||||
assertEq(p.climbY, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 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
|
||||
@ -476,6 +650,28 @@ export default async function run(t) {
|
||||
assertEq(sim.state, 'knocked', 'and the abort did not overwrite the knocked state');
|
||||
});
|
||||
|
||||
t.test('interact: a hold survives the busy transition it causes', () => {
|
||||
// The bug this pins bit twice while building the ladder, and is invisible: canUse is re-checked
|
||||
// every frame to keep the hold alive, and starting the hold sets state='busy' — so any canUse
|
||||
// reading player.state goes false on frame one and cancels itself. Prompt looks dead, no error.
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
let fired = 0;
|
||||
it.register({ id: 'ok', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
|
||||
canUse: (p) => p.climbY < 0.02, onDone: () => { fired++; } }); // physical gate — fine
|
||||
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(fired, 1, 'a physically-gated action completes');
|
||||
|
||||
const sim2 = new PlayerSim();
|
||||
const it2 = new Interact();
|
||||
let fired2 = 0;
|
||||
it2.register({ id: 'trap', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
|
||||
canUse: (p) => p.state === 'idle', onDone: () => { fired2++; } }); // state gate — the trap
|
||||
fixedLoop(1, DT, (dt, tt) => it2.step(dt, tt, sim2, true));
|
||||
assertEq(fired2, 0,
|
||||
'a state-gated canUse cancels its own hold — documented on register(); gate on physical facts');
|
||||
});
|
||||
|
||||
t.test('interact: canUse() gates on the carrying flag', () => {
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
|
||||
@ -75,8 +75,10 @@ const ASSETS = [
|
||||
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
|
||||
{ name: 'garden_gnome_01_broken', h: [0.08, 0.20],
|
||||
nodes: ['stump', 'head', 'hat', 'shards'] },
|
||||
{ name: 'fence_panel_broken', h: [1.70, 1.90],
|
||||
{ name: 'fence_panel_snapped', h: [1.70, 1.90],
|
||||
nodes: ['palings', 'rails', 'debris_palings'] },
|
||||
{ name: 'broom_01', h: [1.35, 1.50],
|
||||
nodes: ['handle', 'head', 'bristles', 'grip_anchor', 'poke_tip'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -268,7 +270,7 @@ export default async function run(t) {
|
||||
// swap needs a fudge offset per prop and will grow one.
|
||||
t.test('broken variants sit on the same ground plane as their intact twin', () => {
|
||||
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
|
||||
['fence_panel', 'fence_panel_broken']]) {
|
||||
['fence_panel', 'fence_panel_snapped']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
assert(Math.abs(box.min.y) < 0.03,
|
||||
|
||||
@ -504,6 +504,101 @@ export function weatherCases(storms) {
|
||||
assert(!validateStorm(hot, 'x').ok, 'validator accepted rain intensity above 1 — the scale is peakMmPerHour, not the curve');
|
||||
});
|
||||
|
||||
// ---- 11. hail (SPRINT5 decision 13) ----
|
||||
// The wind-side of hail: intensity timeline and its ramp across the campaign.
|
||||
// The garden-damage half (steep shadow, ≥2× rig response) needs skyfx +
|
||||
// SailRig, so it lives in c.test.js.
|
||||
test('hail ramps across the campaign: none / mild / a wild-night burst', () => {
|
||||
const peakAndArea = (def) => {
|
||||
const f = createWindField(def);
|
||||
let peak = 0, peakT = 0, area = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
const h = f.hailAt(t);
|
||||
if (h > peak) { peak = h; peakT = t; }
|
||||
area += h * DT;
|
||||
}
|
||||
return { peak, peakT, area };
|
||||
};
|
||||
const g = peakAndArea(storms.storm_01_gentle);
|
||||
const m = peakAndArea(storms.storm_03_southerly);
|
||||
const w = peakAndArea(storms.storm_02_wildnight);
|
||||
metrics['storm_01.hailPeak'] = +g.peak.toFixed(2);
|
||||
metrics['storm_03.hailPeak'] = +m.peak.toFixed(2);
|
||||
metrics['storm_02.hailPeak'] = +w.peak.toFixed(2);
|
||||
metrics['storm_02.hailSeconds'] = +w.area.toFixed(1);
|
||||
|
||||
assert(g.peak === 0, `the gentle storm hailed (${g.peak}) — storm_01 must have no hail`);
|
||||
assert(m.peak > 0.2 && m.peak < 0.8, `storm_03 hail peak ${m.peak.toFixed(2)} — wanted a mild middle rung`);
|
||||
assert(w.peak >= 0.95, `storm_02 hail peak only ${w.peak.toFixed(2)} — the wild night needs a proper burst`);
|
||||
assert(w.area > m.area * 3, 'the wild night should carry vastly more hail than the mild storm');
|
||||
});
|
||||
|
||||
test('storm_02 hail lands ON the southerly change', () => {
|
||||
const change = (storms.storm_02_wildnight.events.find((e) => e.type === 'windchange')).t;
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
// find the authored-burst peak (near the change), not just any gust-synced tick
|
||||
let peak = 0, peakT = 0;
|
||||
for (let t = change - 3; t <= change + 8; t += DT) {
|
||||
const h = f.hailAt(t);
|
||||
if (h > peak) { peak = h; peakT = t; }
|
||||
}
|
||||
assert(peak >= 0.95, `hail near the change only reached ${peak.toFixed(2)}`);
|
||||
assert(Math.abs(peakT - change) < 6, `hail peaks at t=${peakT.toFixed(1)}, the change is at t=${change} — they should coincide`);
|
||||
});
|
||||
|
||||
test('hail is silent through a gust telegraph and rides its own gust', () => {
|
||||
// the gust-synced bursts must not fire during the telegraph window, or hail
|
||||
// would arrive before the gust it belongs to — the opposite of the drama
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def);
|
||||
const thresh = def.hail.withGustsAbove;
|
||||
for (const g of f.gusts) {
|
||||
if (g.pow < thresh) continue;
|
||||
// during the telegraph (first GUST.TELEGRAPH s) the gust-synced part is 0.
|
||||
// an authored burst may still overlap, so only check gusts clear of t=55±8.
|
||||
if (Math.abs(g.t0 - 55) < 10) continue;
|
||||
for (let t = g.t0 + 0.05; t < g.t0 + GUST.TELEGRAPH; t += DT) {
|
||||
assert(f.hailAt(t) < 1e-9, `hail fell during a gust's telegraph at t=${t.toFixed(2)} — it should wait for the gust`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('hail does not re-time the storm (zero draws of its own)', () => {
|
||||
const base = storms.storm_02_wildnight;
|
||||
const a = createWindField(base);
|
||||
for (const gi of [0, 0.3, 0.85, 1]) {
|
||||
const d = JSON.parse(JSON.stringify(base));
|
||||
d.hail.gustBurstIntensity = gi;
|
||||
d.hail.bursts = []; // even removing the authored burst mustn't move gusts
|
||||
const b = createWindField(d);
|
||||
assert(a.gusts.length === b.gusts.length, `hail change moved the gust count`);
|
||||
a.gusts.forEach((g, i) => {
|
||||
assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `hail change re-timed gust ${i}`);
|
||||
});
|
||||
assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), 'hail change altered the wind');
|
||||
}
|
||||
});
|
||||
|
||||
test('validator rejects broken hail blocks', () => {
|
||||
const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
const cases = [
|
||||
['size 0', (d) => { d.hail.size = 0; }],
|
||||
['size huge', (d) => { d.hail.size = 20; }],
|
||||
['intensity > 1', (d) => { d.hail.bursts[0].intensity = 1.5; }],
|
||||
['negative fade', (d) => { d.hail.bursts[0].fade = -1; }],
|
||||
['burst after the storm', (d) => { d.hail.bursts[0].t = 200; }],
|
||||
['gustBurstIntensity > 1', (d) => { d.hail.gustBurstIntensity = 2; }],
|
||||
['neither bursts nor gusts', (d) => { delete d.hail.bursts; delete d.hail.withGustsAbove; }],
|
||||
];
|
||||
for (const [label, mutate] of cases) {
|
||||
const d = base(); mutate(d);
|
||||
assert(!validateStorm(d, 'broken').ok, `validator accepted broken hail: ${label}`);
|
||||
}
|
||||
// a hail-free storm (no block at all) must still validate
|
||||
const none = base(); delete none.hail;
|
||||
assert(validateStorm(none, 'nohail').ok, 'validator rejected a storm with no hail — hail is optional');
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
|
||||
@ -167,6 +167,27 @@ export function buildGustTimeline(def, seed) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- hail (SPRINT5 decision 13) ----------
|
||||
// Hail, not rain, carries the garden score. Rain honestly walks under a sail
|
||||
// (droplets terminal ~9 m/s, so a 30 m/s crosswind blows them in at atan(30/9)
|
||||
// ≈ 73° off vertical — nearly sideways), which is why a perfect rig scored 54%
|
||||
// vs 48% for no rig at all. Hailstones are dense: terminal velocity ~20 m/s for
|
||||
// a 1 cm stone, so the SAME 30 m/s gale only leans them atan(30/20)≈56° — and
|
||||
// that overstates it, because a dense stone's drag couples weakly to the
|
||||
// horizontal air, so observed hail lean tops out ~15-20°. Steep hail is blocked
|
||||
// by overhead cloth even in a gale, so the garden score becomes rig-responsive
|
||||
// without faking the rain physics. The steepness lives in skyfx.hailVelocity();
|
||||
// the intensity timeline lives here.
|
||||
|
||||
/** Envelope of one authored hail burst at local time `dt` (s since it began). */
|
||||
export function hailBurstEnvelope(dt, ramp, hold, fade, peak) {
|
||||
if (dt <= 0) return 0;
|
||||
if (dt < ramp) return peak * (dt / ramp);
|
||||
if (dt < ramp + hold) return peak;
|
||||
if (dt < ramp + hold + fade) return peak * (1 - (dt - ramp - hold) / fade);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------- the field ----------
|
||||
/**
|
||||
* @param {object} def parsed storm JSON (see data/storms/*.json)
|
||||
@ -189,6 +210,19 @@ export function createWindField(def, opts = {}) {
|
||||
const gd = def.gusts || {};
|
||||
const downFrac = gd.downdraftOfTotal ?? gd.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
|
||||
// Hail bursts: authored ones from the JSON, plus one synced to every gust at
|
||||
// or above `withGustsAbove` power — so the biggest gusts arrive WITH hail, the
|
||||
// storm's worst moment landing all at once. Gust-synced bursts key off the
|
||||
// deterministic gust timeline and draw NO randomness, so tuning hail can't
|
||||
// re-time the storm (same guarantee as the downdraft).
|
||||
const hailDef = def.hail || null;
|
||||
const hailBursts = (hailDef && Array.isArray(hailDef.bursts))
|
||||
? hailDef.bursts.map((b) => ({
|
||||
t: b.t, ramp: b.ramp ?? 0.8, hold: b.hold ?? 3, fade: b.fade ?? 1.5,
|
||||
intensity: b.intensity ?? 1,
|
||||
}))
|
||||
: [];
|
||||
|
||||
let shelters = [];
|
||||
|
||||
/** Spatially-uniform part: base curve + every gust envelope live at t. */
|
||||
@ -398,6 +432,42 @@ export function createWindField(def, opts = {}) {
|
||||
return Math.min(1, Math.max(0, r.intensity ?? 0));
|
||||
},
|
||||
|
||||
/**
|
||||
* 0..1 hail intensity at time t (SPRINT5 decision 13). Max over every live
|
||||
* burst — authored plus gust-synced — because "how hard is it hailing right
|
||||
* now" is a level, not a sum; two overlapping bursts don't hail at 1.6×.
|
||||
* Zero for a storm with no `hail` block, so storm_01 simply never hails.
|
||||
*/
|
||||
hailAt(t) {
|
||||
if (!hailDef) return 0;
|
||||
let h = 0;
|
||||
for (let i = 0; i < hailBursts.length; i++) {
|
||||
const b = hailBursts[i];
|
||||
if (t <= b.t || t >= b.t + b.ramp + b.hold + b.fade) continue;
|
||||
const v = hailBurstEnvelope(t - b.t, b.ramp, b.hold, b.fade, b.intensity);
|
||||
if (v > h) h = v;
|
||||
}
|
||||
const thresh = hailDef.withGustsAbove;
|
||||
if (thresh != null) {
|
||||
const gi = hailDef.gustBurstIntensity ?? 0.8;
|
||||
for (let i = 0; i < gusts.length; i++) {
|
||||
const g = gusts[i];
|
||||
if (g.pow < thresh) continue;
|
||||
if (t <= g.t0 || t >= g.endAt) continue;
|
||||
// reuse the gust envelope: silent through the telegraph, then it hits
|
||||
// as the gust ramps and holds — the hail lands WITH the gust.
|
||||
// gustEnvelope(gt, pow) returns pow×fraction, so passing gi as "pow"
|
||||
// gives the intensity-scaled 0..gi envelope directly.
|
||||
const v = gustEnvelope(t - g.t0, gi);
|
||||
if (v > h) h = v;
|
||||
}
|
||||
}
|
||||
return h > 1 ? 1 : h;
|
||||
},
|
||||
|
||||
/** Stone-size scalar (audio pitch, visual scale, damage weight). Default 1. */
|
||||
get hailSize() { return hailDef ? (hailDef.size ?? 1) : 0; },
|
||||
|
||||
/**
|
||||
* Rain rate in REAL-WORLD mm/hr. Same curve as rainAt(), with physical units
|
||||
* on it — `rainAt` stays 0..1 because it drives drop count and opacity, and a
|
||||
@ -520,5 +590,34 @@ export function validateStorm(def, name = 'storm') {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- hail (SPRINT5 decision 13) ----
|
||||
if (def.hail) {
|
||||
const hd = def.hail;
|
||||
if (hd.size != null && (!Number.isFinite(hd.size) || hd.size <= 0 || hd.size > 5)) {
|
||||
bad(`hail.size must be a positive scalar up to ~5 (stones bigger than golf balls break the metaphor) — got ${hd.size}`);
|
||||
}
|
||||
if (hd.withGustsAbove != null && !Number.isFinite(hd.withGustsAbove)) {
|
||||
bad('hail.withGustsAbove must be a finite gust-power threshold in m/s');
|
||||
}
|
||||
if (hd.gustBurstIntensity != null && (!(hd.gustBurstIntensity >= 0) || hd.gustBurstIntensity > 1)) {
|
||||
bad(`hail.gustBurstIntensity must be 0..1 — got ${hd.gustBurstIntensity}`);
|
||||
}
|
||||
// A storm with a hail block but nothing to fire it never hails — that's a
|
||||
// typo, not a design, so say so rather than shipping silent hail.
|
||||
if (!Array.isArray(hd.bursts) && hd.withGustsAbove == null) {
|
||||
bad('hail block has neither bursts[] nor withGustsAbove — it would never hail');
|
||||
}
|
||||
for (const b of hd.bursts || []) {
|
||||
if (!Number.isFinite(b.t)) bad(`hail burst ${JSON.stringify(b)} has no finite t`);
|
||||
if (b.t + 0 > (def.duration ?? 90)) bad(`hail burst at t=${b.t} starts after the storm ends`);
|
||||
for (const k of ['ramp', 'hold', 'fade']) {
|
||||
if (b[k] != null && !(b[k] >= 0)) bad(`hail burst at t=${b.t} has a negative ${k}`);
|
||||
}
|
||||
if (b.intensity != null && (!(b.intensity >= 0) || b.intensity > 1)) {
|
||||
bad(`hail burst at t=${b.t} intensity must be 0..1 — got ${b.intensity}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@ -87,6 +87,12 @@ export function createWind(def, opts = {}) {
|
||||
/** 0..1 rain intensity — drives drop count and opacity. */
|
||||
rainAt(t) { return field.rainAt(t); },
|
||||
|
||||
/** 0..1 hail intensity (SPRINT5 decision 13). Zero for a hail-free storm. */
|
||||
hailAt(t) { return field.hailAt(t); },
|
||||
|
||||
/** Stone-size scalar — audio pitch, visual scale, damage weight. */
|
||||
get hailSize() { return field.hailSize; },
|
||||
|
||||
/** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */
|
||||
rainMmPerHour(t) { return field.rainMmPerHour(t); },
|
||||
|
||||
|
||||
@ -45,6 +45,11 @@ const SHED_TABLE = { x: 9, z: 6, rotY: -Math.PI / 2 };
|
||||
// the same line the graybox taught everyone to expect.
|
||||
const HOUSE = { x: 0, z: -10.5 };
|
||||
|
||||
// The gnome stands just off the bed's south-east corner — inside the footprint
|
||||
// of most rigs, so a sail that lets go has something of the client's to land on.
|
||||
// DESIGN.md: your own failure is the worst debris of all.
|
||||
const GNOME = { x: 4.3, z: 4.4, rotY: -2.2, collateralValue: 25 };
|
||||
|
||||
// Sun: mid-afternoon, high and off the north-west shoulder. Elevation 55°.
|
||||
// Stored as the direction from the GROUND toward the SUN (see contracts.js).
|
||||
const SUN_ELEV = (55 * Math.PI) / 180;
|
||||
@ -95,7 +100,13 @@ export function createWorld(scene, opts = {}) {
|
||||
/** @type {{group: THREE.Object3D, phase: number, base: THREE.Euler}[]} */
|
||||
const canopies = [];
|
||||
/** Graybox stand-ins, kept so dress() can retire them once E's GLBs load. */
|
||||
const graybox = { house: null, trees: new Map() };
|
||||
const graybox = { house: null, trees: new Map(), bed: null };
|
||||
/**
|
||||
* E's three wilt states, siblings in one GLB — toggled by visibility rather
|
||||
* than reloaded, which is why they all ship together.
|
||||
* @type {{full: THREE.Object3D, tattered: THREE.Object3D, dead: THREE.Object3D}|null}
|
||||
*/
|
||||
let plants = null;
|
||||
|
||||
// --- sky & light -------------------------------------------------------
|
||||
// Calm-day only. Lane C's skyfx.js takes over the sky and this becomes the
|
||||
@ -329,6 +340,7 @@ export function createWorld(scene, opts = {}) {
|
||||
}
|
||||
}
|
||||
root.add(bed);
|
||||
graybox.bed = bed;
|
||||
|
||||
// --- boundary fence ----------------------------------------------------
|
||||
// East, south and west only: the house is the north boundary.
|
||||
@ -379,6 +391,18 @@ export function createWorld(scene, opts = {}) {
|
||||
pos: new THREE.Vector3(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z) + 0.9, SHED_TABLE.z),
|
||||
};
|
||||
|
||||
/**
|
||||
* Show one of E's three wilt states. No-op against the graybox bed, so the
|
||||
* HUD can call it unconditionally.
|
||||
* @param {'full'|'tattered'|'dead'} which
|
||||
*/
|
||||
function setPlants(which) {
|
||||
if (!plants) return;
|
||||
for (const [k, node] of Object.entries(plants)) {
|
||||
if (node) node.visible = k === which;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap Lane E's GLBs in over the graybox. Async and separate from
|
||||
* createWorld() on purpose: the selftest builds a yard with no server, and a
|
||||
@ -432,11 +456,34 @@ export function createWorld(scene, opts = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const [shed, table, houseGlb, tree1, tree2] = await Promise.all([
|
||||
const [shed, table, houseGlb, tree1, tree2, bedGlb, gnome] = await Promise.all([
|
||||
load('shed_01_v1'), load('shed_table_v1'), load('house_yardside_v1'),
|
||||
load('tree_gum_01_v1'), load('tree_gum_02_v1'),
|
||||
load('tree_gum_01_v1'), load('tree_gum_02_v1'), load('garden_bed_v1'),
|
||||
load('garden_gnome_01_v1'),
|
||||
]);
|
||||
|
||||
// --- garden bed ------------------------------------------------------
|
||||
if (bedGlb) {
|
||||
retire(graybox.bed);
|
||||
bedGlb.name = 'garden_bed';
|
||||
bedGlb.position.set(GARDEN_BED.x, heightAt(GARDEN_BED.x, GARDEN_BED.z), GARDEN_BED.z);
|
||||
root.add(bedGlb);
|
||||
const pick = (n) => bedGlb.getObjectByName(n) ?? null;
|
||||
plants = { full: pick('plants_full'), tattered: pick('plants_tattered'), dead: pick('plants_dead') };
|
||||
setPlants('full');
|
||||
}
|
||||
|
||||
// --- gnome (aftermath collateral bait) -------------------------------
|
||||
// Placed under the sail's likely footprint on purpose: DESIGN.md wants your
|
||||
// own failures to be the worst debris, and something breakable has to be
|
||||
// standing there for that to land.
|
||||
if (gnome) {
|
||||
gnome.name = 'garden_gnome_01';
|
||||
gnome.position.set(GNOME.x, heightAt(GNOME.x, GNOME.z), GNOME.z);
|
||||
gnome.rotation.y = GNOME.rotY;
|
||||
root.add(gnome);
|
||||
}
|
||||
|
||||
// --- house (decision 6: no re-cut, the GLB's data wins) ---------------
|
||||
// E's fascia sits at 2.80 m and their anchors span x=-3..3, where my
|
||||
// graybox guessed 2.6 m and -5..5. Reading them narrows the house span by
|
||||
@ -532,6 +579,10 @@ export function createWorld(scene, opts = {}) {
|
||||
*/
|
||||
shedTable,
|
||||
dress,
|
||||
/** @param {'full'|'tattered'|'dead'} which */
|
||||
setPlants,
|
||||
/** Where the breakable client property stands, and what breaking it costs. */
|
||||
gnome: GNOME,
|
||||
// Lane C's skyfx MODULATES these as the storm builds and hands them back
|
||||
// untouched on dispose() — it doesn't own them. That's why the yard exposes
|
||||
// its lights rather than keeping them private.
|
||||
|
||||
BIN
web/world/models/broom_01_v1.glb
Normal file
BIN
web/world/models/broom_01_v1.glb
Normal file
Binary file not shown.
Binary file not shown.
BIN
web/world/models/fence_panel_snapped_v1.glb
Normal file
BIN
web/world/models/fence_panel_snapped_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/textures/pond_normal.png
Normal file
BIN
web/world/models/textures/pond_normal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
BIN
web/world/models/textures/pond_water.png
Normal file
BIN
web/world/models/textures/pond_water.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Loading…
Reference in New Issue
Block a user