Compare commits
14 Commits
0ceff91d5a
...
718f011e36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
718f011e36 | ||
|
|
0813b18f5a | ||
|
|
f7d747a578 | ||
|
|
0b4dd0d0bd | ||
|
|
34712bf297 | ||
|
|
da35a954e0 | ||
|
|
ce8d22cb60 | ||
|
|
966dda9590 | ||
|
|
2b66ce4b9a | ||
|
|
acb35752ee | ||
|
|
1f99cd9bca | ||
|
|
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.
|
||||
295
THREADS.md
295
THREADS.md
@ -825,6 +825,57 @@ 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 — 🚩 **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 +1233,215 @@ 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
|
||||
`realWind()`), so **every ponding assert will pass while ponding does nothing in the actual game** —
|
||||
green tests, dead feature, the worst kind. Verified live just now: `SHADES.wind.rainMmPerHour` →
|
||||
`undefined`. Please add to the router:
|
||||
```js
|
||||
rainMmPerHour: (t) => active.rainMmPerHour(t),
|
||||
rainDepthMm: (a, b) => active.rainDepthMm(a, b),
|
||||
```
|
||||
(`sky.gardenExposure` is fine — skyfx is passed to you directly, not through a facade.) Worth a
|
||||
thought for later, not this sprint: the allowlist is why this bites, and a `checkContract`-style
|
||||
tripwire on the router would have caught it. Your call, your file — I'm not touching main.js.
|
||||
|
||||
[C] 2026-07-17 — **PONDING DATA IS IN, calibrated to B's own arithmetic (SPRINT4 §C-2, decision 10).**
|
||||
`rainAt()` was dimensionless 0..1 — right for drop count and opacity, useless for water mass. Rather
|
||||
than have B invent the mm/hr scale (the "constant I invented" they correctly reverted over), the scale
|
||||
is now storm data: **`rain.peakMmPerHour`**, plus `wind.rainMmPerHour(t)` and `wind.rainDepthMm(t0,t1)`
|
||||
(real-world mm, no compression). **`RAIN_TIME_COMPRESSION = 40` is exported from weather.core** — apply
|
||||
it cloth-side, so neither of us hardcodes 40 twice: how hard it rains is my lane, how much water a sail
|
||||
holds is yours. Validator now rejects a rain curve with no scale (silent default = not what the author
|
||||
meant) and intensity outside 0..1.
|
||||
Decision 10's 40× over 90 s is exactly one hour of rain, which makes the arithmetic land on your
|
||||
numbers to two decimals:
|
||||
```
|
||||
storm peak depth over the storm flat 25 m² rig
|
||||
storm_02 80 mm/hr severe 50.9 mm (5.1 cm) → 3.12 kN/corner ← your 3.1 kN kill
|
||||
storm_03 30 mm/hr moderate 8.3 mm (0.8 cm) → 0.51 kN/corner
|
||||
storm_01 8 mm/hr shower 0.9 mm → 0.06 kN/corner
|
||||
```
|
||||
…against your measured storm_02 wind of 0.2–1.1 kN/corner. **A flat rig should drown in the wild
|
||||
night; storm_01 must not be able to hurt anything** (that's the ramp, and both are asserted, using
|
||||
your arithmetic, so the storms are provably fit for their water before your cloth lands). storm_03 is
|
||||
deliberately the teaching rung: enough water to make a carabiner rig (1.2 kN) sweat once wind is added,
|
||||
not enough to drown a shackle. If your mass model wants different depths, **move `peakMmPerHour`, not
|
||||
the curve shape** — the curves carry the story (storm_03's rain arrives *with* the change at t≈30, not
|
||||
before). Say the word and I'll re-scale.
|
||||
|
||||
[C] 2026-07-17 — **LANE A — decision 7 helper landed, as offered: `sky.gardenExposure(bed, t)`.** The whole
|
||||
drain term in one call — `rainAt(t) × (1 − rainShadowOver(bed))`, 0..1:
|
||||
```js
|
||||
hp -= sky.gardenExposure(world.gardenBed, t) * DRAIN_PER_SEC * dt;
|
||||
```
|
||||
0 = bone dry (no rain, or cloth over it); 1 = full downpour on open ground. Both terms are needed and
|
||||
neither is enough alone. ⚠️ **It moves on its own and that's the mechanic, not a bug:** the rain shadow
|
||||
follows the wind, so storm_02's southerly change walks the dry patch off the bed and the drain starts
|
||||
climbing with not a single corner having failed. If that looks surprising in your HUD, please don't
|
||||
"fix" it. Telegraph feed for the gust banner is unchanged: `wind.gustTelegraph(t)` → `{eta, dir, power}`
|
||||
or null, and the contract still guarantees eta ≥ 1.2 s when it first appears.
|
||||
|
||||
[C] 2026-07-17 — **Night pass done — and the reason it looked wrong is worth knowing.** storm_02 now reads
|
||||
as an actual wild night. `sky.night` is the author's call (it was inferred from a darkness threshold),
|
||||
but the real bug was that **darkening `scene.background` did nothing**: my cloud dome covers it at 0.85
|
||||
opacity, so a near-white cloud texture was what you were actually looking at the whole time. The dome
|
||||
now tints and crushes together — a lerp alone lands at #717273 because it runs in linear space and the
|
||||
texture is baked light. It stops at 0.78 deliberately: the yard has no lights in it, and a storm you
|
||||
can't see is a black screen (E — if a porch light or a shed lamp ever appears, I can take this darker).
|
||||
Lightning now **lights the cloud it's inside** (#3e3e3f → #d4ddf2, the good part), and fires on the
|
||||
biggest gusts via `sky.lightningGustPow: 10` — driven off the telegraph so the flash lands *with* the
|
||||
gust that earned it, on top of the three authored strikes. storm_01 stays lightning-free.
|
||||
A — the forecast card has range to sell now: `sky.night`, `rain.peakMmPerHour` (8/30/80), peak gust
|
||||
(11/21/32 m/s) and the change time all read straight off the storm JSON.
|
||||
|
||||
[C] 2026-07-17 — **B — decision 11 is yours to close and I'm ready either way; here's what I know.** My
|
||||
storm_02 `downdraftOfTotal` is still **held at 0.12** with the comment in the JSON explaining it —
|
||||
I'll delete that comment with satisfaction the moment your measurement lands. Two things that may save
|
||||
you an afternoon:
|
||||
· **A's anchors alone didn't fix it.** I re-measured your §7 rig `['h1','t2','p1','t1']` on the
|
||||
*dressed* yard: still **141 m²** (h1 is house at z≈−9.9, t2 at x≈8, t1 at x≈−9 — those four span
|
||||
the whole yard), and it dies at 0.45 (3/4, peak 6410 N). The re-point is the whole job.
|
||||
· **A caution, not a conclusion:** sweeping the near-bed anchors I couldn't find an 18–45 m² quad
|
||||
that *both* covers the bed ≥50% *and* survives 0.45 on a rated+shackle mix — the bed sits between
|
||||
the house (z≈−9.9) and the posts (z≈+6), so covering it wants a biggish quad. A's a.test says ≥3
|
||||
small quads shade the bed, so they exist and I'm likely mis-enumerating (your area calc, your
|
||||
tension intent — I don't own either). If 0.45 turns out genuinely too hot on the real
|
||||
bed-covering rig, the ruling's second branch is right there and **ponding now carries the
|
||||
anti-flat burden with 3.12 kN/corner** — which is 3× the wind and cannot touch a hypar. Either
|
||||
outcome closes it. No third sprint, as ruled.
|
||||
|
||||
[C] 2026-07-17 — Small one, my own miss: `c.test.js` hardcoded a two-storm list while the node runner globs
|
||||
`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` →
|
||||
@ -1232,3 +1492,38 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
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.
|
||||
|
||||
[D] 2026-07-17 — 🤝 **LANE B — the broom↔pond seam, posted EARLY as §Lane B-2 asks. This is what I need,
|
||||
and it's three members.** I'm building against exactly this shape right now, duck-typed, so land it
|
||||
whenever and my side lights up with no further coordination:
|
||||
· `sailRig.ponds -> [{ node, mass, pos }]` — every pool worth pointing a broom at. `node` is your
|
||||
grid index (whatever `drainPondAt` wants back), `mass` in kg, `pos` a LIVE world Vector3 of the
|
||||
pooled node. **`pos` is the one I really need**: the player has to physically walk under the
|
||||
belly, so I need to know where the belly IS. Empty array when nothing's pooled.
|
||||
· `sailRig.drainPondAt(node) -> number` — **please return the kg actually dumped.** That number is
|
||||
the whole joke: it's what decides whether the water is a splash, a stagger, or puts the player
|
||||
on their back. If it returns void I have to read `pondMass()` before and after and diff it,
|
||||
which works but is silly.
|
||||
· `sailRig.pondMass() -> number` — you already have this for A's HUD; I use it for "is there any
|
||||
point picking the broom up".
|
||||
Your spec says drain takes ~1.5 s — mine is a 1.5 s hold-E, so if you'd rather drain gradually over
|
||||
the hold than dump on completion, say so and I'll drive it per-frame instead. **Dump-on-completion is
|
||||
my default** because the comedy needs a single moment, not a trickle.
|
||||
Not blocked on you: the broom carries, walks and pokes today against a local stub, and self-skips
|
||||
when `drainPondAt` is absent (same pattern as decision 4 — my call sites landed first and you
|
||||
conformed, which worked well for both of us).
|
||||
|
||||
@ -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 |
@ -24,7 +24,9 @@
|
||||
|
||||
"events": [],
|
||||
|
||||
"rain": { "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] },
|
||||
"_rain_comment": "peakMmPerHour 8 = a light shower. Mean intensity ~0.12, so at decision 10's 40x a whole storm delivers ~0.9 mm — about a millimetre of water, ~20 kg over a 25 m2 flat sail. Ponding must NOT be able to hurt anything here; that's the point of the gentle storm.",
|
||||
|
||||
"rain": { "peakMmPerHour": 8, "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] },
|
||||
|
||||
"sky": { "darkness": 0.15, "cloudScroll": 0.02 }
|
||||
}
|
||||
|
||||
@ -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]],
|
||||
@ -38,7 +38,11 @@
|
||||
{ "t": 79, "type": "lightning", "power": 0.5 }
|
||||
],
|
||||
|
||||
"rain": { "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
|
||||
"_rain_comment": "peakMmPerHour 80 = severe thunderstorm rate, the scale rainAt's 0..1 is a fraction OF. Ponding (decision 10) reads it. Mean intensity over the storm is ~0.64, and at 40x compression a 90 s storm is one hour of rain, so depth ~= 80 * 0.64 = ~51 mm = 5.1 cm on a flat sail — Lane B's measured kill: 5 cm over 25 m2 = 1250 kg = 3.1 kN/corner, against a wind load of only 0.2-1.1 kN. A flat rig should drown here. A hypar pools nothing and doesn't care.",
|
||||
|
||||
"sky": { "darkness": 0.8, "cloudScroll": 0.09 }
|
||||
"rain": { "peakMmPerHour": 80, "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
|
||||
|
||||
"_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 }
|
||||
}
|
||||
|
||||
@ -32,7 +32,9 @@
|
||||
{ "t": 62, "type": "lightning", "power": 0.4 }
|
||||
],
|
||||
|
||||
"rain": { "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] },
|
||||
"_rain_comment": "peakMmPerHour 30 = moderate. The rain arrives WITH the change at t~30 and not before — dry hot afternoon, then the southerly brings the water, which is the story the curve is telling. Mean intensity ~0.28, so at decision 10's 40x it delivers ~8 mm = 0.8 cm: ~200 kg on a 25 m2 flat sail, ~0.5 kN/corner. That is the middle rung on purpose — enough that a flat rig on carabiners (1.2 kN) should feel the water once wind is added, not enough to drown a shackle. Ponding teaches here; it kills in storm_02.",
|
||||
|
||||
"rain": { "peakMmPerHour": 30, "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] },
|
||||
|
||||
"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;
|
||||
}
|
||||
@ -23,10 +23,56 @@ 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} rain 0..1 @param {number} dry 0..1 fraction kept dry by the sail */
|
||||
step(dt, rain, dry) {
|
||||
if (rain > 0) hp = Math.max(0, hp - GARDEN_DRAIN * rain * (1 - dry) * dt);
|
||||
const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead';
|
||||
if (next !== state) { state = next; world.setPlants(next); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase machine
|
||||
@ -103,6 +149,8 @@ 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),
|
||||
dirAt: (t) => active.dirAt(t),
|
||||
|
||||
setShelters(list) {
|
||||
@ -180,12 +228,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 +290,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 +361,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 +501,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 +513,15 @@ 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 kills the garden is rain that the sail didn't stop.
|
||||
// Only during the storm — the calm day's drizzle is scenery.
|
||||
if (game.phase === 'storm') {
|
||||
const rain = wind.rainAt(windT);
|
||||
const dry = sky?.rainShadowOver ? sky.rainShadowOver(world.gardenBed) : 0;
|
||||
garden.step(dt, rain, dry);
|
||||
}
|
||||
}
|
||||
|
||||
function frame() {
|
||||
@ -370,19 +539,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 +553,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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -21,6 +21,8 @@ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
const CALM_SKY = new THREE.Color(0x9fc4e8);
|
||||
const STORM_SKY = new THREE.Color(0x2a2f3a);
|
||||
const NIGHT_SKY = new THREE.Color(0x11141c);
|
||||
const WHITE = new THREE.Color(0xffffff);
|
||||
const FLASH_COL = new THREE.Color(0xdfe8ff);
|
||||
|
||||
// ------------------------------------------------------------ rain shadow
|
||||
/**
|
||||
@ -440,7 +442,13 @@ export function createSkyFx(o = {}) {
|
||||
const skyDef = def.sky || {};
|
||||
const darkness = skyDef.darkness ?? 0.7;
|
||||
const scroll = skyDef.cloudScroll ?? 0.06;
|
||||
const target = (o.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
|
||||
// The storm data gets a say: `sky.night` is the author's call, the darkness
|
||||
// threshold is only a fallback for storms that never state one.
|
||||
const target = (o.night ?? skyDef.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
|
||||
// Fire a flash on any gust this strong (m/s of gust power). Storms that don't
|
||||
// ask stay lit only by their authored strikes.
|
||||
const lightningGustPow = skyDef.lightningGustPow ?? Infinity;
|
||||
const firedGusts = new Set();
|
||||
|
||||
const rain = createRain({ groundY: o.groundY ?? 0 });
|
||||
if (scene) scene.add(rain.mesh);
|
||||
@ -510,6 +518,28 @@ export function createSkyFx(o = {}) {
|
||||
*/
|
||||
rainShadowOver(rect) { return shadow.fractionOver(rect); },
|
||||
|
||||
/**
|
||||
* Decision 7's garden-HP drain term, 0..1, in one call — the combined helper
|
||||
* I offered Lane A. This is the whole of "is the bed getting hit right now":
|
||||
*
|
||||
* hp -= sky.gardenExposure(world.gardenBed, t) * DRAIN_PER_SEC * dt;
|
||||
*
|
||||
* 0 = bone dry (no rain, or the cloth is over it); 1 = full downpour on open
|
||||
* ground. Both terms matter and neither is enough alone: a sail over the bed
|
||||
* in a downpour is 0, and a clear sky with no sail is also 0. It uses the
|
||||
* grid we already rebuild for the rain, so it costs a few array reads.
|
||||
*
|
||||
* Note it moves on its own during storm_02: the rain shadow follows the wind,
|
||||
* so the southerly change walks the dry patch off the bed and the drain
|
||||
* starts climbing without a single corner having failed. That's the mechanic,
|
||||
* not a bug — worth not "fixing" if it looks surprising in the HUD.
|
||||
*/
|
||||
gardenExposure(rect, t) {
|
||||
const rain = wind.rainAt(t);
|
||||
if (rain <= 0) return 0;
|
||||
return rain * (1 - shadow.fractionOver(rect));
|
||||
},
|
||||
|
||||
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||
unlockAudio() { audio.unlock(); },
|
||||
|
||||
@ -539,6 +569,24 @@ export function createSkyFx(o = {}) {
|
||||
o.onEvent(ev.text);
|
||||
}
|
||||
}
|
||||
|
||||
// --- lightning on the biggest gusts ---
|
||||
// The storm's worst moments should light up, not only the three authored
|
||||
// strikes. Driven off the telegraph (which is also what the HUD banner and
|
||||
// the audio whoosh read), so the flash lands WITH the gust that earned it.
|
||||
// Deterministic: the gust timeline is, and each gust fires at most once.
|
||||
const tgl = wind.gustTelegraph(t);
|
||||
if (tgl && tgl.power >= lightningGustPow) {
|
||||
// key on the ramp instant — stable across frames, unique per gust
|
||||
const key = Math.round((t + tgl.eta) * 100);
|
||||
if (!firedGusts.has(key)) {
|
||||
firedGusts.add(key);
|
||||
const p = Math.min(1, 0.45 + (tgl.power - lightningGustPow) * 0.06);
|
||||
flashQueue.push({ at: t + tgl.eta, power: p });
|
||||
flashQueue.push({ at: t + tgl.eta + 0.08 + p * 0.06, power: p * 0.5 });
|
||||
flashQueue.push({ at: t + tgl.eta + 1.1, power: 0, thunder: p });
|
||||
}
|
||||
}
|
||||
for (let i = flashQueue.length - 1; i >= 0; i--) {
|
||||
if (flashQueue[i].at <= t) {
|
||||
const f = flashQueue[i];
|
||||
@ -552,7 +600,7 @@ export function createSkyFx(o = {}) {
|
||||
|
||||
// --- sky ---
|
||||
skyCol.copy(baseSky).lerp(target, storminess * darkness);
|
||||
if (flash > 0) skyCol.lerp(new THREE.Color(0xdfe8ff), Math.min(0.85, flash));
|
||||
if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash));
|
||||
if (scene) {
|
||||
if (scene.fog) {
|
||||
scene.fog.color.copy(skyCol);
|
||||
@ -565,6 +613,22 @@ export function createSkyFx(o = {}) {
|
||||
|
||||
dome.position.copy(camPos);
|
||||
dome.material.opacity = storminess * 0.85;
|
||||
// Tint the CLOUDS, not just the sky behind them. The dome covers the
|
||||
// background at ~0.85 opacity, so darkening `scene.background` alone did
|
||||
// nothing — a "wild night" still read as an overcast afternoon because the
|
||||
// grey cloud texture was what you were actually looking at.
|
||||
//
|
||||
// Both a lerp AND a brightness crush: the lerp alone lands ~#717273 because
|
||||
// it runs in linear space (84% toward night still reads mid-grey in sRGB)
|
||||
// and the cloud texture is baked near-white. The crush is what makes night
|
||||
// look like night. It stops at 0.78 on purpose — the yard has no lights in
|
||||
// it yet, and a storm you can't see isn't a storm, it's a black screen.
|
||||
const nightAmt = storminess * darkness;
|
||||
dome.material.color.copy(WHITE)
|
||||
.lerp(target, nightAmt * 0.92)
|
||||
.multiplyScalar(1 - 0.78 * nightAmt);
|
||||
// and lightning lights the cloud it's inside, which is the whole look
|
||||
if (flash > 0) dome.material.color.lerp(FLASH_COL, Math.min(0.9, flash));
|
||||
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
|
||||
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
|
||||
|
||||
|
||||
@ -21,7 +21,10 @@ import { createDebris } from '../debris.js';
|
||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { weatherCases } from './weather.selftest.js';
|
||||
|
||||
const STORMS = ['storm_01_gentle', 'storm_02_wildnight'];
|
||||
// Keep in step with data/storms/. The node runner globs the directory, so this
|
||||
// list going stale shows up here first — as it did when storm_03 landed and the
|
||||
// ponding case reached for a storm the browser half had never loaded.
|
||||
const STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly'];
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default async function run(t) {
|
||||
@ -213,6 +216,55 @@ export default async function run(t) {
|
||||
assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`);
|
||||
});
|
||||
|
||||
// --- decision 7: the drain helper Lane A wires garden HP to ---
|
||||
t.test('gardenExposure is rain AND no cover, and needs both', () => {
|
||||
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 };
|
||||
|
||||
// no sail: exposure is simply the rain
|
||||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, {}));
|
||||
const open = sky.gardenExposure(bed, 70);
|
||||
assert(Math.abs(open - wind.rainAt(70)) < 1e-9,
|
||||
`open ground should be exposed exactly as hard as it rains: ${open} vs ${wind.rainAt(70)}`);
|
||||
assert(open > 0.5, 'storm_02 at t=70 should be pouring');
|
||||
|
||||
// a panel over the bed dries it out
|
||||
const panel = { pos: new Float32Array([-3, 3, -3, 3, 3, -3, 3, 3, 3, -3, 3, 3]), tris: [0, 1, 2, 0, 2, 3] };
|
||||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, { sail: panel }));
|
||||
const covered = sky.gardenExposure(bed, 70);
|
||||
assert(covered < open * 0.5, `cloth over the bed barely helped: ${covered.toFixed(2)} vs open ${open.toFixed(2)}`);
|
||||
|
||||
// and no rain means no drain, sail or not
|
||||
assert(sky.gardenExposure(bed, 0) === 0, 'the bed is draining before the rain has started');
|
||||
sky.dispose();
|
||||
});
|
||||
|
||||
t.test('storm_02 lights up on its biggest gusts, storm_01 does not', () => {
|
||||
const mk = (name) => {
|
||||
const scene = new THREE.Scene();
|
||||
const wind = createWind(storms[name]);
|
||||
const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind });
|
||||
let flashes = 0, peak = 0;
|
||||
let was = 0;
|
||||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||
sky.step(dt, time, {});
|
||||
if (sky.flash > was + 0.05) flashes++; // a rising edge = a strike
|
||||
was = sky.flash;
|
||||
peak = Math.max(peak, sky.flash);
|
||||
});
|
||||
sky.dispose();
|
||||
return { flashes, peak };
|
||||
};
|
||||
const wild = mk('storm_02_wildnight');
|
||||
const gentle = mk('storm_01_gentle');
|
||||
// 3 authored strikes + the worst gusts; must be more than the authored ones
|
||||
assert(wild.flashes > 3, `wild night only flashed ${wild.flashes} times — the big gusts aren't lighting up`);
|
||||
assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
// call it with the fetched storm defs.
|
||||
|
||||
import {
|
||||
createWindField, validateStorm, gustEnvelope, GUST,
|
||||
createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
|
||||
} from '../weather.core.js';
|
||||
|
||||
const DT = 1 / 60;
|
||||
@ -405,6 +405,105 @@ export function weatherCases(storms) {
|
||||
assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field');
|
||||
});
|
||||
|
||||
// ---- 10. the ponding story (SPRINT4 decision 10) ----
|
||||
// Lane B owns water-on-cloth; these assert the RAIN DATA can tell the story
|
||||
// their mass model needs, using their own arithmetic, before their cloth lands.
|
||||
// B measured: 5 cm over a 25 m² flat sail = 1250 kg = 3.1 kN/corner, against a
|
||||
// storm_02 wind load of only 0.2–1.1 kN. So depth is the whole mechanic.
|
||||
const FLAT_AREA = 25; // m², B's reference flat sail
|
||||
const KILL_DEPTH_MM = 50; // 5 cm — B's 3.1 kN/corner
|
||||
/** Water on a flat sail over a whole storm, at decision 10's compression. */
|
||||
const stormDepthMm = (def) => {
|
||||
const f = createWindField(def);
|
||||
return f.rainDepthMm(0, f.duration) * RAIN_TIME_COMPRESSION;
|
||||
};
|
||||
/** B's arithmetic: mm over an area → kN per corner (4 corners, 1000 kg/m³). */
|
||||
const kNPerCorner = (mm) => (mm / 1000) * FLAT_AREA * 1000 * 9.81 / 4 / 1000;
|
||||
|
||||
test('storm_02 rain can drown a flat rig; storm_01 rain cannot', () => {
|
||||
const wild = stormDepthMm(storms.storm_02_wildnight);
|
||||
const gentle = stormDepthMm(storms.storm_01_gentle);
|
||||
metrics['storm_02.pondDepth_mm'] = +wild.toFixed(1);
|
||||
metrics['storm_02.pond_kN_per_corner'] = +kNPerCorner(wild).toFixed(2);
|
||||
metrics['storm_01.pondDepth_mm'] = +gentle.toFixed(2);
|
||||
metrics['storm_01.pond_kN_per_corner'] = +kNPerCorner(gentle).toFixed(3);
|
||||
|
||||
assert(wild >= KILL_DEPTH_MM * 0.9,
|
||||
`storm_02 only delivers ${wild.toFixed(1)} mm — Lane B needs ~${KILL_DEPTH_MM} mm to drown a flat rig`);
|
||||
// and it must dwarf the wind it's competing with (B: 0.2–1.1 kN/corner)
|
||||
assert(kNPerCorner(wild) > 1.5,
|
||||
`storm_02 ponding is only ${kNPerCorner(wild).toFixed(2)} kN/corner — no stronger than the wind`);
|
||||
// the gentle storm must be unable to hurt anything, or the ramp is a lie
|
||||
assert(kNPerCorner(gentle) < 0.3,
|
||||
`storm_01 ponds ${kNPerCorner(gentle).toFixed(2)} kN/corner — a light shower must not threaten a rig`);
|
||||
assert(wild > gentle * 20, 'the wild night should deliver vastly more water than a shower');
|
||||
});
|
||||
|
||||
test('storm_03 ponding sits between the other two', () => {
|
||||
const mid = stormDepthMm(storms.storm_03_southerly);
|
||||
const wild = stormDepthMm(storms.storm_02_wildnight);
|
||||
const gentle = stormDepthMm(storms.storm_01_gentle);
|
||||
metrics['storm_03.pondDepth_mm'] = +mid.toFixed(1);
|
||||
metrics['storm_03.pond_kN_per_corner'] = +kNPerCorner(mid).toFixed(2);
|
||||
assert(mid > gentle * 3 && mid < wild * 0.5,
|
||||
`storm_03 delivers ${mid.toFixed(1)} mm — wanted a real middle rung between ${gentle.toFixed(1)} and ${wild.toFixed(1)}`);
|
||||
// it should threaten a carabiner (1.2 kN) once wind is added, not a shackle (3.2)
|
||||
assert(kNPerCorner(mid) > 0.25 && kNPerCorner(mid) < 1.2,
|
||||
`storm_03 ponds ${kNPerCorner(mid).toFixed(2)} kN/corner — should tease a cheap rig, not drown a decent one`);
|
||||
});
|
||||
|
||||
test('rain depth integrates monotonically and matches its curve', () => {
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
// depth only ever accumulates
|
||||
let prev = 0;
|
||||
for (let t = 1; t <= f.duration; t += 1) {
|
||||
const d = f.rainDepthMm(0, t);
|
||||
assert(d >= prev - 1e-9, `rain depth went BACKWARDS at t=${t}: ${d} < ${prev}`);
|
||||
prev = d;
|
||||
}
|
||||
// splitting the interval must give the same water (no double-count, no gap)
|
||||
const whole = f.rainDepthMm(0, 90);
|
||||
const split = f.rainDepthMm(0, 30) + f.rainDepthMm(30, 60) + f.rainDepthMm(60, 90);
|
||||
assert(Math.abs(whole - split) < 0.05,
|
||||
`depth(0,90)=${whole.toFixed(3)} but the three thirds sum to ${split.toFixed(3)}`);
|
||||
// dead calm before the rain starts
|
||||
assert(f.rainDepthMm(0, 0) === 0, 'zero-length interval delivered water');
|
||||
assert(f.rainDepthMm(10, 5) === 0, 'a backwards interval delivered water');
|
||||
});
|
||||
|
||||
test('rainMmPerHour tracks rainAt against the storm scale', () => {
|
||||
for (const [name, def] of defs) {
|
||||
if (!def.rain || !def.rain.curve) continue;
|
||||
const f = createWindField(def);
|
||||
const peak = def.rain.peakMmPerHour;
|
||||
assert(Number.isFinite(peak), `${name} has a rain curve but no peakMmPerHour`);
|
||||
for (let t = 0; t <= f.duration; t += 2.5) {
|
||||
const want = f.rainAt(t) * peak;
|
||||
assert(Math.abs(f.rainMmPerHour(t) - want) < 1e-9,
|
||||
`${name}: rainMmPerHour ${f.rainMmPerHour(t)} != rainAt×peak ${want} at t=${t}`);
|
||||
}
|
||||
// rainAt stays a 0..1 intensity — skyfx uses it for drop count and opacity
|
||||
for (let t = 0; t <= f.duration; t += 2.5) {
|
||||
const r = f.rainAt(t);
|
||||
assert(r >= 0 && r <= 1, `${name}: rainAt ${r} outside 0..1 at t=${t}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('validator rejects a rain curve with no scale, and a silly scale', () => {
|
||||
const noScale = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
delete noScale.rain.peakMmPerHour;
|
||||
assert(!validateStorm(noScale, 'x').ok, 'validator accepted a rain curve with no mm/hr scale — ponding would silently use the default');
|
||||
for (const mm of [-1, 5000, NaN]) {
|
||||
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
d.rain.peakMmPerHour = mm;
|
||||
assert(!validateStorm(d, 'x').ok, `validator accepted peakMmPerHour=${mm}`);
|
||||
}
|
||||
const hot = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
hot.rain.curve = [[0, 0], [45, 3], [90, 0]];
|
||||
assert(!validateStorm(hot, 'x').ok, 'validator accepted rain intensity above 1 — the scale is peakMmPerHour, not the curve');
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
|
||||
@ -132,6 +132,25 @@ function sampleAngleCurve(curve, t) {
|
||||
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
|
||||
export const DEFAULT_DOWNDRAFT = 0.22;
|
||||
|
||||
/** Fallback rain scale, mm/hr at rainAt()==1. Storms should state their own. */
|
||||
export const DEFAULT_PEAK_MM_PER_HOUR = 40;
|
||||
|
||||
/**
|
||||
* SPRINT4 decision 10 — the time-compression fiat, in ONE place.
|
||||
*
|
||||
* Game rain accumulates ~40× real time. A 90 s storm is canonically a whole
|
||||
* night (storm_02 telegraphs its change "around the hour mark"), so it should
|
||||
* deliver a night's water: 90 s × 40 = 3600 s = one hour of rain. At storm_02's
|
||||
* 80 mm/hr peak that lands ~5 cm on a flat sail — exactly Lane B's measured kill
|
||||
* threshold (5 cm over 25 m² = 1250 kg = 3.1 kN/corner) against a wind load of
|
||||
* only 0.2–1.1 kN. Ponding is 3–15× everything else, and it cannot pincer §7
|
||||
* because a hypar has no flat to pool in.
|
||||
*
|
||||
* Exported so Lane B applies it cloth-side rather than either of us hardcoding
|
||||
* 40 twice: how hard it rains is Lane C, how much water a sail holds is Lane B.
|
||||
*/
|
||||
export const RAIN_TIME_COMPRESSION = 40;
|
||||
|
||||
export function buildGustTimeline(def, seed) {
|
||||
const g = def.gusts || {};
|
||||
const rng = mulberry32(seed >>> 0);
|
||||
@ -378,6 +397,45 @@ export function createWindField(def, opts = {}) {
|
||||
if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t)));
|
||||
return Math.min(1, Math.max(0, r.intensity ?? 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
|
||||
* renderer doesn't want millimetres.
|
||||
*
|
||||
* This exists for ponding (decision 10). Without it Lane B has to invent the
|
||||
* mm/hr scale to turn intensity into water mass, which is exactly the
|
||||
* "default-off code tuned by a constant I invented" they rightly reverted.
|
||||
* The scale is storm data, so it lives here: `rain.peakMmPerHour`.
|
||||
* For reference: 8 = light shower, 30 = moderate, 50 = heavy, 80+ = severe.
|
||||
*/
|
||||
rainMmPerHour(t) {
|
||||
const r = def.rain;
|
||||
if (!r) return 0;
|
||||
return field.rainAt(t) * (r.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR);
|
||||
},
|
||||
|
||||
/**
|
||||
* Real-world water depth in mm delivered over (t0, t1]. Deterministic
|
||||
* trapezoid over the curve — no compression applied.
|
||||
*
|
||||
* Lane B multiplies by RAIN_TIME_COMPRESSION cloth-side: how much water a
|
||||
* SAIL holds is theirs, how hard it rains is mine. Handy shape for a HUD
|
||||
* "water delivered" readout too.
|
||||
*/
|
||||
rainDepthMm(t0, t1, stepS = 0.25) {
|
||||
if (!(t1 > t0)) return 0;
|
||||
let mm = 0;
|
||||
const n = Math.max(1, Math.ceil((t1 - t0) / stepS));
|
||||
const h = (t1 - t0) / n;
|
||||
let prev = field.rainMmPerHour(t0);
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const cur = field.rainMmPerHour(t0 + i * h);
|
||||
mm += (prev + cur) * 0.5 * h / 3600; // mm/hr × seconds → mm
|
||||
prev = cur;
|
||||
}
|
||||
return mm;
|
||||
},
|
||||
};
|
||||
|
||||
return field;
|
||||
@ -446,6 +504,21 @@ export function validateStorm(def, name = 'storm') {
|
||||
}
|
||||
|
||||
if (def.rain && def.rain.curve && !isCurve(def.rain.curve)) bad('rain.curve must be [[t,intensity],...]');
|
||||
if (def.rain) {
|
||||
if (def.rain.curve && isCurve(def.rain.curve)
|
||||
&& def.rain.curve.some((p) => p[1] < 0 || p[1] > 1)) {
|
||||
bad('rain.curve intensity must be 0..1 — the physical scale is rain.peakMmPerHour');
|
||||
}
|
||||
const mm = def.rain.peakMmPerHour;
|
||||
if (mm != null && (!Number.isFinite(mm) || mm < 0 || mm > 300)) {
|
||||
bad(`rain.peakMmPerHour must be 0..300 mm/hr (8 light, 30 moderate, 50 heavy, 80+ severe) — got ${mm}`);
|
||||
}
|
||||
// Ponding reads this; a storm that rains with no scale silently ponds at the
|
||||
// default instead of what its author meant.
|
||||
if (def.rain.curve && mm == null) {
|
||||
bad('rain.curve without rain.peakMmPerHour — ponding needs the mm/hr scale (SPRINT4 decision 10)');
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@ -10,9 +10,9 @@
|
||||
// determinism rule can't be broken by accident.
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { createWindField, validateStorm, GUST } from './weather.core.js';
|
||||
import { createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION } from './weather.core.js';
|
||||
|
||||
export { GUST, validateStorm };
|
||||
export { GUST, validateStorm, RAIN_TIME_COMPRESSION };
|
||||
|
||||
// Resolved against this module, not the server root: server.py serves the repo
|
||||
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
||||
@ -84,9 +84,16 @@ export function createWind(def, opts = {}) {
|
||||
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
|
||||
eventsBetween(a, b) { return field.eventsBetween(a, b); },
|
||||
|
||||
/** 0..1 rain intensity. */
|
||||
/** 0..1 rain intensity — drives drop count and opacity. */
|
||||
rainAt(t) { return field.rainAt(t); },
|
||||
|
||||
/** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */
|
||||
rainMmPerHour(t) { return field.rainMmPerHour(t); },
|
||||
|
||||
/** Real-world mm of water delivered over (t0,t1]. Multiply by
|
||||
* RAIN_TIME_COMPRESSION cloth-side — see weather.core. */
|
||||
rainDepthMm(t0, t1) { return field.rainDepthMm(t0, t1); },
|
||||
|
||||
/** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
|
||||
dirAt(t) { return field.dirAt(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