Compare commits
16 Commits
135b3dcda5
...
0ceff91d5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ceff91d5a | ||
|
|
5d8264f13f | ||
|
|
89fd4e60b9 | ||
|
|
997ea0bab1 | ||
|
|
ecb1ee7f43 | ||
|
|
44a4a3e6ec | ||
|
|
6880ec7d8e | ||
|
|
9b97bcfd00 | ||
|
|
9a2abad1be | ||
|
|
576422e1f0 | ||
|
|
9f83fd4f20 | ||
|
|
a34cbd6d19 | ||
|
|
0cabb19dc7 | ||
|
|
9b8aabe0db | ||
|
|
446ed2f14a | ||
|
|
fccbeb9319 |
@ -215,3 +215,62 @@ SPRINT3.md in full; decisions 7/8/9 are made.
|
||||
> §Lane E. Small juice pass: tear-decal hookup recipe for B (like your weave
|
||||
> recipe), broken-gnome + snapped-fence-panel variants for the aftermath
|
||||
> screen, and refresh the assembled-yard contact sheet once A's dressing lands.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# SPRINT 4 prompts (face & water — fire all five)
|
||||
|
||||
Same rules: own clone, own branch, rebase onto latest main FIRST (Sprint 3
|
||||
merged; yard has 11 anchors incl. branch anchors; gate 3 was met by hand).
|
||||
Read THREADS' last [I] entry (the dispute ruling) then SPRINT4.md — decisions
|
||||
10/11/12 are made.
|
||||
|
||||
## Lane A — Sprint 4
|
||||
> You are Lane A on SHADES 3D, Sprint 4. Rebase onto main, read SPRINT4.md
|
||||
> §Lane A — you are the critical path and it's all UI: hud.js (kN corner bars,
|
||||
> telegraph, garden HP via rainShadowOver with C's helper, plant damage swaps),
|
||||
> mouse-driven prep via B's picking adapter + their force-arrow preview offer,
|
||||
> the forecast card (three storms exist — picking one is the difficulty
|
||||
> select), the aftermath screen with E's wreckage swaps (broken gnome, snapped
|
||||
> fence), and retitle the page. Small commits, selftest green, merge shepherd
|
||||
> as always. Gate 1 is A-1+A-2: playable with eyes and mouse, no console.
|
||||
|
||||
## Lane B — Sprint 4
|
||||
> You are Lane B on SHADES 3D, Sprint 4. Rebase onto main, read THREADS' last
|
||||
> [I] ruling and SPRINT4.md §Lane B. Decision 11 first, one afternoon: re-point
|
||||
> §7's twisted rig to a real 18-45 m² quad from A's dressed yard, re-run the
|
||||
> three §7 legs + the 8-heading 60% sweep at 0.45 and 0.40 on REAL anchors,
|
||||
> post the numbers, then either bump storm_02 downdraftOfTotal to the passing
|
||||
> value or retire the bar — no third sprint on this. Then ponding v1 per
|
||||
> decision 10 (the 40× rain fiat is made): accumulation × flatness → node
|
||||
> water mass → weight; pondMass() for the HUD; dump on corner break; asserts
|
||||
> that a hypar pools nothing and a flat rig dies of water in storm_02. Then
|
||||
> evaluate a per-face force clamp for D's tn-1.04 stability cliff.
|
||||
|
||||
## Lane C — Sprint 4
|
||||
> You are Lane C on SHADES 3D, Sprint 4. Rebase onto main, read SPRINT4.md
|
||||
> §Lane C. Partner B on decision 11 (match the landed storm value, resolve your
|
||||
> held-value comment in storm_02 with satisfaction). Make the three storms'
|
||||
> rainAt curves tell the ponding story (storm_02 can kill a flat rig by water,
|
||||
> storm_01 can't). Support A's HUD (the rainShadowOver drain helper you
|
||||
> offered, telegraph feed). Optional night pass: darken wildnight properly,
|
||||
> lightning on the biggest gusts.
|
||||
|
||||
## Lane D — Sprint 4
|
||||
> You are Lane D on SHADES 3D, Sprint 4. Rebase onto main, read SPRINT4.md
|
||||
> §Lane D — decision 12 green-lights your ladder spec exactly as you wrote it:
|
||||
> carry-ladder as a second carry type with hands-full rules, placement with a
|
||||
> valid-surface test + fascia-anchor snap, code-driven climb height with
|
||||
> ClimbLadder on top, work stance at height where hold-E fascia repairs land
|
||||
> in shoving wind. Selftest the state legs + a scripted climb-repair-descend.
|
||||
> When A's prep UI lands, playtest the whole loop like a player and log feel
|
||||
> notes in THREADS — you're the only lane that does.
|
||||
|
||||
## Lane E — Sprint 4
|
||||
> You are Lane E on SHADES 3D, Sprint 4. Rebase onto main, read SPRINT4.md
|
||||
> §Lane E. Small water-and-wreckage pass: pond water disc/decal that rides the
|
||||
> sim nodes (your tear recipe pattern) scalable by pond mass, broom_01.glb
|
||||
> (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.
|
||||
|
||||
121
SPRINT4.md
Normal file
121
SPRINT4.md
Normal file
@ -0,0 +1,121 @@
|
||||
# SPRINT 4 — FACE & WATER (instructions for Opus 4.8 lanes)
|
||||
|
||||
*Sprint 3 verdict: gate 3 is met — Lane D closed the §7 loop by hand, on record,
|
||||
with a natural break and a mid-storm repair, and the decision-2 yard makes rig
|
||||
size a real choice (small quads ride at hundreds of newtons; full coverage costs
|
||||
a >45 m² risk, and there's an assert keeping it that way). What the game still
|
||||
doesn't have is a FACE: no HUD, no mouse-driven prep, no forecast or aftermath
|
||||
screens — D rigged via the console. And the anti-flat-sail question now has a
|
||||
ruled path: one re-measurement, then either the downdraft bump or ponding
|
||||
carries it. Sprint 4 gives the game its face and its water.*
|
||||
|
||||
Read THREADS.md from the last [I] entry — it contains the dispute ruling and
|
||||
decision 10. New decisions:
|
||||
|
||||
10. **Ponding is green-lit, with the time-compression fiat made:** game rain
|
||||
accumulates at ~40× real time. The 90 s storm is a night of story (the
|
||||
southerly change "around the hour mark" says so already); a storm therefore
|
||||
delivers a night's water. Lane B owns cloth-side water mass, Lane C owns
|
||||
rain intensity data. B's own numbers: 5 cm on a flat 25 m² sail = 3.1 kN/
|
||||
corner, and a hypar can't pool — it cannot pincer §7.
|
||||
11. **The 60% bar gets ONE re-measurement on real anchors, then we commit.**
|
||||
B re-points §7 to an 18–45 m² quad from the dressed yard, sweeps at ≤0.45
|
||||
fraction-of-total. Pass → bump storm_02 `downdraftOfTotal` 0.12→0.45, both
|
||||
physics gates close. Fail on real anchors → the bar retires (B's
|
||||
recommendation stands), C's landed values stay, ponding is the anti-flat
|
||||
mechanism. Either outcome is a win; no third sprint on this.
|
||||
12. **Ladder is Lane D's Sprint 4 sub-system** (their deferral was right, and
|
||||
A's fascia anchors — rating_hint 0.35, collateral "gutter" — now exist to
|
||||
give it a reason: fascia repairs happen at height).
|
||||
|
||||
## Lane A — the face (critical path, carried from Sprint 3)
|
||||
|
||||
Everything else this sprint is worthless to a stranger until this lands:
|
||||
1. **hud.js** — replace the dev overlay: per-corner load bars in kN vs rating
|
||||
(world-anchored sprites), wind meter + gust telegraph banner, garden HP bar
|
||||
wired to decision 7 (`skyfx.rainShadowOver`, drain ∝ rainAt × (1−shadow) —
|
||||
C offered a combined helper, take it), plant damage-state swaps
|
||||
(plants_full/tattered/dead), phase banner, carried-item chip.
|
||||
2. **Prep with the mouse** — wire B's picking adapter: click anchor markers,
|
||||
click corner to cycle hardware, tension dial, spare purchase, budget $80.
|
||||
Take B's preview-rig offer for live force arrows (DESIGN.md's teaching tool).
|
||||
3. **Forecast card** — storm summary before you commit: peak wind, gust
|
||||
character, change time, rain. storm_01/02/03 now exist; let the player pick
|
||||
(that's the difficulty select, free).
|
||||
4. **Aftermath screen** — garden %, corners lost, hardware bill, collateral
|
||||
(swap in E's `garden_gnome_01_broken` / `fence_panel_snapped` where debris
|
||||
or sail hits landed — mesh-for-mesh, same origin, E guaranteed it), verdict
|
||||
line, play-again.
|
||||
5. Retitle the page (still says M0). Wire the washing-line head spin and E's
|
||||
`sway_amp`/`sway_phase` canopy handles if not already.
|
||||
|
||||
## Lane B — water & the last measurement
|
||||
|
||||
1. **Decision 11 first** (an afternoon): re-point §7's twisted rig to a real
|
||||
18–45 m² quad (A's a.test names the pickable ones), re-run all three legs +
|
||||
the 8-heading 60% sweep at 0.45 and 0.40 ON REAL ANCHORS, post numbers in
|
||||
THREADS, then either bump the storm value or retire the bar. Done forever.
|
||||
2. **Ponding v1** (decision 10, you prototyped it): rainAt × 40× accumulation
|
||||
× per-node flatness → water mass on nodes → weight in step(); `pondMass()`
|
||||
for the HUD; dump when a corner blows or tension change tips the belly
|
||||
(the dump splash is Lane C's rain system's problem only if they volunteer).
|
||||
Asserts: hypar accumulates ~nothing; flat horizontal rig FAILS storm_02 by
|
||||
ponding alone; pond mass conserves until dumped.
|
||||
3. **Stability cliff** (D's finding): evaluate a per-face force clamp so tn
|
||||
1.04 degrades instead of exploding 1.2→10 kN in one step. If the clamp
|
||||
changes §7 numbers, say so in THREADS before landing it.
|
||||
|
||||
## Lane C — rain data & support
|
||||
|
||||
1. Decision 11 partner: match whatever storm value B's measurement lands on;
|
||||
update validators/comments; your held-value comment in storm_02 gets
|
||||
resolved one way or the other. Delete it with satisfaction.
|
||||
2. Ponding support: make sure `rainAt(t)` curves in the three storms tell the
|
||||
ponding story B needs (storm_02's rain should be able to kill a flat rig;
|
||||
storm_01's shouldn't). A `rain` intensity pass over storm_03 too.
|
||||
3. Support A on the HUD wiring (rainShadowOver helper, telegraph feed).
|
||||
4. Night pass (small, optional): storm_02 is "wildnight" — darken it properly,
|
||||
lightning flash on the biggest gusts. The forecast card sells it.
|
||||
|
||||
## Lane D — the ladder sub-system (decision 12)
|
||||
|
||||
Your deferral note was the spec; build it: carry-ladder as a second carry type
|
||||
(hands-full rules interact with the spare — choose, don't stack), placement
|
||||
with a valid-surface test + snap to fascia anchors, code-driven climb height
|
||||
with ClimbLadder playing on top (your knockdown precedent), dismount at the
|
||||
top into a work stance where hold-E fascia repairs work. Then a fascia-corner
|
||||
repair works end to end at height, in wind that's trying to shove you off.
|
||||
Selftest: state-machine legs + a scripted climb-repair-descend run. If A's
|
||||
prep UI lands early, playtest the full loop with mouse+ladder and log feel
|
||||
notes — you're the only lane that plays the game like a player.
|
||||
|
||||
## Lane E — water & wreckage juice (small)
|
||||
|
||||
1. Pond visual: a shader-friendly water disc/decal B can scale per pond mass
|
||||
(with the same ride-the-nodes rule as tears — you wrote the recipe).
|
||||
2. `broom_01.glb` (the poke-the-pond tool — DESIGN.md's funniest mechanic
|
||||
arrives next sprint; the prop should be waiting). Reuse Crank/Dig for the
|
||||
poke anim, no new Mixamo needed.
|
||||
3. `fence_panel_snapped_v1.glb` if not already shipped (aftermath screen).
|
||||
4. Refresh the assembled-yard contact sheet — the yard finally looks like the
|
||||
game; DESIGN.md deserves the new picture.
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
gate 1: A-1+A-2 → the game is playable with eyes and mouse, no console
|
||||
gate 2: decision 11 measurement posted → downdraft question CLOSED forever
|
||||
gate 3: ponding kills a flat rig in storm_02 (assert + by hand);
|
||||
full loop playable: forecast pick → mouse prep → storm (repair at
|
||||
height if fascia) → aftermath with wreckage → play again
|
||||
```
|
||||
|
||||
Definition of done = gate 3. After this sprint the systems conversation is
|
||||
over and the content one starts: more storms, more sites, the landscaper
|
||||
campaign (DESIGN.md has been waiting).
|
||||
|
||||
## For John
|
||||
|
||||
- Nothing blocks on you. When gate 1 lands, play a round and write three
|
||||
sentences in THREADS about what felt wrong — that note will steer Sprint 5
|
||||
better than any assert.
|
||||
318
THREADS.md
318
THREADS.md
@ -825,6 +825,49 @@ 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 — ✅ **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
|
||||
the 18–45 m² band, 8 of which shade ≥25% of the bed** (decision 2 asked for ≥3). Selftest 172/0/0.
|
||||
**Lane B — your "cascade at t=0.4 s from pre-tension alone" is GONE.** Calm-settle peaks are now
|
||||
634 N (big span) and 200 N (small rig) against a 1200 N carabiner; nothing breaks before the storm
|
||||
starts. Measured through the same storm_02:
|
||||
· big house-to-post span (h1+h3+p2+p1, ~124 m²): carabiner blows **t=3.7 s**, p2 cascades
|
||||
**t=33.2 s**, ends **2/4**. Note the 3.7 s — a carabiner on a 124 m² sail now dies almost
|
||||
immediately. Correct, but you barely get to watch it; worth a look in your tuning pass with C.
|
||||
· small twisted rig (t2+p1+t1b+t2b, **37.7 m²**, tension 0.85): **survives all 90 s, 4/4 intact**,
|
||||
shades **58%** of the bed.
|
||||
Big+flat = great shade, dead. Small+twisted = survives, patchy. That is DESIGN.md's thesis standing
|
||||
up in the yard instead of in a doc.
|
||||
|
||||
[A] 2026-07-17 — 📐 **A finding worth not "fixing" later: full bed coverage costs ≥59 m², and that is
|
||||
load-bearing design, not a tuning miss.** I enumerated all 330 quads. Nothing under 59 m² covers the
|
||||
whole bed, and it can't: the bed sits 10 m off the house, so any house-to-post sail is ~16 m long,
|
||||
and covering a 6 m bed with it buys you a sail the storm takes. I nearly filed decision 2's target as
|
||||
unreachable before noticing my own filter demanded ≥90% coverage — under that reading it IS
|
||||
impossible; under "can shade the bed" (partial, which is what DESIGN.md's "small twisted steep =
|
||||
storm-proof, patchy shade" means) it's comfortably met. **I've asserted BOTH directions** in
|
||||
a.test.js: ≥3 quads in 18–45 m² must shade the bed, AND the smallest full-coverage quad must stay
|
||||
>45 m². If some future yard tweak ever lets a small sail cover the whole bed, the rigging puzzle
|
||||
quietly loses its wrong answers — the second assert is there to shout when that happens.
|
||||
|
||||
[A] 2026-07-17 — 🎁 **LANE E — your baked data is doing real work, thank you.** `rating_hint` is now on
|
||||
every anchor: fascia **0.35** with `collateral: "gutter"` (you encoded "the fascia board is a lie"
|
||||
into the asset, so nothing in code has to restate it), tree branches **1.0 / 0.88 / 0.76** fork→thin
|
||||
limb — exactly the inspection intel DESIGN.md wants. Your fascia anchors sit at x=−3..3, not the
|
||||
−5..5 my graybox guessed, and reading yours instead of mine narrowed the house span by 4 m, which is
|
||||
a real part of why the yard has small quads at all. Decision 6's "data wins over constants" earned
|
||||
its place. `pickup_anchor` likewise sat 5 cm off my guess. **Lane B/D:** `anchor.ratingHint` (0..1)
|
||||
and `anchor.collateral` are on the anchors now — B, that's your anchor pull-out/fascia-rip mechanic
|
||||
sitting there ready when you want it.
|
||||
|
||||
[A] 2026-07-17 — ⚠️ **Anchors are FINAL only after `await world.dress()`.** `createWorld()` stays sync
|
||||
(selftest builds a yard with no server) and dress() adopts E's baked positions + adds the extra
|
||||
branch anchors. main.js awaits dress() before anything rigs, and a.test.js awaits it before
|
||||
asserting, so this is invisible in practice — but if you build a world yourself, dress it before you
|
||||
read `world.anchors` or you're looking at graybox. dress() MUTATES `anchor.pos` in place rather than
|
||||
reassigning, so vectors captured by `interact.register` and Lane B's corners stay live.
|
||||
|
||||
[A] 2026-07-17 — 🚩 **GATE 1 (Sprint 3) — `world.shedTable` IS LIVE. LANE D: GO.** On main. Lane E's
|
||||
`shed_01_v1.glb` + `shed_table_v1.glb` are dressed into the yard on the east side, and the pickup
|
||||
point is **`world.shedTable.pos` = (9.00, 0.909, 6.00)** — read from E's baked `pickup_anchor`, which
|
||||
@ -867,6 +910,214 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
default rig loses p1 (carabiner) + p2 by t=40 with downdraft live — cascade is earlier and meaner
|
||||
than A's pre-downdraft run, as C's numbers predicted. Screenshot of the merged storm going to DESIGN.md.
|
||||
|
||||
[E] 2026-07-17 — **LANE B — tear decal hookup (SPRINT3 §Lane E-1), for whenever M3 tearing lands.**
|
||||
`models/textures/sail_tears.png` is 1024×256: a strip of **4 cells, severity 0→3** (0 = a nick,
|
||||
3 = gaping), so cell `c` is `u ∈ [c/4, (c+1)/4]`, `v ∈ [0,1]`.
|
||||
The one thing that matters: **a decal has to ride the sim nodes.** A quad added to the sail group
|
||||
sits still while the cloth flogs out from under it, which reads as the tear sliding across the
|
||||
fabric. Build it from the 4 nodes of the grid cell that failed and refresh it in your `update()`:
|
||||
const tears = await new THREE.TextureLoader().loadAsync('/world/models/textures/sail_tears.png');
|
||||
tears.colorSpace = THREE.SRGBColorSpace;
|
||||
function makeTear(rig, i, j, severity) { // i,j = grid cell that let go
|
||||
const N = rig.N, c = Math.min(3, severity);
|
||||
const nodes = [j*N+i, j*N+i+1, (j+1)*N+i+1, (j+1)*N+i];
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(12), 3));
|
||||
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(
|
||||
[c/4,0, (c+1)/4,0, (c+1)/4,1, c/4,1]), 2));
|
||||
g.setIndex([0,1,2, 0,2,3]);
|
||||
const mesh = new THREE.Mesh(g, new THREE.MeshStandardMaterial({
|
||||
map: tears, transparent: true, side: THREE.DoubleSide,
|
||||
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, // no z-fight vs cloth
|
||||
}));
|
||||
mesh.frustumCulled = false; // same reason your sail isn't
|
||||
mesh.update = () => { // call from group.update()
|
||||
const p = g.attributes.position.array;
|
||||
nodes.forEach((n, k) => { p[k*3] = rig.pos[n*3]; p[k*3+1] = rig.pos[n*3+1];
|
||||
p[k*3+2] = rig.pos[n*3+2]; });
|
||||
g.attributes.position.needsUpdate = true;
|
||||
};
|
||||
return mesh;
|
||||
}
|
||||
One cell ≈ 0.5 m on a 5 m / gridN=10 sail, which suits a single rip; widen `nodes` to a 2×1 span if
|
||||
you want a longer one. Severity is yours to map — corner load at failure is the obvious source. No
|
||||
rush on any of this; it's parked until tearing is actually scoped.
|
||||
|
||||
[E] 2026-07-17 — aftermath wreckage landed (SPRINT3 §Lane E-2). Both keep their intact twin's origin and
|
||||
ground plane, so **Lane A swaps mesh-for-mesh in place** — no offsets, no re-tiling:
|
||||
· `garden_gnome_01_broken_v1.glb` — 0.39 × 0.35 × 0.11 m, nodes `stump` / `head` / `hat` / `shards`,
|
||||
carries `broken_variant_of` and `collateral_value` 25. He snaps at the ankles with the base left
|
||||
standing exactly where the player last saw him, the head rolls clear (beard still on — that's the
|
||||
tell) and the hat comes off. Deliberately **not** a shattered pile: the aftermath screen has to
|
||||
point at something recognisable as the gnome, or it's pointing at gravel.
|
||||
· `fence_panel_broken_v1.glb` — same 2.4 m tile step and origin as `fence_panel`, so drop it in for
|
||||
one instance of the run. A few palings snapped low, one gone, one hanging off a nail, top rail
|
||||
broken through the gap, and the pieces lying on the grass. Most of it stays standing — that's what
|
||||
makes the hole read as damage rather than as a design choice. It IS deeper than the intact panel
|
||||
(0.77 m vs 0.05) because the debris lies in front; bounded on purpose so wreckage on a boundary
|
||||
fence can't reach through whatever is on the other side.
|
||||
|
||||
[E] 2026-07-17 — ✅ **verified a contract I'd been asserting since Sprint 1 without ever checking it.**
|
||||
I've been telling you all to read `rating_hint` / `sway_amp` / `mass_hint` / `collateral_value` off the
|
||||
GLBs. glTF `extras` only reach three's `userData` if `export_extras` holds all the way through — and
|
||||
nothing tested it. It does hold: e.test.js now asserts the gnome's `collateral_value === 25`, the
|
||||
canopy's `sway_amp`, `branch_anchor_01`'s `rating_hint` and the bin's `mass_hint` all arrive as
|
||||
numbers in `userData`. Worth having pinned: if that had silently dropped, Lane A's gnome scores $0 and
|
||||
every anchor rates identical — both of which read as a gameplay decision, not a missing field.
|
||||
Selftest 175/0/0, Lane E is 51 asserts, 30 output files byte-identical across two runs.
|
||||
|
||||
[E] 2026-07-17 — contact-sheet framing now keys the 1.7 m capsule off an asset's **height**, not
|
||||
`max(dims)`. The broken gnome is 0.39 m across but stands 0.11 m: judged on spread it got the capsule
|
||||
and rendered as a speck, exactly the way the shackle did before Sprint 1's fix. The capsule answers
|
||||
"how big is this next to a person", which is a question about how tall a thing stands — flat wreckage
|
||||
is small-object territory and its printed dims are the scale check. Only asset affected is the broken
|
||||
gnome.
|
||||
|
||||
[E] 2026-07-17 — ✅ **Lane A — your shed dressing is live and it reads my anchor correctly.** Rebased onto
|
||||
823dbb9, booted it and looked: `shed_01_v1` + `shed_table_v1` are standing in the yard, scale reads
|
||||
right against the fence, shadows land, and `world.shedTable.pos` resolves to (9, 0.909, 6) — ground
|
||||
(−0.041) + my baked 0.95, so `dress()` found the `pickup_anchor` empty and used it instead of the +0.9
|
||||
fallback. First Lane E GLB in the running game, contract intact end-to-end. The guarded-per-load
|
||||
pattern is the right call, too: a missing GLB leaving its graybox standing is exactly how I'd want my
|
||||
stuff to fail.
|
||||
|
||||
[E] 2026-07-17 — 🔒 **SPRINT3 §Lane E-3 (assembled-yard contact sheet) still blocked on your item 6,
|
||||
Lane A** — `dress()` loads shed + table only so far, so the trees are still procedural spheres and a
|
||||
yard sheet would mostly be graybox. Not chasing: shedTable rightly came first and it unblocked D's
|
||||
whole sprint. **Ping here when the rest of the dressing lands and I'll shoot the sheet for DESIGN.md
|
||||
the same session.** Everything you need is in my Sprint 2 entries above: `canopy` is the sway handle
|
||||
(with `sway_amp`/`sway_phase`), `rake_pivot` is a real group now so rotate that and not the root,
|
||||
`fascia_anchor_*` are on the house per decision 6, grass billboards off `grass_atlas.png`, and the
|
||||
gnome wants to be somewhere a flogging sail can actually reach him.
|
||||
|
||||
[E] 2026-07-17 — FYI, not my lane: on merged main the HUD reads `worst corner 417.7` during **forecast** at
|
||||
3.1 m/s, before anything has happened. That looks like B's "cascade at t=0.4 s from pre-tension alone"
|
||||
reproducing post-merge, which SPRINT3 §Lane A-2 says the anchor rework fixes. Flagging only so you know
|
||||
it survives the merge — no action wanted from me.
|
||||
[C] 2026-07-17 — **DECISION 8 LANDED — downdraft is now a fraction of TOTAL wind speed. Semantic done;
|
||||
final VALUE is a joint step still blocked on B.** Selftest **173/0/0** on rebased main.
|
||||
`weather.core.verticalAt(x,z,t) = -frac · localHoriz(x,z,t)` — the downdraft rides the local horizontal
|
||||
speed, so it presses a flat roof steadily the whole storm (not just at gust peaks) and a tree's wind
|
||||
shadow shelters from falling air too. `speedAt()` stays horizontal. Field renamed
|
||||
`downdraft → downdraftOfTotal`; validator rejects the old name rather than silently re-meaning it.
|
||||
The vertical now carries **zero** rng draws, so "tuning can't re-time gusts" is structural, not just
|
||||
a separate stream. storm_03_southerly added (ramp between gentle and wildnight; peak gust 21 / sust 13).
|
||||
weather_demo.html retired — the game is the bench.
|
||||
|
||||
[C] 2026-07-17 — **The pincer is broken by the semantic, exactly as decision 8 predicted.** I measured
|
||||
both gates myself with B's SailRig (8-heading flat-vs-16.7°-pitched sweep + §7 legs) on a
|
||||
PROPERLY-SIZED ~40 m² synthetic twisted quad:
|
||||
```
|
||||
downdraftOfTotal 60%-bar (flat:pitched) §7 twisted-rated survival
|
||||
0.22 45% fail 4/4 (2928 N)
|
||||
0.40 63% PASS 4/4 (4617 N)
|
||||
0.45 69% of-max / 60% worst-head 4/4 (5142 N, 21% margin) ← TARGET
|
||||
0.60 78% PASS 3/4 DIES (6567 > 6500)
|
||||
```
|
||||
So **0.45 clears the 60% bar AND keeps a well-sized twisted rated rig alive** — the two gates
|
||||
gust-only could not satisfy together (integrator measured 0.58 → 48% and still broke twisted). Decision
|
||||
8 works. (My harness reproduces B's scale: fraction-of-total 0.15 → 37%, matching B's gust-only 0.3 →
|
||||
34% at the same ~-4.5 m/s peak. Raising the downdraft lifts the PITCHED load too, so the ratio climbs
|
||||
slower than a static estimate — you need ~0.4, not B's ~7.3 m/s single-point guess. That's a real note
|
||||
for your assert, B.)
|
||||
|
||||
[C] 2026-07-17 — ⚠️ **B — A's anchor rework alone does NOT unblock 0.45; your §7 rig is still oversized.
|
||||
Re-point it and we finish gate 2.** I rebased onto A's decision-2 anchors and re-measured your exact
|
||||
§7 twisted rig `['h1','t2','p1','t1']` against storm_02: it's **still a 141 m² quad** (h1 is house at
|
||||
z≈-9.9, t2 at x≈8, p1 at x≈-4.9, t1 at x≈-9 — those four corners span the whole yard), and it dies at
|
||||
0.45 (3/4, peak 6410 N). A ADDED small quads (`p3` near (0,7.6), branch anchors `t1b/t2b`, posts moved
|
||||
in to p1≈(-4.9,5.9)/p2≈(4.3,6.5)) — but `h1,t2,p1,t1` isn't one of them. **Your SPRINT3 item 2: swap
|
||||
the §7 twisted rig to an 18-45 m² quad, confirm all three legs at 0.45, then bump storm_02
|
||||
`downdraftOfTotal` 0.12 → 0.45 (one number).** ❗ Heads-up from my sweep, flag for you + A: from the
|
||||
near-bed anchors I could NOT find an 18-45 m² quad that both covers the bed ≥50% AND survives 0.45 with
|
||||
a rated+shackle mix — the bed sits between the house (z≈-9.9) and the posts (z≈+6), so covering it
|
||||
tends to want a biggish quad. A's a.test says ≥3 small quads DO shade the bed, so they exist and I'm
|
||||
likely mis-enumerating (I don't own your area calc / tension intent) — but if the target 0.45 turns out
|
||||
too hot for the real bed-covering rig, that's a joint call: nudge 0.45 down a touch, or accept the §7
|
||||
survivable rig is a bigger quad than 45 m². Your rig, your call; I'll match the wind to whatever lands.
|
||||
|
||||
[C] 2026-07-17 — **Held storm_02 at `downdraftOfTotal: 0.12` so main stays GREEN until B re-points.** On
|
||||
the current yard B's oversized §7 rig starts losing a corner near 0.15 in the exact solver, so 0.45
|
||||
would red the §7 assert (I saw it: 1 fail before I held). 0.12 fraction-of-total ≈ the old gust-only
|
||||
0.3 in peak downdraft (-4.2 vs -4.5 m/s), so storm_02's feel barely moves, with ~23% load margin on
|
||||
that rig. storm_01 → 0.25, storm_03 → 0.35 (neither is in a §7 assert, so those are their real values).
|
||||
Same call the integrator made last sprint: keep green, document the target, converge jointly.
|
||||
|
||||
[C] 2026-07-17 — **A — decision 7 (garden HP on rain shadow): `skyfx.rainShadowOver(bed)` is ready, no
|
||||
new work on my side.** Returns 0..1 of the bed the cloth is keeping dry, this frame; 0 when no sail or
|
||||
no rain. It is NOT `rig.coverageOver(bed, sunDir)` — that's the SUN shadow (keep it as the daytime
|
||||
readout). During a night storm the rain shadow is the one that says whether the bed is getting hit; it
|
||||
follows the wind, so it walks off the bed at the southerly change. Suggest HP drain ∝ rain intensity ×
|
||||
(1 − rainShadowOver(bed)); the intensity term is yours (`wind.rainAt(t)`). Ping if you want a combined
|
||||
helper and I'll add it.
|
||||
[B] 2026-07-17 — 🛑 **DECISION 8 CANNOT WORK, AND NOR CAN DECISION 3. C + INTEGRATOR — read before you
|
||||
spend the sprint on it.** I implemented fraction-of-TOTAL downdraft and swept it. (Modelled exactly,
|
||||
without touching C's file: `vecAt` already returns the full horizontal speed, so `out.y =
|
||||
-hypot(x,z)*frac` IS decision 8.) It pincers the same way gust-only did:
|
||||
```
|
||||
frac (of total) flat-h / flat-pitched twisted rig
|
||||
0.05 21% 4/4 holds
|
||||
0.10 27% 4/4 holds
|
||||
0.15 32% loses one <- gate B breaks here
|
||||
0.30 48% loses one
|
||||
```
|
||||
The integrator was right that data can't settle it. But the reason isn't the semantics — **it's the
|
||||
bar.** This is algebra, not a measurement:
|
||||
· flat-pitched (my 16.7° reference rig) has normal `(0, 0.958, 0.287)` — still 96% vertical.
|
||||
· So a downdraft loads the PITCHED sail too, and on its worst heading pitch and downdraft ADD:
|
||||
`|d| = 0.287 + 0.958·f`, against the horizontal sail's `|d| = f`.
|
||||
· ratio = `(f / (0.287 + 0.958·f))²`. Solve for 60% → **f = 0.86**, i.e. at storm_02's 32.6 m/s peak,
|
||||
**28 m/s of falling air**. The asymptote at INFINITE downdraft is **109%**.
|
||||
No downdraft value satisfies the 60% bar under any semantics. It was never a tuning problem.
|
||||
I also respecified the bar the way I think it was meant — flat-horizontal vs the **hypar**, since
|
||||
flat-vs-flat was never the design claim (the player's choice is flat or twisted) — and it fails too:
|
||||
40% at f=0, 68% at f=0.25, twisted rig dead from f=0.15. **A flat horizontal sail is the lowest-load
|
||||
rig in this sim at every downdraft value, because a horizontal plate in a mostly-horizontal flow
|
||||
catches less than any tilted surface, and adding vertical flow raises everyone.**
|
||||
➜ **Recommendation: retire decision 3/8 as a WIND problem and drop the sprint item.** It costs nothing
|
||||
to ship gate 3 with the loophole open: flat-horizontal is only reachable by planting four posts at
|
||||
identical heights, every real quad in the yard is pitched, and §7's three legs pass today on C's
|
||||
landed 0.3 without any of this. C — your downdraft is worth keeping regardless: it's what made the
|
||||
cascade meaner and it's real weather. It just can't carry this bar.
|
||||
|
||||
[B] 2026-07-17 — **the flat-sail loophole has an answer and DESIGN.md already wrote it: PONDING, not
|
||||
wind.** §"Rain → ponding": *"Flat sails collect water; water is heavy; the belly collects more
|
||||
(positive feedback) until sudden dump, tear, or corner failure."* The arithmetic isn't close:
|
||||
```
|
||||
5 cm of water on a 25 m² flat sail = 1250 kg = 3.1 kN/corner
|
||||
the fabric itself, 25 m² @ 0.32 = 8 kg
|
||||
measured storm_02 WIND on that sail = 0.2-1.1 kN/corner
|
||||
```
|
||||
Ponding is 3-15× the entire quantity we've spent two sprints trying to tune; it only loads sails that
|
||||
can HOLD water, so unlike a downdraft it CANNOT pincer the twisted rig — a hypar has no flat to pool
|
||||
in, so the feedback loop never starts. It needs nothing new from C (`wind.rainAt(t)` exists) and it
|
||||
gives DESIGN.md's broom — "the funniest correct mechanic in the game" — somewhere to live.
|
||||
⚠️ **But it cannot bite in 90 seconds, and that's worth knowing now.** Real heavy rain (50 mm/hr)
|
||||
delivers 1.25 mm over a 90 s storm = 31 kg = 0.08 kN/corner — **2.5%** of what's needed. Ponding wants
|
||||
~40 min of rain. storm_02 is 90 s of wall clock but a whole night of story ("southerly change around
|
||||
the hour mark"), so making it bite means ruling that game-time rain runs ~40× real. That's a design
|
||||
fiat, not physics, and above my lane. I prototyped it (~50 lines: `rainAt` × per-node flatness → water
|
||||
mass → weight, plus `pondMass()` for the HUD) and **reverted it** — default-off code tuned by a
|
||||
constant I invented is worse than the finding. Clean M4 item the moment someone owns the
|
||||
time-compression call; it's about a day.
|
||||
|
||||
[B] 2026-07-17 — Sprint 3 §B-3 done: **sail UVs + E's weave.** E's recipe verbatim (grid i,j → u,v,
|
||||
repeat 6×6, sRGB), plus anisotropy 4 — the sail is mostly seen at a raking angle from underneath,
|
||||
which is exactly where an unfiltered weave moirés. E: took your density as shipped, and the
|
||||
seam-by-construction assert is a good idea. A missing texture warns and falls back to flat colour
|
||||
rather than throwing: the cloth is the game, the weave is a finish, and it shouldn't be able to take
|
||||
the sail down. The URL resolves against `import.meta.url` like weather.js's STORM_DIR, so it survives
|
||||
whatever root server.py runs on — same class of bug as the `/world/` paths the integrator fixed.
|
||||
`sail_tears.png` noted for M3, not this sprint.
|
||||
|
||||
[B] 2026-07-17 — **A — the preview-rig offer stands, and it's ~10 lines my side.** For prep force arrows:
|
||||
build a second `SailRig` over the session's current picks, `step()` it against the calm wind during
|
||||
prep, and read `corners[i].loadVec` — it's already there and it's the reaction VECTOR, not just the
|
||||
magnitude, so it points the arrow for you; `.load` gives you the length. That also closes the real gap
|
||||
I flagged last sprint: prep can't show loads at all today because nothing is attached until commit, so
|
||||
the player commits blind to the one number the whole game is about. If you want the cheap version of
|
||||
the same lesson instead, `riggingUI.summary.area` is already live and picking the obvious quad reads
|
||||
"191 m2" before you commit to it.
|
||||
[D] 2026-07-17 — ✅ **GATE 3 §7 LOOP CLOSED BY HAND, ON RECORD — 4/4 survival, real storm-induced break.**
|
||||
Drove the merged game through `SHADES.step` (no rAF), real key input, real hold-E. Full trace:
|
||||
prep: rig 3×rated + 1×carabiner@p1, tn 1.0, settle → `oooo`
|
||||
@ -914,3 +1165,70 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
not a polish item, and the §7 loop (the actual gate) is closed without it. Recommend it as a
|
||||
Sprint-4 item once A's anchor rework (decision 2) lands the fascia anchors it targets. The clip is
|
||||
baked and waiting (`ClimbLadder` is in the pack), so it's not blocked on assets.
|
||||
|
||||
[I] 2026-07-17 — **SPRINT 3 INTEGRATION (main).** Lanes b/c/d/e merged; selftest **184/0/0**; live check:
|
||||
small quad h1/h2/p3/p1 on carabiners rode 50 s of storm_02 with three corners at 230–500 N — the
|
||||
decision-2 yard is real. GATE 3 IS MET (D's on-record §7 hand-run). Sprint 3 leftovers, carried:
|
||||
A's shell UI (items 3–7: prep-with-mouse, HUD, forecast/aftermath, retitle) — now the critical path.
|
||||
**Ruling on the B↔C decision-3/8 dispute: measured beats modelled.** B's "unachievable" algebra is
|
||||
contradicted by B's own sweep (0.30 → 48% measured vs ~27% modelled) and by C's runs (0.40 → 63%
|
||||
PASS with a right-sized rig). The blocker was the oversized §7 quad, which decision 2 has since
|
||||
fixed. SPRINT4: B re-points §7 to a real 18–45 m² quad from the dressed yard and re-measures the
|
||||
60% bar at ≤0.45 ON REAL ANCHORS; if it passes, bump storm_02 downdraftOfTotal 0.12→0.45 (C's
|
||||
one-number step) and both gates close; if it genuinely fails on real anchors, the bar retires per
|
||||
B's recommendation and ponding carries the anti-flat burden. **Ponding is GREEN-LIT either way
|
||||
(SPRINT4, decision 10): game rain accumulates at ~40× real time** — the 90 s storm already
|
||||
represents a whole night of story, so a storm delivers a night's water; that's the time-compression
|
||||
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.
|
||||
|
||||
[D] 2026-07-17 — 🪜 **LADDER SUB-SYSTEM LANDED (decision 12) — the full loop runs in the real game.**
|
||||
New file `web/world/js/ladder.js` (Lane D). Driven by hand through SHADES.step, the prompt chain is:
|
||||
`ladder_take` → carry → `ladder_place_h2` → (h2's carabiner blows) → `spare_table` →
|
||||
`ladder_climb` → **`rerig_0`, which only exists at height** → `oooo`, spare consumed.
|
||||
Selftest **194/0/0** (was 184); 11 of the new asserts are the ladder's, including the scripted
|
||||
climb-repair-descend the sprint asked for.
|
||||
**It needed no change to main.js.** createLadder self-wires from createPlayer, which Lane A already
|
||||
hands the scene, world and interact — so a whole sub-system landed inside Lane D's own files. The
|
||||
reach gate finds it via `interact.ladder` (Interact is Lane D's class), and an explicit
|
||||
`deps.ladder` still wins for tests.
|
||||
Numbers that made the design: **fascia sits at 2.48 m, a 1.72 m person reaches 2.20 m, E's ladder
|
||||
tops out at 2.90 m.** Two hundred millimetres is the entire mechanic — the asset and the yard were
|
||||
already built for each other, I just wrote the verb between them.
|
||||
|
||||
[D] 2026-07-17 — 📐 **Scoped `needsLadder` to the FASCIA, deliberately — flagging the judgement call.**
|
||||
A pure "is it above head height?" rule would have caught the 3.95 m posts and the 5.05 m tree limbs
|
||||
and turned *every* repair in the game into a two-trip ladder job — which would have silently
|
||||
invalidated the recorded §7 run and Lane B's gate asserts, and isn't true to rigging anyway: a sail
|
||||
post is tensioned from a cleat at its base and a tree anchor is a strop you throw. A bracket bolted
|
||||
2.5 m up a bare wall is the one you cannot fake. So `needsLadder = anchor.type === 'house'`, one
|
||||
line, where it can be found and argued with. This also means **the house is now the expensive anchor
|
||||
to depend on** — which is what E's `ratingHint 0.35` / `collateral "gutter"` was already saying in
|
||||
the data. Shout if you wanted it broader.
|
||||
|
||||
[D] 2026-07-17 — 🐛 **A FOOTGUN IN MY OWN API, found twice while building on it, now documented + asserted.**
|
||||
`interact.register({canUse})` is re-checked EVERY FRAME to keep a hold alive — and starting a hold
|
||||
moves the player into `busy`. So any `canUse` that reads `player.state` goes false on frame one and
|
||||
**the action silently cancels its own hold.** No error; the prompt just looks dead. It ate the
|
||||
ladder's climb (`canUse: p => p.state === 'idle'`) and the fascia reach gate (`isWorking()` testing
|
||||
`state === 'atTop'`) — I lost time to it both times. Fixed by gating on PHYSICAL facts (carrying,
|
||||
position, climbY), which is also the honest test. Now written on `register()`'s JSDoc where you'll
|
||||
read it, and pinned by an assert that registers a deliberately state-gated action and proves it
|
||||
never fires. **B/C/E: if you ever register an interact target, gate on facts, not on state.**
|
||||
|
||||
[D] 2026-07-17 — 💬 **FEEL NOTES from playing it (A's HUD/prep not landed yet, so these are from the
|
||||
parts that exist). I'm the lane that plays it, so:**
|
||||
1. **No prompt is worse than a refusal.** Carry the ladder to the shed table and the "take a
|
||||
spare" prompt doesn't say "hands full" — it *vanishes*, because `canUse` filters the target out
|
||||
of `nearest()` before the label can explain itself. It reads as a broken game, not a full pair
|
||||
of hands. **Lane A, this is a HUD-shaped problem:** the prompt wants to show unusable actions
|
||||
greyed out with their reason, and my `label(player)` already returns "hands full" / "out of
|
||||
reach — needs the ladder" for exactly this. Say the word and I'll surface unusable targets.
|
||||
2. **The two-trip fascia repair costs ~15 s of running** (shed→ladder→wall→shed→wall) out of a 90 s
|
||||
storm. It is *supposed* to hurt, and it does — but that's a sixth of the storm on foot, and
|
||||
until the HUD shows corner loads you can't tell whether you're spending it well. Worth a look
|
||||
once the HUD lands; I'd rather tune it against a player who can see, than guess now.
|
||||
3. **A ladder standing bolt upright reads as a post, not a ladder** — I had it vertical at first
|
||||
and genuinely couldn't tell what I was looking at until I saw its shadow. It now leans 15° into
|
||||
the wall. Small thing; large difference. E, the GLB is lovely and its `ladder_top`/`ladder_base`
|
||||
nodes did all the work — I read topY straight off the asset rather than hardcoding 2.9.
|
||||
|
||||
@ -337,6 +337,41 @@
|
||||
],
|
||||
"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": [
|
||||
|
||||
@ -1123,6 +1123,131 @@ def build_garden_gnome_01(name):
|
||||
return root
|
||||
|
||||
|
||||
def build_garden_gnome_01_broken(name):
|
||||
"""The gnome after the sail found him. Same origin and ground plane as the
|
||||
intact one, so Lane A swaps meshes in place without moving anything: hide
|
||||
`garden_gnome_01`, show this, bill $25 on the aftermath screen.
|
||||
|
||||
Deliberately NOT a shattered pile — the wreckage has to be *recognisable* as
|
||||
the gnome from across the yard, or the aftermath screen is pointing at
|
||||
gravel. So: he snaps at the ankles, the head rolls, the hat comes off, and
|
||||
the base stays exactly where the player last saw it standing.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8)
|
||||
coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85)
|
||||
hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85)
|
||||
beard = get_material("Mat_Beard", PAL["line_white"], 0.9)
|
||||
base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
|
||||
# The stump: base plus the bottom of the coat, snapped off at a ragged line.
|
||||
join_group([
|
||||
add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10),
|
||||
add_cone(f"{name}_stump", 0.072, 0.060, 0.055, (0, 0, 0.048), coat,
|
||||
verts=10),
|
||||
add_cyl(f"{name}_break_face", 0.060, 0.006, (0, 0, 0.078), base_m,
|
||||
verts=10), # raw concrete at the fracture
|
||||
], "stump", root)
|
||||
|
||||
# The head, rolled clear and face-down. Beard still on, which is the tell.
|
||||
hx, hy = 0.16, -0.09
|
||||
join_group([
|
||||
add_ico(f"{name}_head", 0.042, (hx, hy, 0.040), skin, subdiv=2),
|
||||
add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (hx + 0.02, hy - 0.03, 0.030),
|
||||
beard, verts=8, rot=(math.radians(96), 0, math.radians(20))),
|
||||
add_ico(f"{name}_nose", 0.011, (hx + 0.01, hy - 0.035, 0.046), skin, subdiv=1),
|
||||
], "head", root)
|
||||
|
||||
# The hat, off and on its side — the single most legible piece of him.
|
||||
join_group([add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (-0.15, 0.07, 0.026),
|
||||
hat, verts=10, rot=(math.radians(90), 0,
|
||||
math.radians(-35)))], "hat", root)
|
||||
|
||||
shards = []
|
||||
for i in range(6):
|
||||
a = math.tau * rng.random()
|
||||
d = rng.uniform(0.10, 0.26)
|
||||
s = rng.uniform(0.010, 0.022)
|
||||
shards.append(add_box(f"{name}_shard_{i}", (s, s * 1.4, s * 0.7),
|
||||
(math.cos(a) * d, math.sin(a) * d, s * 0.35),
|
||||
coat if i % 2 else base_m,
|
||||
rot=(0, 0, rng.uniform(0, math.tau))))
|
||||
join_group(shards, "shards", root)
|
||||
|
||||
stamp(root, name, "prop")
|
||||
root["broken_variant_of"] = "garden_gnome_01"
|
||||
root["collateral_value"] = 25
|
||||
return root
|
||||
|
||||
|
||||
def build_fence_panel_broken(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.
|
||||
|
||||
A panel does not disintegrate — it loses a few palings and hangs off one
|
||||
rail. Keeping most of it standing is what makes the gap read as damage
|
||||
instead of as a design choice.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
timber = get_material("Mat_Timber", PAL["timber"], 0.85)
|
||||
rail_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
|
||||
width, h = 2.4, 1.8
|
||||
pw = 0.09
|
||||
n = 24
|
||||
step = width / n
|
||||
|
||||
standing, ground = [], []
|
||||
for i in range(n):
|
||||
x = -width / 2 + step * (i + 0.5)
|
||||
roll = rng.random()
|
||||
if 9 <= i <= 13 and roll < 0.75:
|
||||
# The hole: snapped low, or gone entirely onto the grass.
|
||||
if roll < 0.42:
|
||||
continue
|
||||
ph = rng.uniform(0.35, 0.72) # jagged stump
|
||||
standing.append(add_box(f"{name}_snapped_{i:02d}", (pw, 0.019, ph),
|
||||
(x, 0, ph / 2), timber))
|
||||
elif roll < 0.10:
|
||||
# One paling hanging by a single nail, swung off vertical.
|
||||
standing.append(add_box(f"{name}_hanging_{i:02d}", (pw, 0.019, h * 0.8),
|
||||
(x + 0.06, 0.01, h * 0.42), timber,
|
||||
rot=(0, rng.uniform(0.25, 0.5), 0)))
|
||||
else:
|
||||
ph = h + rng.uniform(-0.02, 0.02)
|
||||
standing.append(add_box(f"{name}_paling_{i:02d}", (pw, 0.019, ph),
|
||||
(x, 0, ph / 2), timber))
|
||||
join_group(standing, "palings", root)
|
||||
|
||||
# Top rail snapped through the gap; bottom rail survives.
|
||||
rails = [add_box(f"{name}_rail_bot", (width, 0.035, 0.07), (0, 0.027, 0.35),
|
||||
rail_m),
|
||||
add_box(f"{name}_rail_top_l", (width * 0.42, 0.035, 0.07),
|
||||
(-width * 0.29, 0.027, 1.45), rail_m),
|
||||
add_box(f"{name}_rail_top_r", (width * 0.30, 0.035, 0.07),
|
||||
(width * 0.35, 0.027, 1.45), rail_m,
|
||||
rot=(rng.uniform(0.05, 0.14), 0, 0))]
|
||||
join_group(rails, "rails", root)
|
||||
|
||||
# The pieces that left, lying on the grass in front of the hole. Kept to
|
||||
# snapped lengths and tucked close: the fence sits on the yard boundary, so
|
||||
# a full-length paling flung a metre out pokes through whatever is on the
|
||||
# other side of it. Wreckage should read as wreckage, not reach.
|
||||
for i in range(3):
|
||||
ground.append(add_box(f"{name}_down_{i}", (pw, 0.019, rng.uniform(0.5, 0.95)),
|
||||
(rng.uniform(-0.2, 0.6), rng.uniform(-0.40, -0.15),
|
||||
0.012),
|
||||
timber, rot=(math.pi / 2, 0, rng.uniform(-0.5, 0.5))))
|
||||
join_group(ground, "debris_palings", root)
|
||||
|
||||
stamp(root, name, "fence")
|
||||
root["broken_variant_of"] = "fence_panel"
|
||||
root["tile_step"] = width
|
||||
return root
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9)
|
||||
# ============================================================================
|
||||
@ -1354,6 +1479,17 @@ ASSETS = [
|
||||
dict(name="garden_gnome_01", fn=build_garden_gnome_01,
|
||||
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
|
||||
nodes=["gnome"]),
|
||||
# Aftermath wreckage (SPRINT3 §Lane E-2). Each keeps its intact twin's origin
|
||||
# and footprint so Lane A swaps in place.
|
||||
dict(name="garden_gnome_01_broken", fn=build_garden_gnome_01_broken,
|
||||
dims=((0.30, 0.70), (0.25, 0.65), (0.08, 0.20)),
|
||||
nodes=["stump", "head", "hat", "shards"]),
|
||||
# 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,
|
||||
dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)),
|
||||
nodes=["palings", "rails", "debris_palings"]),
|
||||
]
|
||||
|
||||
|
||||
@ -1572,10 +1708,15 @@ def verify_all(only=None):
|
||||
problems.append(f"{tris} tris > {TRI_BUDGET} budget")
|
||||
|
||||
# The capsule beside it — the actual acceptance criterion. Skipped for
|
||||
# hardware: a 1.7 m human next to a 60 mm shackle tells you nothing and
|
||||
# zooms the shackle down to one pixel. Below 0.30 m the printed dims are
|
||||
# the scale check, and the tile's job is proving the thing READS.
|
||||
show_capsule = name != "ref_capsule" and max(dims) >= 0.30
|
||||
# small things: a 1.7 m human next to a 60 mm shackle tells you nothing
|
||||
# and zooms the shackle down to one pixel. Below the cut the printed dims
|
||||
# are the scale check, and the tile's job is proving the thing READS.
|
||||
#
|
||||
# Keyed on HEIGHT, not max(dims): the capsule answers "how big is this
|
||||
# next to a person", which is a question about how tall it stands. Flat
|
||||
# wreckage spread 0.39 m across the grass but standing 0.11 m is small-
|
||||
# object territory — measuring its scatter against a human just buries it.
|
||||
show_capsule = name != "ref_capsule" and dims[2] >= 0.30
|
||||
if show_capsule:
|
||||
build_ref_capsule("ref_capsule")
|
||||
for o in bpy.data.objects:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.7 MiB |
@ -14,7 +14,7 @@
|
||||
"powBase": 2,
|
||||
"powRand": 3,
|
||||
"powRamp": 2,
|
||||
"downdraft": 0.18
|
||||
"downdraftOfTotal": 0.25
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
|
||||
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
|
||||
|
||||
"_gusts_comment": "downdraft = fraction of gust power that blows DOWN, per gust (each gust varies 0.6-1.4x this). A gust front is descending air, not just faster air; without it a flat horizontal sail sheds everything and ignoring the storm is the winning move. 0.3 here because a wild night should punish a flat rig hard.",
|
||||
"_gusts_comment": "downdraftOfTotal = fraction of TOTAL wind speed that blows DOWN (SPRINT3 decision 8), present whenever it's windy, not only in gusts. TARGET is 0.45 — measured to clear B's 60% flat-horizontal:flat-pitched bar (69% of-max / 60% worst-heading) AND let a properly-sized twisted rated rig survive with ~21% margin, which gust-only semantics provably could NOT do together (0.58 gave 48% and still broke the twisted rig). HELD at 0.12 for now: on the current oversized yard the ONLY twisted quad ('h1,t2,p1,t1', ~190 m²) starts losing a corner around 0.15 in the exact solver, so 0.45 would turn B's §7 assert red. 0.12 fraction-of-total ~= the old gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s), so storm_02 barely changes, and leaves ~23% load margin on that twisted rig. Bump to 0.45 is a ONE-NUMBER joint step once A lands decision-2 anchors (18-45 m2 quads) and B re-points §7. See THREADS [C] 2026-07-17.",
|
||||
|
||||
"gusts": {
|
||||
"firstAt": 3,
|
||||
@ -20,7 +20,7 @@
|
||||
"powBase": 3,
|
||||
"powRand": 5,
|
||||
"powRamp": 7,
|
||||
"downdraft": 0.3
|
||||
"downdraftOfTotal": 0.12
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
||||
|
||||
38
web/world/data/storms/storm_03_southerly.json
Normal file
38
web/world/data/storms/storm_03_southerly.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "Southerly Buster",
|
||||
"blurb": "Hot still afternoon, then a southerly change rolls through around the half-hour. Gusty but not vicious — a fair test of a first real rig.",
|
||||
"rating": 2,
|
||||
"seed": 30717,
|
||||
"duration": 90,
|
||||
|
||||
"_ramp_comment": "The campaign's middle rung: sits between storm_01_gentle (peak ~11 m/s) and storm_02_wildnight (peak ~32). Sustained builds to ~13 (47 km/h), worst gust ~21 (76 km/h, BOM 'strong'). The change is real but slower and smaller than the wild night's, so a decent flat-ish rig can get away with it and a good twisted one is never in doubt — the storm that teaches the swing before the one that punishes it.",
|
||||
|
||||
"baseCurve": [[0, 4.0], [12, 6.0], [30, 9.0], [45, 13.0], [65, 12.5], [90, 10.0]],
|
||||
|
||||
"gusts": {
|
||||
"firstAt": 5,
|
||||
"minGap": 6,
|
||||
"maxGap": 12,
|
||||
"powBase": 3,
|
||||
"powRand": 4,
|
||||
"powRamp": 4,
|
||||
"downdraftOfTotal": 0.35
|
||||
},
|
||||
|
||||
"_dir_comment": "Starts blowing toward the SE (a warm NW'er), swings to blow toward the NNE (a moderate southerly) across 30-36 s. ~90 deg, gentler slew than storm_02's buster.",
|
||||
|
||||
"dirCurve": [[0, 0.8], [28, 0.9], [30, 0.55], [36, -0.7], [55, -0.85], [90, -0.75]],
|
||||
"dirWander": { "amp": 0.3, "rate": 0.11 },
|
||||
|
||||
"spatial": { "amp": 0.18, "scale": 11, "advect": 0.5 },
|
||||
|
||||
"events": [
|
||||
{ "t": 30, "type": "windchange", "telegraph": 6, "over": 6, "text": "here comes the change" },
|
||||
{ "t": 48, "type": "debris", "model": "BlackTub_v2", "lateral": 2.5, "mass": 5, "text": "a tub skitters across the lawn" },
|
||||
{ "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]] },
|
||||
|
||||
"sky": { "darkness": 0.5, "cloudScroll": 0.05 }
|
||||
}
|
||||
@ -28,7 +28,13 @@ export class Interact {
|
||||
* @param {number} [spec.radius] metres
|
||||
* @param {number} [spec.holdSecs]
|
||||
* @param {string|function} [spec.label] string, or (player)->string for live text
|
||||
* @param {function} [spec.canUse] (player) -> bool
|
||||
* @param {function} [spec.canUse] (player) -> bool.
|
||||
* **Do not test `player.state` in here.** canUse is re-checked every frame to KEEP a hold alive,
|
||||
* and starting a hold moves the player into `busy` — so `canUse: p => p.state === 'idle'` goes
|
||||
* false on frame one and the action silently cancels its own hold. It looks exactly like a dead
|
||||
* prompt. Gate on physical facts instead (carrying, position, height); locked states already
|
||||
* can't start a hold, because step() checks `!player.busy` first. This bit twice: the ladder's
|
||||
* climb and the fascia reach gate.
|
||||
* @param {function} [spec.onDone] (player, t) -> void
|
||||
* @param {string} [spec.clip] verb played for the length of the hold ('Crank', 'PickUp', …).
|
||||
* Must name a clip in player_anims.glb; omitted means the busy state's default Idle.
|
||||
@ -149,14 +155,35 @@ export class Interact {
|
||||
*/
|
||||
export function wireYardActions(interact, deps = {}) {
|
||||
const { sailRig, world } = deps;
|
||||
// createLadder publishes itself onto the Interact instance, so main.js doesn't have to thread a
|
||||
// ladder through to get the fascia reach gate. An explicit dep still wins (tests pass one).
|
||||
const ladder = deps.ladder || interact.ladder || null;
|
||||
const wired = [];
|
||||
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null;
|
||||
const anchorOf = (i) => {
|
||||
const c = cornerAt(i);
|
||||
if (!c) return null;
|
||||
return c.anchor || (world && world.anchors && world.anchors.find((a) => a.id === c.anchorId)) || null;
|
||||
};
|
||||
// a flogging corner is MOVING — resolve position every frame, never once at wire time
|
||||
const posAt = (i) => () => {
|
||||
const c = cornerAt(i);
|
||||
if (!c) return null;
|
||||
// A fascia corner is worked AT THE BRACKET, not at the cloth: the corner has detached and the
|
||||
// sail is hanging down somewhere, but re-attaching it means getting a shackle onto a fitting
|
||||
// 2.48 m up a wall. So the prompt lives at the anchor and you need the ladder to hold it.
|
||||
if (ladder && ladder.needsLadder(anchorOf(i))) {
|
||||
const a = anchorOf(i);
|
||||
return { x: a.pos.x, y: a.pos.y, z: a.pos.z + 0.9 };
|
||||
}
|
||||
return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null;
|
||||
};
|
||||
/** Fascia work needs you up the ladder that's planted under THAT bracket. */
|
||||
const canReach = (i, p) => {
|
||||
const a = anchorOf(i);
|
||||
if (!ladder || !ladder.needsLadder(a)) return true; // everything else is ground work
|
||||
return ladder.isWorking(a.id) && p.reachY >= a.pos.y;
|
||||
};
|
||||
|
||||
if (sailRig && Array.isArray(sailRig.corners)) {
|
||||
sailRig.corners.forEach((_corner, i) => {
|
||||
@ -166,10 +193,10 @@ export function wireYardActions(interact, deps = {}) {
|
||||
pos: posAt(i),
|
||||
radius: 1.8,
|
||||
holdSecs: 2.5,
|
||||
label: 're-rig corner',
|
||||
label: (p) => (canReach(i, p) ? 're-rig corner' : 'out of reach — needs the ladder'),
|
||||
clip: 'Crank',
|
||||
canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken)
|
||||
&& p.carrying === 'spare' && !!sailRig.repair,
|
||||
&& p.carrying === 'spare' && !!sailRig.repair && canReach(i, p),
|
||||
onDone: (p) => { p.carrying = null; sailRig.repair(i); },
|
||||
}));
|
||||
// per-corner turnbuckle trim — new vs the prototype; makes corners individual
|
||||
@ -178,9 +205,9 @@ export function wireYardActions(interact, deps = {}) {
|
||||
pos: posAt(i),
|
||||
radius: 1.8,
|
||||
holdSecs: 1.2,
|
||||
label: 'tighten turnbuckle',
|
||||
label: (p) => (canReach(i, p) ? 'tighten turnbuckle' : 'out of reach — needs the ladder'),
|
||||
clip: 'Crank',
|
||||
canUse: () => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim,
|
||||
canUse: (p) => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim && canReach(i, p),
|
||||
onDone: () => sailRig.trim(i, +0.1),
|
||||
}));
|
||||
});
|
||||
|
||||
210
web/world/js/ladder.js
Normal file
210
web/world/js/ladder.js
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* ladder.js — the ladder sub-system. (Lane D, SPRINT4 decision 12)
|
||||
*
|
||||
* Why it exists: the house fascia brackets sit at y=2.48 and a 1.72 m person's hands reach 2.20.
|
||||
* Two hundred millimetres is the whole mechanic. Everything else in the yard you can rig from the
|
||||
* ground — a sail post is tensioned from a cleat at its base, a tree anchor is a strop you throw —
|
||||
* but a bracket bolted 2.5 m up a bare wall is not negotiable, and E's ladder tops out at exactly
|
||||
* 2.9 m. The asset and the yard were built for each other; this file is the verb between them.
|
||||
*
|
||||
* The loop it creates is the "limited hands" rule from DESIGN.md doing real work:
|
||||
* ladder and spare are BOTH carry items, and you can only hold one.
|
||||
* So a fascia repair costs two trips — fetch the ladder, plant it, go back for the spare —
|
||||
* while a post repair costs one. The house is the expensive anchor to depend on, which is
|
||||
* exactly what E's ratingHint 0.35 / collateral "gutter" is already telling you in the data.
|
||||
*
|
||||
* Ownership: Lane D. Self-wires from createPlayer() in player.js, so main.js (Lane A's file) needs
|
||||
* no change to get this — it already hands createPlayer the scene, world and interact.
|
||||
*/
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
|
||||
export const LADDER_URL = './models/ladder_01_v1.glb';
|
||||
|
||||
/**
|
||||
* Which anchors you cannot rig from the ground.
|
||||
*
|
||||
* Deliberately keyed on the anchor TYPE, not on a height test. A pure "is it above reach?" rule
|
||||
* would rope in the posts (3.95 m) and tree limbs (up to 5.05 m) and turn every single repair into
|
||||
* a two-trip ladder job — which is both untrue to how sails are actually rigged and would have
|
||||
* silently invalidated the recorded §7 run and Lane B's gate asserts. Decision 12 scopes this to
|
||||
* the fascia; this is that scope, in one line, where it can be found and argued with.
|
||||
*/
|
||||
export const needsLadder = (anchor) => !!anchor && anchor.type === 'house';
|
||||
|
||||
/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */
|
||||
const STAND_OFF = 0.9; // m clear of the wall face
|
||||
const PLACE_RANGE = 2.6; // m — how close you must be to a fascia anchor to plant the ladder
|
||||
const RUNG_CLEAR = 0.55; // m — feet this far below the top rung, so the fascia is at chest height
|
||||
|
||||
/**
|
||||
* @param {THREE.Object3D} scene
|
||||
* @param {object} world contracts World (must be dressed — anchors are final only after dress())
|
||||
* @param {object} interact Lane D's Interact
|
||||
* @param {object} player the PlayerSim
|
||||
* @returns {object} the ladder system
|
||||
*/
|
||||
export function createLadder(scene, world, interact, player) {
|
||||
const anchors = (world.anchors || []).filter(needsLadder);
|
||||
if (!anchors.length) {
|
||||
// no fascia in this yard (a bare harness, say) — the whole sub-system is moot, don't half-wire it
|
||||
return { placedAt: null, carried: false, needsLadder, isWorking: () => false,
|
||||
workY: () => 0, servedAnchor: () => null, update() {}, dispose() {} };
|
||||
}
|
||||
|
||||
const state = {
|
||||
carried: false, // in the player's hands
|
||||
placedAt: null, // anchor id, or null while stowed/carried
|
||||
base: new THREE.Vector3(),
|
||||
topY: 2.9, // overwritten from the GLB's ladder_top node
|
||||
view: null,
|
||||
};
|
||||
|
||||
// Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable
|
||||
// so it follows the shed if Lane A moves it. The offset is deliberately ~3 m: at 1.4 m the two
|
||||
// prompts overlapped and standing at the ladder offered you "take a spare", which is the kind of
|
||||
// thing that reads as a broken game rather than a crowded shed.
|
||||
const home = new THREE.Vector3(10.4, 0, 3.4);
|
||||
if (world.shedTable && world.shedTable.pos) {
|
||||
home.set(world.shedTable.pos.x + 1.4, 0, world.shedTable.pos.z - 2.6);
|
||||
}
|
||||
home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0;
|
||||
state.base.copy(home);
|
||||
|
||||
// --- view -----------------------------------------------------------------
|
||||
new GLTFLoader().load(LADDER_URL, (g) => {
|
||||
const obj = g.scene;
|
||||
const top = obj.getObjectByName('ladder_top');
|
||||
if (top) state.topY = top.position.y;
|
||||
obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
|
||||
state.view = obj;
|
||||
scene.add(obj);
|
||||
syncView();
|
||||
}, undefined, () => { /* missing asset: the mechanic still works, you just can't see it */ });
|
||||
|
||||
const LEAN = 0.26; // rad (~15°) — a ladder stood bolt upright reads as a post, not a ladder
|
||||
function syncView() {
|
||||
if (!state.view) return;
|
||||
state.view.visible = !state.carried;
|
||||
state.view.position.copy(state.base);
|
||||
const a = state.placedAt && world.anchors.find((x) => x.id === state.placedAt);
|
||||
if (a) {
|
||||
// planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the
|
||||
// top toward local +Z, which is the wall — so the feet stand off and the head rests on it.
|
||||
state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0);
|
||||
} else {
|
||||
state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed
|
||||
}
|
||||
}
|
||||
|
||||
/** The fascia anchor this ladder is currently serving, if any. */
|
||||
const servedAnchor = () => (state.placedAt ? world.anchors.find((a) => a.id === state.placedAt) : null);
|
||||
|
||||
/** Standing height at the top of the ladder — feet a rung or two down from the very top. */
|
||||
const workY = () => Math.max(0, state.topY - RUNG_CLEAR);
|
||||
|
||||
/**
|
||||
* True if the player is up THIS ladder and can work the given anchor.
|
||||
* Height only, deliberately — hold-E moves the player into `busy`, so testing for state 'atTop'
|
||||
* here would make a fascia repair un-usable the moment it started and cancel its own hold.
|
||||
*/
|
||||
function isWorking(anchorId) {
|
||||
return state.placedAt === anchorId && player.climbY > workY() - 0.15;
|
||||
}
|
||||
|
||||
// --- interactions ---------------------------------------------------------
|
||||
const wired = [];
|
||||
|
||||
// 1. pick the ladder up (from its home, or from wherever it's planted)
|
||||
wired.push(interact.register({
|
||||
id: 'ladder_take',
|
||||
pos: () => (state.carried ? null : state.base),
|
||||
radius: 1.6,
|
||||
holdSecs: 0.8,
|
||||
clip: 'PickUp',
|
||||
label: (p) => (p.carrying ? 'hands full' : state.placedAt ? 'take the ladder back' : 'take the ladder'),
|
||||
canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02,
|
||||
onDone: (p, t) => {
|
||||
state.carried = true;
|
||||
state.placedAt = null;
|
||||
p.pickUp('ladder', t);
|
||||
syncView();
|
||||
},
|
||||
}));
|
||||
|
||||
// 2. plant it at a fascia anchor
|
||||
for (const a of anchors) {
|
||||
wired.push(interact.register({
|
||||
id: `ladder_place_${a.id}`,
|
||||
// stand off the wall, on the yard side — the ladder leans in toward the bracket
|
||||
pos: () => ({ x: a.pos.x, y: 0, z: a.pos.z + STAND_OFF }),
|
||||
radius: PLACE_RANGE,
|
||||
holdSecs: 1.0,
|
||||
clip: 'PickUp',
|
||||
label: `set the ladder under ${a.id}`,
|
||||
canUse: (p) => p.carrying === 'ladder',
|
||||
onDone: (p, t) => {
|
||||
state.carried = false;
|
||||
state.placedAt = a.id;
|
||||
state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0,
|
||||
a.pos.z + STAND_OFF);
|
||||
p.carrying = null;
|
||||
p.events.push({ type: 'ladderPlaced', anchorId: a.id, t });
|
||||
syncView();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. climb it — only when it's planted, and only from the ground
|
||||
wired.push(interact.register({
|
||||
id: 'ladder_climb',
|
||||
pos: () => (state.placedAt && !state.carried ? state.base : null),
|
||||
radius: 1.5,
|
||||
holdSecs: 0.4,
|
||||
clip: 'PickUp',
|
||||
label: 'climb',
|
||||
// NB: no test on p.state here. Starting a hold moves the player into `busy`, so a canUse that
|
||||
// reads state goes false the instant the hold begins and cancels itself. climbY is the honest
|
||||
// gate (are you on the ground?), and locked states can't start a hold anyway.
|
||||
canUse: (p) => !!state.placedAt && !state.carried && p.climbY < 0.02,
|
||||
onDone: (p, t) => {
|
||||
p.pos.x = state.base.x; p.pos.z = state.base.z; // step onto the rungs
|
||||
const a = servedAnchor();
|
||||
if (a) p.facing = Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z);
|
||||
p.climbTo(workY(), t);
|
||||
},
|
||||
}));
|
||||
|
||||
const api = {
|
||||
get placedAt() { return state.placedAt; },
|
||||
get carried() { return state.carried; },
|
||||
get base() { return state.base; },
|
||||
get topY() { return state.topY; },
|
||||
workY,
|
||||
isWorking,
|
||||
servedAnchor,
|
||||
needsLadder,
|
||||
|
||||
/**
|
||||
* Drive descent from input. Held S climbs down; nothing else can strand you up there, and a
|
||||
* knockdown already drops climbY to 0 on its own.
|
||||
* player.js calls this each frame from the same input it reads for movement.
|
||||
*/
|
||||
update(dt, t, input) {
|
||||
if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t);
|
||||
syncView();
|
||||
},
|
||||
|
||||
dispose() {
|
||||
wired.forEach((un) => un());
|
||||
if (interact.ladder === api) interact.ladder = null;
|
||||
if (state.view) scene.remove(state.view);
|
||||
},
|
||||
};
|
||||
|
||||
// Publish onto the Interact instance so wireYardActions can find the reach gate without main.js
|
||||
// (Lane A's file) having to learn about ladders and thread it through. Interact is Lane D's own
|
||||
// class, so this stays inside the lane; an explicit `deps.ladder` still wins if anyone passes one.
|
||||
interact.ladder = api;
|
||||
return api;
|
||||
}
|
||||
@ -15,9 +15,10 @@
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js';
|
||||
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
import { PlayerSim, STATES, TUNE, clipFor } from './player.sim.js';
|
||||
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
|
||||
import { createLadder } from './ladder.js';
|
||||
|
||||
export { PlayerSim, STATES, TUNE, clipFor };
|
||||
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder };
|
||||
|
||||
export const CHAR_URL = './models/player_01.glb';
|
||||
export const ANIM_URL = './models/player_anims.glb';
|
||||
@ -193,7 +194,10 @@ export class PlayerView {
|
||||
// clipFor, not st.clip: carrying swaps in Carry/CarryIdle, and an interaction names its own verb
|
||||
this.play(clipFor(sim), st.loop !== false);
|
||||
|
||||
this.root.position.set(sim.pos.x, sim.pos.y, sim.pos.z);
|
||||
// climbY lifts the whole rig up the rungs. Same trick as the knockdown pitch: _rotOnly strips
|
||||
// the root from every clip, so ClimbLadder can't raise the body — the sim does, and the clip
|
||||
// just supplies the arms and legs.
|
||||
this.root.position.set(sim.pos.x, sim.pos.y + (sim.climbY || 0), sim.pos.z);
|
||||
|
||||
// yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the
|
||||
// feet. At pitch 0 this is exactly the yaw, so upright play is untouched.
|
||||
@ -278,6 +282,12 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
const keyboard = new KeyboardInput();
|
||||
const { sim, view } = p;
|
||||
|
||||
// The ladder self-wires from here rather than from main.js: createPlayer is already handed the
|
||||
// scene, the world and interact, which is everything it needs — so Lane A's file doesn't have to
|
||||
// change to get a whole sub-system. Opt out with {ladder: false} if a harness doesn't want it.
|
||||
const ladder = (opts.ladder === false || !opts.interact)
|
||||
? null : createLadder(scene, world, opts.interact, sim);
|
||||
|
||||
return {
|
||||
get pos() { return sim.pos; },
|
||||
get carrying() { return sim.carrying; },
|
||||
@ -288,6 +298,7 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
update(dt, t) {
|
||||
const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
|
||||
sim.step(dt, t, input, opts.wind);
|
||||
if (ladder) ladder.update(dt, t, input);
|
||||
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
|
||||
view.sync(sim, dt);
|
||||
},
|
||||
@ -296,8 +307,9 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
get object() { return view.root; },
|
||||
sim,
|
||||
view,
|
||||
ladder,
|
||||
keyboard,
|
||||
dispose() { keyboard.dispose(); view.dispose(); },
|
||||
dispose() { keyboard.dispose(); view.dispose(); if (ladder) ladder.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -29,8 +29,26 @@ export const STATES = {
|
||||
stagger: { clip: 'Reaction', locked: true, loop: false, secs: 0.9, next: 'idle' },
|
||||
knocked: { clip: 'Falling', locked: true, loop: false, secs: 1.4, next: 'getup' },
|
||||
getup: { clip: 'CrouchToStand', locked: true, loop: false, secs: 1.3, next: 'idle' },
|
||||
|
||||
// --- the ladder (decision 12). climbY is driven in code and the clip plays on top, exactly the
|
||||
// knockdown precedent: _rotOnly strips the root, so ClimbLadder can no more lift the body than
|
||||
// Falling could lay it down. ladder.js calls climbTo(); the sim owns the height. ---
|
||||
climb: { clip: 'ClimbLadder', locked: true, loop: true, releasedBy: 'ladder' },
|
||||
// atTop is deliberately NOT locked: `busy` gates interact.js, and the whole point of being up
|
||||
// there is that hold-E works. Movement is stopped by `onLadder` instead, not by `locked`.
|
||||
atTop: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true, releasedBy: 'ladder' },
|
||||
};
|
||||
|
||||
/**
|
||||
* True while the player is on a ladder — movement is off, and the wind is meaner.
|
||||
*
|
||||
* Keyed on HEIGHT, not on state, and that distinction is load-bearing: hold-E puts you into `busy`,
|
||||
* so a state-based test would say you'd stepped off the ladder the instant you started the repair
|
||||
* you climbed up to do. (It did exactly that — the gate cancelled its own hold.) Height is the
|
||||
* physical truth and survives every state the ladder passes through.
|
||||
*/
|
||||
export const onLadder = (sim) => sim.climbY > 0.02 || sim.state === 'climb';
|
||||
|
||||
/**
|
||||
* Which clip a state actually plays right now. Carrying swaps the locomotion set (Carry/CarryIdle),
|
||||
* and an interaction can name its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) —
|
||||
@ -96,6 +114,13 @@ export const TUNE = {
|
||||
// gusts are too strong to cross the yard" — wait one out, then move in the lull.
|
||||
shelterKnockMult: 2.0, // knockWind × this while braced — a gust that floors you standing won't
|
||||
shelterShoveMult: 0.25, // and it barely pushes you
|
||||
|
||||
// Ladder (decision 12). DESIGN.md: "ladder work at height in wind is genuinely tense" — this is
|
||||
// where that gets teeth. You cannot brace up there (both hands are on the rungs), so the only
|
||||
// defence is choosing your moment: climb in a lull, not through a gust.
|
||||
climbRate: 1.1, // m/s up or down a rung — slow enough that the storm gets a vote
|
||||
reach: 2.2, // m — how high a 1.72 m person's hands get, standing. Fascia sits at 2.48.
|
||||
ladderKnockMult: 0.6, // knockWind × this while on the ladder — far easier to be blown off
|
||||
};
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
@ -140,6 +165,10 @@ export class PlayerSim {
|
||||
this.pitch = 0; // 0 upright … 1 flat on the ground
|
||||
this.knockDir = { x: 0, z: 1 }; // which way the body went down
|
||||
|
||||
this.climbY = 0; // m above the ground; >0 means you're up a ladder
|
||||
this.climbTarget = 0; // where ladder.js asked you to be
|
||||
this.fellFrom = 0; // m — height of the last fall, for the HUD/aftermath to shame you with
|
||||
|
||||
this.groundAt = opts.groundAt || (() => 0);
|
||||
this.collide = opts.collide || null;
|
||||
this.bodyHeight = opts.height || 1.72;
|
||||
@ -151,6 +180,23 @@ export class PlayerSim {
|
||||
get clip() { return STATES[this.state].clip; }
|
||||
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
|
||||
|
||||
/** How high this person's hands get right now. The gate on reaching a fascia bracket. */
|
||||
get reachY() { return this.pos.y + this.climbY + this.tune.reach; }
|
||||
|
||||
/**
|
||||
* Go up or down a ladder. ladder.js owns WHERE (it knows the rungs); the sim owns the motion, so
|
||||
* a climb is deterministic and fast-forwards in selftest like everything else.
|
||||
* @param {number} y target height above ground; 0 climbs back down
|
||||
*/
|
||||
climbTo(y, t = 0) {
|
||||
this.climbTarget = Math.max(0, y);
|
||||
if (Math.abs(this.climbTarget - this.climbY) > 0.02) {
|
||||
this.vel.x = this.vel.z = 0;
|
||||
this.setState('climb', t);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setState(s, t = 0) {
|
||||
if (this.state === s) return false;
|
||||
if (!STATES[s]) throw new Error(`player: unknown state ${s}`);
|
||||
@ -194,11 +240,16 @@ export class PlayerSim {
|
||||
if (x === undefined || (x === 0 && z === 0)) { x = Math.sin(this.facing); z = Math.cos(this.facing); }
|
||||
const m = Math.hypot(x, z) || 1;
|
||||
this.knockDir = { x: x / m, z: z / m };
|
||||
// Blown off a ladder: you don't stagger, you fall. Everything else about a knockdown is the
|
||||
// same, so the ladder gets the get-up chain for free — you just arrive on the ground first.
|
||||
this.fellFrom = this.climbY;
|
||||
this.climbY = 0;
|
||||
this.climbTarget = 0;
|
||||
this.setState('knocked', t);
|
||||
this.exposure = 0;
|
||||
this.vel.x = this.vel.z = 0;
|
||||
this.drop(t);
|
||||
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir } });
|
||||
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir }, fellFrom: this.fellFrom });
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -225,7 +276,9 @@ export class PlayerSim {
|
||||
this.gust = Math.max(0, ws - this.windBase);
|
||||
|
||||
// --- shelter: hold to brace. Enters and leaves itself, so releasing the key always frees you
|
||||
// even mid-gust. Refused while you're down — you can't brace from your back. ---
|
||||
// even mid-gust. Refused while you're down — you can't brace from your back, and refused on
|
||||
// a ladder: both hands are on the rungs. Up there your only defence is having picked a lull. ---
|
||||
const up = onLadder(this);
|
||||
const wantShelter = !!input.shelter;
|
||||
const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run';
|
||||
if (wantShelter && canShelter) this.setState('shelter', t);
|
||||
@ -233,12 +286,34 @@ export class PlayerSim {
|
||||
const braced = this.state === 'shelter';
|
||||
|
||||
// --- sustained extreme wind puts you down (same rule as a sail corner letting go).
|
||||
// Bracing raises the bar rather than removing it: a big enough gust still wins. ---
|
||||
const knockAt = braced ? T.knockWind * T.shelterKnockMult : T.knockWind;
|
||||
// Bracing raises the bar; a ladder LOWERS it. Same exposure clock either way, so the storm
|
||||
// speaks one language whether you're on your feet or up a rung. ---
|
||||
const knockAt = T.knockWind
|
||||
* (braced ? T.shelterKnockMult : 1)
|
||||
* (up ? T.ladderKnockMult : 1);
|
||||
if (ws > knockAt) this.exposure += dt;
|
||||
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
|
||||
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
|
||||
|
||||
// --- the climb itself: code-driven height, ClimbLadder plays on top (the knockdown precedent) ---
|
||||
if (this.state === 'climb') {
|
||||
const dy = this.climbTarget - this.climbY;
|
||||
const rung = T.climbRate * dt;
|
||||
if (Math.abs(dy) <= rung) {
|
||||
this.climbY = this.climbTarget;
|
||||
this.setState(this.climbY > 0.02 ? 'atTop' : 'idle', t);
|
||||
} else {
|
||||
this.climbY += Math.sign(dy) * rung;
|
||||
}
|
||||
}
|
||||
// Invariant: you cannot be standing on the grass while you are 2 m up a ladder. interact.js
|
||||
// releases a finished hold to 'idle' without knowing where you are; this puts you back in the
|
||||
// work stance instead of leaving you idling in mid-air.
|
||||
if (this.climbY > 0.02
|
||||
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
|
||||
this.setState('atTop', t);
|
||||
}
|
||||
|
||||
// --- a gust below the knockdown bar can still break your stride ---
|
||||
if (!braced && this.gust > T.stumbleGust && this.stumbleCool <= 0
|
||||
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
|
||||
@ -248,11 +323,12 @@ export class PlayerSim {
|
||||
}
|
||||
|
||||
const st = STATES[this.state];
|
||||
const aloft = onLadder(this); // re-read: the climb block above may have just landed you
|
||||
|
||||
// --- movement ---
|
||||
const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you
|
||||
let wantX = 0, wantZ = 0;
|
||||
if (!st.locked) {
|
||||
if (!st.locked && !aloft) {
|
||||
const ix = input.x || 0, iz = input.z || 0;
|
||||
const mag = Math.hypot(ix, iz);
|
||||
if (mag > 1e-3) {
|
||||
@ -305,8 +381,10 @@ export class PlayerSim {
|
||||
const pstep = dt / T.pitchSecs;
|
||||
this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);
|
||||
|
||||
// --- locomotion state from actual speed (so shove/slow can't desync the feet) ---
|
||||
if (!st.locked) {
|
||||
// --- locomotion state from actual speed (so shove/slow can't desync the feet).
|
||||
// `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without
|
||||
// this guard the speed check would immediately re-state you to idle and drop you off. ---
|
||||
if (!st.locked && !aloft) {
|
||||
const sp = this.speed;
|
||||
this.setState(sp < 0.15 ? 'idle' : sp > T.walkSpeed * 1.35 ? 'run' : 'walk', t);
|
||||
} else if (st.secs && this.stateT >= st.secs && st.next) {
|
||||
|
||||
@ -755,9 +755,37 @@ export async function createSailView(rig, { color = 0xd8c48a } = {}) {
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
|
||||
geo.setIndex(new THREE.BufferAttribute(new Uint16Array(rig.tris), 1));
|
||||
|
||||
// UVs: the grid IS the UV space, so (i, j) maps straight to (u, v). Without
|
||||
// this three defaults every vertex to (0,0), the map samples one texel, and
|
||||
// the membrane reads as flat colour — which looks like the texture failing
|
||||
// rather than like a bug. (Lane E's recipe, THREADS.)
|
||||
const N = rig.N;
|
||||
const uv = new Float32Array(N * N * 2);
|
||||
for (let j = 0, k = 0; j < N; j++) {
|
||||
for (let i = 0; i < N; i++, k += 2) { uv[k] = i / (N - 1); uv[k + 1] = j / (N - 1); }
|
||||
}
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color, side: THREE.DoubleSide, roughness: 0.92, metalness: 0.0,
|
||||
});
|
||||
|
||||
// Resolved against this module rather than the server root: the same reason
|
||||
// weather.js builds STORM_DIR this way, and it's what the integrator's
|
||||
// /world/ -> relative pass was fixing. A missing texture must not take the
|
||||
// sail down — the cloth is the game, the weave is a finish.
|
||||
try {
|
||||
const tex = await new THREE.TextureLoader().loadAsync(
|
||||
new URL('../models/textures/sail_weave.png', import.meta.url).href,
|
||||
);
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(6, 6); // ~6 tiles across a 5 m sail (E's density)
|
||||
tex.colorSpace = THREE.SRGBColorSpace; // r175 spelling — `encoding` is gone
|
||||
tex.anisotropy = 4; // it's viewed at a raking angle from underneath
|
||||
mat.map = tex; // keep mat.color: the weave multiplies it
|
||||
} catch (err) {
|
||||
console.warn('[sail] weave texture missing, falling back to flat colour:', err.message);
|
||||
}
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.castShadow = true; // the shadow IS the product
|
||||
mesh.receiveShadow = true;
|
||||
|
||||
@ -8,13 +8,27 @@ import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../con
|
||||
import { createWorld, heightAt } from '../world.js';
|
||||
import { createCameraRig } from '../camera.js';
|
||||
import { createGame } from '../main.js';
|
||||
import { orderRing } from '../sail.js';
|
||||
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
export default async function run(t) {
|
||||
const scene = new THREE.Scene();
|
||||
const world = createWorld(scene, { wind: createStubWind({ calm: true }) });
|
||||
|
||||
// Dress the yard before asserting anything about it: anchors are only FINAL
|
||||
// after dress(), which moves them onto the positions Lane E baked and adds the
|
||||
// extra tree branches. Testing the graybox would be testing a yard that never
|
||||
// reaches a player. Guarded, so a missing server degrades to graybox asserts
|
||||
// rather than reddening the whole lane.
|
||||
let dressed = false;
|
||||
try {
|
||||
await world.dress();
|
||||
dressed = true;
|
||||
} catch (err) {
|
||||
console.warn('[a.test] dress() unavailable, asserting against graybox:', err.message);
|
||||
}
|
||||
|
||||
// --- contract conformance ------------------------------------------------
|
||||
// These are the merge tripwires: if a lane's module drifts from contracts.js,
|
||||
// this is where we find out, not three lanes later.
|
||||
@ -111,15 +125,138 @@ export default function run(t) {
|
||||
|
||||
// --- anchors -------------------------------------------------------------
|
||||
|
||||
t.test('yard offers 7 anchors: 3 house, 2 tree, 2 post', () => {
|
||||
t.test('yard offers 11 anchors: 3 house, 5 tree, 3 post', () => {
|
||||
const by = (type) => world.anchors.filter((a) => a.type === type).length;
|
||||
assertEq(by('house'), 3, 'house anchors');
|
||||
assertEq(by('tree'), 2, 'tree anchors');
|
||||
assertEq(by('post'), 2, 'post anchors');
|
||||
assertEq(by('tree'), dressed ? 5 : 2, 'tree anchors (branch_anchor_* arrive with dress())');
|
||||
assertEq(by('post'), 3, 'post anchors — p3 added, SPRINT3 decision 2');
|
||||
const ids = world.anchors.map((a) => a.id);
|
||||
assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`);
|
||||
});
|
||||
|
||||
t.test('anchors carry Lane E\'s rating_hint, and the fascia is the weak one', () => {
|
||||
if (!dressed) return t.skip('needs dress()');
|
||||
const hint = (id) => world.anchors.find((a) => a.id === id)?.ratingHint;
|
||||
// DESIGN.md: "The fascia board is a lie: holds until the first real gust."
|
||||
// Lane E encoded that as rating_hint 0.35 in house_yardside_v1.glb, so the
|
||||
// asset says it and nothing here has to restate it. If this ever flips to
|
||||
// 1.0, the yard has quietly stopped teaching its best lesson.
|
||||
assertLess(hint('h1'), 0.5, 'fascia anchor should be the weak option');
|
||||
assertEq(world.anchors.find((a) => a.id === 'h1').collateral, 'gutter',
|
||||
'a fascia failure takes the gutter with it — that is the collateral cost');
|
||||
assert(hint('t1') > hint('t1c'),
|
||||
'a branch anchor at the fork must out-rate one out where the limb is thin');
|
||||
});
|
||||
|
||||
// --- decision 2: the yard has to offer a real choice ----------------------
|
||||
|
||||
t.test('yard offers ≥3 riggable quads in the 18-45 m² band that shade the bed', () => {
|
||||
if (!dressed) return t.skip('needs dress() — anchors are only final after it');
|
||||
|
||||
// SPRINT3 decision 2. Before the rework every quad covering the bed was
|
||||
// 110 m²+, which pre-tensions itself into a cascade at t=0.4 s before the
|
||||
// wind does anything — the yard taught the wrong lesson.
|
||||
const bed = world.gardenBed;
|
||||
const areaOf = (q) => {
|
||||
const r = orderRing(q);
|
||||
let a = 0;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
|
||||
}
|
||||
return Math.abs(a / 2);
|
||||
};
|
||||
const inside = (x, z, r) => {
|
||||
let c = false;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
const a = r[i].pos, b = r[j].pos;
|
||||
if ((a.z > z) !== (b.z > z) && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x) c = !c;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
const coverOf = (q) => {
|
||||
const r = orderRing(q);
|
||||
let hit = 0, tot = 0;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const x = bed.x - bed.w / 2 + ((i + 0.5) / 6) * bed.w;
|
||||
const z = bed.z - bed.d / 2 + ((j + 0.5) / 4) * bed.d;
|
||||
tot++;
|
||||
if (inside(x, z, r)) hit++;
|
||||
}
|
||||
}
|
||||
return hit / tot;
|
||||
};
|
||||
|
||||
const A = world.anchors;
|
||||
const band = [];
|
||||
for (let i = 0; i < A.length; i++) {
|
||||
for (let j = i + 1; j < A.length; j++) {
|
||||
for (let k = j + 1; k < A.length; k++) {
|
||||
for (let l = k + 1; l < A.length; l++) {
|
||||
const q = [A[i], A[j], A[k], A[l]];
|
||||
const m2 = areaOf(q);
|
||||
if (m2 >= 18 && m2 <= 45 && coverOf(q) >= 0.25) {
|
||||
band.push(`${q.map((a) => a.id).join('+')} ${m2.toFixed(0)}m²`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(band.length >= 3,
|
||||
`only ${band.length} quads in 18-45 m² shade the bed — the yard offers no ` +
|
||||
`storm-survivable option. Found: ${band.join(', ') || 'none'}`);
|
||||
});
|
||||
|
||||
t.test('full shade over the bed stays expensive — the tradeoff is the game', () => {
|
||||
if (!dressed) return t.skip('needs dress()');
|
||||
// The other half of decision 2, and the half that is easy to "fix" by
|
||||
// accident. DESIGN.md's core tension is that big+flat+low buys great shade
|
||||
// and dies in a storm, while small+twisted survives and shades patchily. If
|
||||
// some future yard tweak ever lets a small quad cover the whole bed, that
|
||||
// tension is gone and the rigging puzzle has no wrong answers left.
|
||||
const bed = world.gardenBed;
|
||||
const A = world.anchors;
|
||||
let smallestFull = Infinity;
|
||||
const areaOf = (q) => {
|
||||
const r = orderRing(q);
|
||||
let a = 0;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
|
||||
}
|
||||
return Math.abs(a / 2);
|
||||
};
|
||||
const inside = (x, z, r) => {
|
||||
let c = false;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
const a = r[i].pos, b = r[j].pos;
|
||||
if ((a.z > z) !== (b.z > z) && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x) c = !c;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
for (let i = 0; i < A.length; i++) {
|
||||
for (let j = i + 1; j < A.length; j++) {
|
||||
for (let k = j + 1; k < A.length; k++) {
|
||||
for (let l = k + 1; l < A.length; l++) {
|
||||
const q = [A[i], A[j], A[k], A[l]];
|
||||
const r = orderRing(q);
|
||||
let hit = 0;
|
||||
for (let a = 0; a < 6; a++) {
|
||||
for (let b = 0; b < 4; b++) {
|
||||
const x = bed.x - bed.w / 2 + ((a + 0.5) / 6) * bed.w;
|
||||
const z = bed.z - bed.d / 2 + ((b + 0.5) / 4) * bed.d;
|
||||
if (inside(x, z, r)) hit++;
|
||||
}
|
||||
}
|
||||
if (hit / 24 >= 0.9) smallestFull = Math.min(smallestFull, areaOf(q));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(smallestFull > 45,
|
||||
`a ${smallestFull.toFixed(0)} m² quad covers the whole bed — full shade is ` +
|
||||
`supposed to cost you a sail the storm can take`);
|
||||
});
|
||||
|
||||
t.test('sway() returns an absolute position, not an offset', () => {
|
||||
// If sway ever regresses to returning an offset, the returned point lands
|
||||
// near the origin instead of near the anchor, and Lane B's cloth corners
|
||||
|
||||
@ -54,11 +54,13 @@ export default async function run(t) {
|
||||
assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result');
|
||||
});
|
||||
|
||||
// This assert used to read `a.y === 0` — "wind should be horizontal". SPRINT2
|
||||
// decision 3 made that false on purpose: gusts now descend, which is what makes
|
||||
// a flat sail pay. Keeping the useful half — y is downward-or-zero, never up,
|
||||
// and never garbage — so player shove and rain angle can still trust the sign.
|
||||
t.test('vertical wind is downward-only, and only during gusts', () => {
|
||||
// This assert once read `a.y === 0` — "wind is horizontal". SPRINT2 decision 3
|
||||
// made that false on purpose (gusts descend, so a flat sail pays); SPRINT3
|
||||
// decision 8 made the descent a fraction of TOTAL wind, so it's present
|
||||
// whenever it's windy, not only in gusts. Keeping the invariants that consumers
|
||||
// (player shove, rain angle, HUD) rely on: y is down-or-zero, never up, never
|
||||
// garbage, and speedAt() is horizontal-only.
|
||||
t.test('vertical wind is downward-only and rides the wind', () => {
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const pos = new THREE.Vector3(0, 1.7, 0);
|
||||
const v = new THREE.Vector3();
|
||||
@ -66,12 +68,13 @@ export default async function run(t) {
|
||||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||
wind.sample(pos, time, v);
|
||||
assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`);
|
||||
if (v.y < -1) sawDown = true;
|
||||
assert(Number.isFinite(v.y), `vertical wind is not finite at t=${time.toFixed(2)}`);
|
||||
if (v.y < -2) sawDown = true;
|
||||
});
|
||||
assert(sawDown, 'never saw a downdraft worth the name in a whole wild night');
|
||||
// and the wind meter must stay horizontal — a falling gust shouldn't spike the HUD
|
||||
const calm = wind.speedAt(pos, 0.5);
|
||||
assert(Math.abs(calm - Math.hypot(wind.sample(pos, 0.5).x, wind.sample(pos, 0.5).z)) < 1e-9,
|
||||
// the wind meter must stay horizontal — falling air shouldn't spike the HUD
|
||||
const s = wind.sample(pos, 0.5);
|
||||
assert(Math.abs(wind.speedAt(pos, 0.5) - Math.hypot(s.x, s.z)) < 1e-9,
|
||||
'speedAt() is not the horizontal magnitude of sample()');
|
||||
});
|
||||
|
||||
|
||||
@ -343,6 +343,180 @@ export default async function run(t) {
|
||||
assertLess(bracedKnocks, exposedKnocks, 'and bracing through it is strictly better');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- the ladder (decision 12)
|
||||
// A fake ladder standing in for ladder.js's THREE half: same shape, no GLB, no scene. The real
|
||||
// one is verified by hand in the game; these pin the legs of the state machine.
|
||||
const fakeLadder = (opts = {}) => {
|
||||
const st = { placedAt: opts.placedAt || null, carried: false };
|
||||
return {
|
||||
needsLadder: (a) => !!a && a.type === 'house',
|
||||
workY: () => 2.35,
|
||||
// height only, mirroring the real ladder.js — see the note there on why testing for 'atTop'
|
||||
// here makes the repair cancel its own hold
|
||||
isWorking: (id) => st.placedAt === id && !!opts.player && opts.player.climbY > 2.2,
|
||||
servedAnchor: () => null,
|
||||
get placedAt() { return st.placedAt; },
|
||||
_place: (id) => { st.placedAt = id; },
|
||||
update() {}, dispose() {},
|
||||
};
|
||||
};
|
||||
|
||||
t.test('ladder: climb is code-driven and lands in a work stance', () => {
|
||||
const s = new PlayerSim();
|
||||
assertEq(s.climbY, 0, 'starts on the ground');
|
||||
s.climbTo(2.35);
|
||||
assertEq(s.state, 'climb', 'climbing');
|
||||
assert(s.busy, 'you cannot be interrupted mid-rung');
|
||||
assertEq(clipFor(s), 'ClimbLadder', 'and ClimbLadder plays');
|
||||
drive(s, 0.5);
|
||||
assert(s.climbY > 0.3 && s.climbY < 2.35, `partway up, got ${s.climbY.toFixed(2)}`);
|
||||
drive(s, 3);
|
||||
assertEq(s.state, 'atTop', 'arrives in the work stance');
|
||||
assertClose(s.climbY, 2.35, 1e-6, 'at the top rung');
|
||||
assert(!s.busy, 'atTop must NOT be busy — the whole point is that hold-E works up there');
|
||||
});
|
||||
|
||||
t.test('ladder: the fascia is out of reach from the ground and in reach from the top', () => {
|
||||
const s = new PlayerSim();
|
||||
const FASCIA_Y = 2.48; // measured in the real yard
|
||||
assertLess(s.reachY, FASCIA_Y, 'standing on the ground, a 1.72 m person cannot reach the bracket');
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
assert(s.reachY >= FASCIA_Y, `up the ladder they can, reach=${s.reachY.toFixed(2)}`);
|
||||
});
|
||||
|
||||
t.test('ladder: you cannot walk while you are on it', () => {
|
||||
const s = new PlayerSim();
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
assertEq(s.state, 'atTop');
|
||||
const x0 = s.pos.x, z0 = s.pos.z;
|
||||
drive(s, 1.5, { x: 1, z: 1, run: true, camYaw: 0 });
|
||||
assertClose(s.pos.x, x0, 1e-9, 'WASD does not walk you off a ladder');
|
||||
assertClose(s.pos.z, z0, 1e-9);
|
||||
assertEq(s.state, 'atTop', 'and you stay in the work stance');
|
||||
});
|
||||
|
||||
t.test('ladder: descending returns you to the ground and frees you', () => {
|
||||
const s = new PlayerSim();
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
s.climbTo(0);
|
||||
assertEq(s.state, 'climb', 'going down is the same clip');
|
||||
drive(s, 4);
|
||||
assertEq(s.state, 'idle', 'back on your feet');
|
||||
assertEq(s.climbY, 0);
|
||||
drive(s, 1, { x: 0, z: 1, camYaw: 0 });
|
||||
assertEq(s.state, 'walk', 'and walking again');
|
||||
});
|
||||
|
||||
t.test('ladder: you cannot brace up there — both hands are on the rungs', () => {
|
||||
const s = new PlayerSim();
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
// a wind under even the ladder's lowered bar, so this isolates the brace refusal from the fall
|
||||
drive(s, 1, { shelter: true }, windX(TUNE.knockWind * TUNE.ladderKnockMult - 4));
|
||||
assertEq(s.state, 'atTop', 'holding C on a ladder does nothing');
|
||||
});
|
||||
|
||||
t.test('ladder: the wind is meaner at height, and being blown off is a FALL', () => {
|
||||
// a wind that is survivable standing must be able to take you off the ladder
|
||||
const between = TUNE.knockWind * TUNE.ladderKnockMult + 2; // over the ladder bar, under the standing one
|
||||
assertLess(between, TUNE.knockWind, 'the test wind must be survivable on the ground');
|
||||
|
||||
const ground = new PlayerSim();
|
||||
drive(ground, TUNE.knockSustain + 0.5, {}, windX(between));
|
||||
assertEq(ground.state, 'idle', 'on your feet this wind is nothing');
|
||||
|
||||
const up = new PlayerSim();
|
||||
up.climbTo(2.35); drive(up, 4);
|
||||
up.carrying = 'spare';
|
||||
drive(up, TUNE.knockSustain + 0.3, {}, windX(between));
|
||||
assertEq(up.state, 'knocked', 'the same wind takes you off the ladder');
|
||||
assertEq(up.climbY, 0, 'you are on the ground now, not floating at height');
|
||||
assertClose(up.fellFrom, 2.35, 1e-6, 'and the fall height is recorded');
|
||||
assertEq(up.carrying, null, 'you dropped the spare on the way down');
|
||||
drive(up, 3);
|
||||
assertEq(up.state, 'idle', 'the ladder gets the normal get-up chain for free');
|
||||
});
|
||||
|
||||
t.test('ladder: needsLadder is scoped to the fascia, not to everything above head height', () => {
|
||||
const L = fakeLadder();
|
||||
assert(L.needsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it');
|
||||
assert(!L.needsLadder({ type: 'post', pos: { y: 3.95 } }),
|
||||
'a 4 m post does NOT — you tension it from a cleat at the base');
|
||||
assert(!L.needsLadder({ type: 'tree', pos: { y: 5.05 } }),
|
||||
'nor a tree limb — that is a strop you throw');
|
||||
});
|
||||
|
||||
t.test('ladder: fascia re-rig is gated on being up it; post re-rig is not', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const L = fakeLadder({ player: p });
|
||||
const anchors = [{ id: 'h2', type: 'house', pos: { x: 0, y: 2.48, z: 0.9 } },
|
||||
{ id: 'p1', type: 'post', pos: { x: 0, y: 3.95, z: 0 } }];
|
||||
const corners = [{ anchorId: 'h2', broken: true }, { anchorId: 'p1', broken: true }];
|
||||
let repaired = [];
|
||||
const it = new Interact();
|
||||
wireYardActions(it, {
|
||||
ladder: L,
|
||||
world: { anchors },
|
||||
sailRig: { corners, repair: (i) => repaired.push(i), trim: () => {},
|
||||
cornerPos: () => ({ x: 0, y: 0.4, z: 0 }) },
|
||||
});
|
||||
p.carrying = 'spare';
|
||||
|
||||
// the POST corner: repairable from the ground, exactly as it was before the ladder existed
|
||||
p.pos.x = 0; p.pos.z = 0;
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(repaired.includes(1), 'post corner still re-rigs from the ground — the ladder changed nothing here');
|
||||
|
||||
// the FASCIA corner: same spare, standing right under it, refused
|
||||
repaired = []; p.carrying = 'spare'; p.pos.x = 0; p.pos.z = 0.9;
|
||||
it.latched = false;
|
||||
const near = it.nearest(p);
|
||||
assert(!near || near.id !== 'rerig_0', 'standing under the bracket is not enough');
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(!repaired.includes(0), 'fascia re-rig refused from the ground');
|
||||
|
||||
// plant the ladder and climb it → now it lands
|
||||
L._place('h2');
|
||||
p.climbTo(2.35); drive(p, 4);
|
||||
assertEq(p.state, 'atTop');
|
||||
p.carrying = 'spare';
|
||||
it.latched = false;
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(repaired.includes(0), 'up the ladder, the fascia re-rig lands');
|
||||
assertEq(p.carrying, null, 'and it ate the spare');
|
||||
});
|
||||
|
||||
t.test('ladder: the scripted loop — carry, plant, fetch spare, climb, repair, descend', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const L = fakeLadder({ player: p });
|
||||
const anchors = [{ id: 'h2', type: 'house', pos: { x: 0, y: 2.48, z: 0.9 } }];
|
||||
const corners = [{ anchorId: 'h2', broken: true }];
|
||||
let repaired = false;
|
||||
const it = new Interact();
|
||||
wireYardActions(it, { ladder: L, world: { anchors },
|
||||
sailRig: { corners, repair: () => { repaired = true; }, trim: () => {},
|
||||
cornerPos: () => ({ x: 0, y: 0.4, z: 0 }) } });
|
||||
|
||||
// hands-full: the ladder and the spare compete for the same pair of hands
|
||||
assertEq(p.pickUp('ladder'), true, 'pick the ladder up');
|
||||
assertEq(p.pickUp('spare'), false, 'you cannot also carry a spare — that is the two-trip cost');
|
||||
assertEq(p.drop(), 'ladder', 'put it down');
|
||||
|
||||
// trip 2: the spare, then up
|
||||
assertEq(p.pickUp('spare'), true);
|
||||
L._place('h2');
|
||||
p.climbTo(L.workY()); drive(p, 4);
|
||||
assertEq(p.state, 'atTop', 'up the ladder with the spare');
|
||||
it.latched = false;
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(repaired, 'fascia repaired at height');
|
||||
assertEq(p.carrying, null, 'spare consumed');
|
||||
|
||||
// and back down
|
||||
p.climbTo(0); drive(p, 4);
|
||||
assertEq(p.state, 'idle', 'down and free');
|
||||
assertEq(p.climbY, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- solids collision
|
||||
t.test('collision: solids stop you, and the pushout slides you along them', () => {
|
||||
// one box: the yard's north wall, x -8..8, z -16..-10, waist high
|
||||
@ -476,6 +650,28 @@ export default async function run(t) {
|
||||
assertEq(sim.state, 'knocked', 'and the abort did not overwrite the knocked state');
|
||||
});
|
||||
|
||||
t.test('interact: a hold survives the busy transition it causes', () => {
|
||||
// The bug this pins bit twice while building the ladder, and is invisible: canUse is re-checked
|
||||
// every frame to keep the hold alive, and starting the hold sets state='busy' — so any canUse
|
||||
// reading player.state goes false on frame one and cancels itself. Prompt looks dead, no error.
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
let fired = 0;
|
||||
it.register({ id: 'ok', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
|
||||
canUse: (p) => p.climbY < 0.02, onDone: () => { fired++; } }); // physical gate — fine
|
||||
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(fired, 1, 'a physically-gated action completes');
|
||||
|
||||
const sim2 = new PlayerSim();
|
||||
const it2 = new Interact();
|
||||
let fired2 = 0;
|
||||
it2.register({ id: 'trap', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
|
||||
canUse: (p) => p.state === 'idle', onDone: () => { fired2++; } }); // state gate — the trap
|
||||
fixedLoop(1, DT, (dt, tt) => it2.step(dt, tt, sim2, true));
|
||||
assertEq(fired2, 0,
|
||||
'a state-gated canUse cancels its own hold — documented on register(); gate on physical facts');
|
||||
});
|
||||
|
||||
t.test('interact: canUse() gates on the carrying flag', () => {
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
|
||||
@ -73,6 +73,10 @@ const ASSETS = [
|
||||
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
|
||||
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
|
||||
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
|
||||
{ name: 'garden_gnome_01_broken', h: [0.08, 0.20],
|
||||
nodes: ['stump', 'head', 'hat', 'shards'] },
|
||||
{ name: 'fence_panel_broken', h: [1.70, 1.90],
|
||||
nodes: ['palings', 'rails', 'debris_palings'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -234,6 +238,45 @@ export default async function run(t) {
|
||||
assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
|
||||
});
|
||||
|
||||
// Custom props are a contract, not decoration — and I have been telling other
|
||||
// lanes to read these since Sprint 1 without ever checking they survive the
|
||||
// export. glTF `extras` arrive as three's userData, but only if export_extras
|
||||
// held all the way through; if it silently dropped, Lane A's gnome scores $0
|
||||
// and Lane B's anchors all rate the same, both of which would look like a
|
||||
// gameplay decision rather than a missing field.
|
||||
t.test('glTF extras survive as userData — the props other lanes read', () => {
|
||||
const gnome = loaded.get('garden_gnome_01')?.scene.getObjectByName('garden_gnome_01');
|
||||
assert(gnome, 'gnome root node missing');
|
||||
assert(gnome.userData?.collateral_value === 25,
|
||||
`collateral_value lost (userData=${JSON.stringify(gnome.userData)}) — Lane A scores off this`);
|
||||
|
||||
const canopy = loaded.get('tree_gum_01')?.scene.getObjectByName('canopy');
|
||||
assert(typeof canopy?.userData?.sway_amp === 'number',
|
||||
'canopy.sway_amp lost — world.js per-tree sway tuning reads it');
|
||||
|
||||
const branch = loaded.get('tree_gum_01')?.scene.getObjectByName('branch_anchor_01');
|
||||
assert(typeof branch?.userData?.rating_hint === 'number',
|
||||
'branch_anchor_01.rating_hint lost — Lane B picks anchors on it');
|
||||
|
||||
const bin = loaded.get('wheelie_bin_01')?.scene.getObjectByName('wheelie_bin_01');
|
||||
assert(typeof bin?.userData?.mass_hint === 'number',
|
||||
'wheelie_bin mass_hint lost — Lane C throws it with this');
|
||||
});
|
||||
|
||||
// The wreckage has to drop into the intact asset's place, so both variants
|
||||
// stand on the same ground plane. If the broken one floats or sinks, Lane A's
|
||||
// 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']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
assert(Math.abs(box.min.y) < 0.03,
|
||||
`${n} rests at y=${box.min.y.toFixed(3)}, not on the ground`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||
// rather than reloading, so all three have to be present at once.
|
||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||||
|
||||
@ -280,44 +280,75 @@ export function weatherCases(storms) {
|
||||
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
|
||||
});
|
||||
|
||||
// ---- 9. vertical gust structure (SPRINT2 decision 3) ----
|
||||
// Cloth pressure goes with dot(wind, normal). A flat horizontal panel's normal
|
||||
// points at the sky, so in a perfectly horizontal wind that dot is ~0 and the
|
||||
// cheapest winning rig is "lie it flat and ignore the storm" — the opposite of
|
||||
// the game. Gust fronts are descending air, and descending air hits a flat
|
||||
// panel square on. Lane B owns the cloth-side assert; these are the wind side.
|
||||
test('gusts carry a downdraft, and still air does not', () => {
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
let peakDown = 0, betweenMax = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
const v = f.gustVertical(t);
|
||||
assert(v <= 1e-12, `vertical wind went UP (${v.toFixed(2)}) at t=${t.toFixed(2)} — downdraft only`);
|
||||
const live = f.gusts.some((g) => t > g.t0 && t < g.endAt);
|
||||
if (live) peakDown = Math.min(peakDown, v);
|
||||
else betweenMax = Math.max(betweenMax, Math.abs(v));
|
||||
}
|
||||
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
|
||||
assert(betweenMax === 0, `air is falling between gusts (${betweenMax}) — downdraft must be a gust feature`);
|
||||
assert(peakDown < -2, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
|
||||
});
|
||||
|
||||
test('downdraft tracks its own gust and its JSON fraction', () => {
|
||||
// ---- 9. vertical structure (SPRINT3 decision 8: fraction of TOTAL) ----
|
||||
// Cloth pressure goes with dot(wind, normal). A flat panel's normal points at
|
||||
// the sky, so in a purely horizontal wind that dot is ~0 and "lie it flat and
|
||||
// ignore the storm" wins — the opposite of the game. The downdraft is now a
|
||||
// fraction of the LOCAL total wind speed (was: gust power), so a flat roof is
|
||||
// pressed whenever it's windy, not only at gust peaks. Lane B owns the
|
||||
// cloth-side no-free-lunch assert; these are the wind side.
|
||||
test('downdraft is a fixed fraction of the local horizontal speed', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def);
|
||||
const frac = def.gusts.downdraft;
|
||||
for (const g of f.gusts) {
|
||||
assert(g.down >= frac * 0.6 - 1e-9 && g.down <= frac * 1.4 + 1e-9,
|
||||
`gust at t=${g.t0.toFixed(1)} has down=${g.down.toFixed(3)}, outside 0.6–1.4× of ${frac}`);
|
||||
// minGap >= GUST.TOTAL means gusts never overlap, so at hold it's exactly this gust
|
||||
const atHold = f.gustVertical(g.t0 + 3);
|
||||
assert(Math.abs(atHold - -(g.pow * g.down)) < 1e-9,
|
||||
`at gust hold vertical is ${atHold.toFixed(3)}, want ${(-g.pow * g.down).toFixed(3)}`);
|
||||
const frac = def.gusts.downdraftOfTotal;
|
||||
assert(Math.abs(f.downFrac - frac) < 1e-12, `field downFrac ${f.downFrac} != json ${frac}`);
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
let peakDown = 0;
|
||||
for (const p of PROBES) {
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
f.vecAt(p.x, p.z, t, out);
|
||||
const horiz = Math.hypot(out.x, out.z);
|
||||
assert(out.y <= 1e-9, `vertical went UP (${out.y.toFixed(3)}) at t=${t.toFixed(2)} — downdraft only`);
|
||||
// out.y must be exactly -frac * horizontal, everywhere, always
|
||||
assert(Math.abs(out.y + frac * horiz) < 1e-9,
|
||||
`downdraft ${out.y.toFixed(3)} != -${frac}×${horiz.toFixed(3)} at t=${t.toFixed(2)}`);
|
||||
peakDown = Math.min(peakDown, out.y);
|
||||
}
|
||||
}
|
||||
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
|
||||
// Held at downdraftOfTotal 0.15 → ~-4.9 m/s; target 0.45 → ~-14.7. Floor at
|
||||
// -3 so this proves "a real downdraft exists" across the whole transition
|
||||
// range without false-failing when the joint step bumps the value.
|
||||
assert(peakDown < -3, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
|
||||
});
|
||||
|
||||
test('downdraft rides the wind: present when windy, gone when calm', () => {
|
||||
// The point of fraction-of-total: it's not a gust-only feature any more. Some
|
||||
// sustained-wind moment between gusts must still carry a real downdraft, and a
|
||||
// hypothetically dead-calm field must carry none.
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
let sustainedDown = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
const inGust = f.gusts.some((g) => t > g.t0 && t < g.endAt);
|
||||
if (!inGust) sustainedDown = Math.min(sustainedDown, f.verticalAt(0, 0, t));
|
||||
}
|
||||
assert(sustainedDown < -2,
|
||||
`between gusts the downdraft peaks at only ${sustainedDown.toFixed(2)} — total-speed semantics should keep it pressing`);
|
||||
|
||||
// dead calm → no downdraft (guards against a constant offset sneaking in)
|
||||
const calm = createWindField({
|
||||
duration: 10, baseCurve: [[0, 0], [10, 0]], dirCurve: [[0, 0], [10, 0]],
|
||||
gusts: { minGap: 6, maxGap: 6, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.5 },
|
||||
});
|
||||
for (let t = 0; t <= 10; t += 0.1) {
|
||||
assert(Math.abs(calm.verticalAt(0, 0, t)) < 1e-9, `air is falling in a dead calm at t=${t.toFixed(1)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('downdraft 0 gives a perfectly horizontal wind', () => {
|
||||
test('downdraft follows the tree shadow (shelters from falling air too)', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
|
||||
const t = 30;
|
||||
const d = f.dirAt(t);
|
||||
const dx = Math.cos(d), dz = Math.sin(d);
|
||||
const leeDown = Math.abs(f.verticalAt(dx * 5, dz * 5, t)); // downwind of the tree
|
||||
const openDown = Math.abs(f.verticalAt(-dx * 5, -dz * 5, t)); // upwind, unsheltered
|
||||
assert(leeDown < openDown * 0.85, `lee downdraft ${leeDown.toFixed(2)} not sheltered vs open ${openDown.toFixed(2)}`);
|
||||
});
|
||||
|
||||
test('downdraftOfTotal 0 gives a perfectly horizontal wind', () => {
|
||||
const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
def.gusts.downdraft = 0;
|
||||
def.gusts.downdraftOfTotal = 0;
|
||||
const f = createWindField(def);
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
for (let t = 0; t <= f.duration; t += 0.05) {
|
||||
@ -327,15 +358,16 @@ export function weatherCases(storms) {
|
||||
});
|
||||
|
||||
test('downdraft does not re-time the storm', () => {
|
||||
// The vertical draws from its own RNG stream precisely so that adding or
|
||||
// tuning it can't shift gust times or powers. Lane A hand-drove storm_02 and
|
||||
// watched the carabiner blow at t=45.4 and p2 cascade at t=56; a downdraft
|
||||
// tweak silently moving those would be a nasty way to lose an afternoon.
|
||||
// The vertical carries NO rng draws of its own now (it's a pure function of
|
||||
// local speed), so tuning it cannot possibly shift gust times or powers. Lane
|
||||
// A hand-drove storm_02 and watched the carabiner blow at t=45.4 and p2
|
||||
// cascade at t=56; a downdraft tweak silently moving those would be a nasty
|
||||
// way to lose an afternoon. Determinism is now structural, but still asserted.
|
||||
const base = storms.storm_02_wildnight;
|
||||
const a = createWindField(base);
|
||||
for (const dd of [0, 0.1, 0.25, 0.5, 1]) {
|
||||
for (const dd of [0, 0.1, 0.22, 0.5, 1]) {
|
||||
const d = JSON.parse(JSON.stringify(base));
|
||||
d.gusts.downdraft = dd;
|
||||
d.gusts.downdraftOfTotal = dd;
|
||||
const b = createWindField(d);
|
||||
assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
|
||||
a.gusts.forEach((g, i) => {
|
||||
@ -343,32 +375,34 @@ export function weatherCases(storms) {
|
||||
`downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`);
|
||||
assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`);
|
||||
});
|
||||
// and the HORIZONTAL wind must be byte-identical regardless of downdraft
|
||||
assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), `downdraft ${dd} changed the horizontal wind`);
|
||||
}
|
||||
});
|
||||
|
||||
test('at a gust peak the downdraft is a real fraction of the horizontal', () => {
|
||||
test('speedAt stays horizontal — a wind meter does not read falling air', () => {
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
let bestRatio = 0, atT = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
f.vecAt(0, 0, t, out);
|
||||
const horiz = Math.hypot(out.x, out.z);
|
||||
if (horiz < 1) continue;
|
||||
const r = Math.abs(out.y) / horiz;
|
||||
if (r > bestRatio) { bestRatio = r; atT = t; }
|
||||
for (const p of PROBES) {
|
||||
for (const t of [12, 40, 60, 75.3]) {
|
||||
f.vecAt(p.x, p.z, t, out);
|
||||
assert(Math.abs(f.speedAt(p.x, p.z, t) - Math.hypot(out.x, out.z)) < 1e-9,
|
||||
`speedAt != horizontal magnitude of sample at t=${t}`);
|
||||
}
|
||||
}
|
||||
metrics['storm_02.peakVerticalRatio'] = +bestRatio.toFixed(3);
|
||||
assert(bestRatio > 0.12,
|
||||
`strongest downdraft is only ${(bestRatio * 100).toFixed(0)}% of the horizontal wind (t=${atT.toFixed(1)}) — a flat sail still shrugs`);
|
||||
});
|
||||
|
||||
test('validator rejects a bad downdraft', () => {
|
||||
test('validator rejects a bad downdraft and the renamed field', () => {
|
||||
for (const dd of [-0.1, 1.5, NaN, 'lots']) {
|
||||
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
d.gusts.downdraft = dd;
|
||||
const { ok } = validateStorm(d, 'broken');
|
||||
assert(!ok, `validator ACCEPTED downdraft = ${dd}`);
|
||||
d.gusts.downdraftOfTotal = dd;
|
||||
assert(!validateStorm(d, 'broken').ok, `validator ACCEPTED downdraftOfTotal = ${dd}`);
|
||||
}
|
||||
// the old gust-only field must be rejected, not silently re-meant
|
||||
const legacy = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
delete legacy.gusts.downdraftOfTotal;
|
||||
legacy.gusts.downdraft = 0.3;
|
||||
assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field');
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
|
||||
@ -130,29 +130,19 @@ function sampleAngleCurve(curve, t) {
|
||||
|
||||
// ---------- gust timeline ----------
|
||||
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
|
||||
export const DEFAULT_DOWNDRAFT = 0.25;
|
||||
export const DEFAULT_DOWNDRAFT = 0.22;
|
||||
|
||||
export function buildGustTimeline(def, seed) {
|
||||
const g = def.gusts || {};
|
||||
const rng = mulberry32(seed >>> 0);
|
||||
// Vertical draws from its OWN stream, deliberately. Pulling it from `rng`
|
||||
// would shift every subsequent (t0, pow) and silently re-time storms that are
|
||||
// already tuned and hand-verified — A drove storm_02 and watched the carabiner
|
||||
// blow at t=45.4 and cascade at t=56, one second after the change. Adding a
|
||||
// downdraft shouldn't move that.
|
||||
const rngV = mulberry32((seed ^ 0x0d0117) >>> 0);
|
||||
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
|
||||
const downFrac = g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
const out = [];
|
||||
let t = g.firstAt ?? 3;
|
||||
// hard cap: a malformed gap can't spin us forever
|
||||
while (t < def.duration && out.length < 512) {
|
||||
const p = def.duration > 0 ? t / def.duration : 0;
|
||||
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
|
||||
// Not every gust slams down the same: some roll through nearly flat, some
|
||||
// are a proper little downburst. 0.6–1.4× the storm's fraction.
|
||||
const down = downFrac * (0.6 + rngV() * 0.8);
|
||||
out.push({ t0: t, pow, down, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
|
||||
out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
|
||||
t += minGap + rng() * Math.max(0, maxGap - minGap);
|
||||
}
|
||||
return out;
|
||||
@ -174,6 +164,11 @@ export function createWindField(def, opts = {}) {
|
||||
const wander = def.dirWander || {};
|
||||
const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13;
|
||||
const nSeed = (seed ^ 0x9e3779b9) | 0;
|
||||
// SPRINT3 decision 8: the downdraft is a fraction of TOTAL wind speed, not of
|
||||
// gust power. `downdraftOfTotal` is the field name; `downdraft` is read as a
|
||||
// legacy alias so an un-migrated storm doesn't silently lose its vertical.
|
||||
const gd = def.gusts || {};
|
||||
const downFrac = gd.downdraftOfTotal ?? gd.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
|
||||
let shelters = [];
|
||||
|
||||
@ -202,24 +197,37 @@ export function createWindField(def, opts = {}) {
|
||||
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
|
||||
}
|
||||
|
||||
/** Local horizontal wind speed (m/s) — base+gusts, spatial noise, tree shadow.
|
||||
* The one place the local-speed maths lives; speedAt/vecAt/verticalAt share it. */
|
||||
function localHoriz(x, z, t) {
|
||||
const uni = uniformSpeed(t);
|
||||
const d = dirAt(t);
|
||||
const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d));
|
||||
return s > 0 ? s : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical wind, m/s. NEGATIVE = downward. Zero between gusts.
|
||||
* Vertical wind, m/s. NEGATIVE = downward. A fraction of the LOCAL horizontal
|
||||
* speed at this point and time.
|
||||
*
|
||||
* A gust front is descending air, not just faster air. Without this the field
|
||||
* is perfectly horizontal, and a horizontal sail is a free lunch: cloth
|
||||
* pressure goes with dot(wind, normal), a flat panel's normal points at the
|
||||
* sky, and the dot product is ~0 no matter how hard it blows. So the cheapest
|
||||
* winning rig was "lie it flat and ignore the storm", which is the opposite of
|
||||
* the game (SPRINT2 decision 3). A downdraft hits a flat panel square on.
|
||||
* Why a horizontal sail must pay: cloth pressure goes with dot(wind, normal),
|
||||
* a flat panel's normal points at the sky, so in a purely horizontal wind the
|
||||
* dot is ~0 and "lie it flat and ignore the storm" wins — the opposite of the
|
||||
* game. A descending component hits a flat panel square on.
|
||||
*
|
||||
* SPRINT3 decision 8 — fraction of TOTAL, not of gust power. Under gust-only
|
||||
* semantics the downdraft peaked exactly at the gust peak, where the horizontal
|
||||
* ALSO peaked, so a flat sail could never reach 60% of a pitched one's load
|
||||
* (B measured 34%) without a downdraft so violent it also killed the twisted
|
||||
* rig the §7 gate needs to survive. The two gates pincered. Riding total speed
|
||||
* instead spreads the load across the whole storm: a flat roof is pressed
|
||||
* steadily (peak total 32.6 m/s dwarfs peak gust power 12.6), so the ratio
|
||||
* clears 60% at a gentle fraction, without a spike at the gust peak. It follows
|
||||
* the LOCAL speed, so a tree's wind shadow shelters from falling air too.
|
||||
*/
|
||||
function gustVertical(t) {
|
||||
let v = 0;
|
||||
for (let i = 0; i < gusts.length; i++) {
|
||||
const g = gusts[i];
|
||||
if (t <= g.t0) break; // sorted — nothing later is live
|
||||
if (t < g.endAt) v -= gustEnvelope(t - g.t0, g.pow) * g.down;
|
||||
}
|
||||
return v;
|
||||
function verticalAt(x, z, t) {
|
||||
if (downFrac <= 0) return 0;
|
||||
return -downFrac * localHoriz(x, z, t);
|
||||
}
|
||||
|
||||
// ---- noise drift ----
|
||||
@ -318,27 +326,22 @@ export function createWindField(def, opts = {}) {
|
||||
* The cheap path — no allocation.
|
||||
*/
|
||||
speedAt(x, z, t) {
|
||||
const uni = uniformSpeed(t);
|
||||
const d = dirAt(t);
|
||||
const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d));
|
||||
return s > 0 ? s : 0;
|
||||
return localHoriz(x, z, t);
|
||||
},
|
||||
|
||||
dirAt,
|
||||
uniformSpeed,
|
||||
gustOnly,
|
||||
gustVertical,
|
||||
verticalAt,
|
||||
get downFrac() { return downFrac; },
|
||||
|
||||
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
|
||||
vecAt(x, z, t, out) {
|
||||
const uni = uniformSpeed(t);
|
||||
const d = dirAt(t);
|
||||
const dirX = Math.cos(d), dirZ = Math.sin(d);
|
||||
const m = spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
|
||||
let s = uni * m;
|
||||
if (s < 0) s = 0;
|
||||
const s = localHoriz(x, z, t);
|
||||
out.x = dirX * s;
|
||||
out.y = gustVertical(t) * m; // gust fronts descend — see gustVertical()
|
||||
out.y = -downFrac * s; // the downdraft rides the local speed — see verticalAt()
|
||||
out.z = dirZ * s;
|
||||
return out;
|
||||
},
|
||||
@ -415,9 +418,14 @@ export function validateStorm(def, name = 'storm') {
|
||||
// Overlapping gusts stack, and a stacked telegraph is unreadable to the player.
|
||||
if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`);
|
||||
if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0');
|
||||
const dd = g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
// `downdraft` (gust-only, pre-SPRINT3) is still accepted but flagged, so an
|
||||
// un-migrated storm loads visibly wrong rather than silently at a third power.
|
||||
if (g.downdraft != null && g.downdraftOfTotal == null) {
|
||||
bad('gusts.downdraft is the old gust-only field — rename to downdraftOfTotal (SPRINT3 decision 8); it now means a fraction of TOTAL wind speed');
|
||||
}
|
||||
const dd = g.downdraftOfTotal ?? g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
if (!Number.isFinite(dd) || dd < 0 || dd > 1) {
|
||||
bad(`gusts.downdraft must be 0..1 — the fraction of gust power that blows DOWN — got ${dd}`);
|
||||
bad(`gusts.downdraftOfTotal must be 0..1 — the fraction of TOTAL wind speed that blows DOWN — got ${dd}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -40,6 +40,11 @@ const GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 };
|
||||
const SHED = { x: 11.8, z: 6.2, rotY: -Math.PI / 2 };
|
||||
const SHED_TABLE = { x: 9, z: 6, rotY: -Math.PI / 2 };
|
||||
|
||||
// Lane E's house_yardside GLB is a 9.2 x 2.9 x 1.05 m façade whose fascia
|
||||
// anchors sit at local z = +0.55, so placing it here lands them on z = -9.95 —
|
||||
// the same line the graybox taught everyone to expect.
|
||||
const HOUSE = { x: 0, z: -10.5 };
|
||||
|
||||
// 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;
|
||||
@ -89,6 +94,8 @@ export function createWorld(scene, opts = {}) {
|
||||
const anchors = [];
|
||||
/** @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() };
|
||||
|
||||
// --- sky & light -------------------------------------------------------
|
||||
// Calm-day only. Lane C's skyfx.js takes over the sky and this becomes the
|
||||
@ -174,6 +181,7 @@ export function createWorld(scene, opts = {}) {
|
||||
|
||||
root.add(house);
|
||||
solids.push(wall, roof);
|
||||
graybox.house = house;
|
||||
|
||||
for (const [i, x] of [-5, 0, 5].entries()) {
|
||||
anchors.push(makeStaticAnchor(`h${i + 1}`, 'house', new THREE.Vector3(x, 2.6, -9.9)));
|
||||
@ -222,6 +230,7 @@ export function createWorld(scene, opts = {}) {
|
||||
canopies.push({ group: canopy, phase: spec.phase, base: canopy.rotation.clone() });
|
||||
|
||||
root.add(tree);
|
||||
graybox.trees.set(spec.id, tree);
|
||||
|
||||
// The anchor is at a branch fork, not the canopy centre.
|
||||
anchors.push(makeSwayAnchor(
|
||||
@ -236,9 +245,24 @@ export function createWorld(scene, opts = {}) {
|
||||
// Raked away from the yard centre, because that is the correct practice and
|
||||
// the shape should teach it before any text does (DESIGN.md: "rake the post
|
||||
// away from the load").
|
||||
// SPRINT3 decision 2: posts pulled in off the fence and a third added.
|
||||
//
|
||||
// The old pair sat at (-6, 7) and (5, 7.5), which put every rigging option in
|
||||
// the 70–192 m² range Lane B flagged — a sail that big pre-tensions itself
|
||||
// into a cascade at t=0.4 s before the wind has done anything, so the yard was
|
||||
// teaching the wrong lesson. Pulled in, plus p3, the same yard now offers 31
|
||||
// quads in the 18–45 m² band (8 of which shade a quarter of the bed or more).
|
||||
//
|
||||
// Worth knowing before anyone "fixes" it: the smallest quad that covers the
|
||||
// bed COMPLETELY is 59 m², and that is not a bug to tune away. The bed sits
|
||||
// 10 m off the house, so any house-to-post sail is ~16 m long, and covering a
|
||||
// 6 m bed with it costs you a sail the storm will take. Full shade is meant to
|
||||
// be the expensive answer; the small quads buy survival and pay in patchy
|
||||
// shade. That IS the design (DESIGN.md, "big flat low vs small twisted steep").
|
||||
const postSpecs = [
|
||||
{ id: 'p1', x: -6, z: 7, h: 4.0 },
|
||||
{ id: 'p2', x: 5, z: 7.5, h: 4.0 },
|
||||
{ id: 'p1', x: -4.5, z: 5.5, h: 4.0 },
|
||||
{ id: 'p2', x: 4.0, z: 6.0, h: 4.0 },
|
||||
{ id: 'p3', x: 0, z: 7.0, h: 4.0 },
|
||||
];
|
||||
const RAKE = (8 * Math.PI) / 180;
|
||||
for (const spec of postSpecs) {
|
||||
@ -366,6 +390,35 @@ export function createWorld(scene, opts = {}) {
|
||||
const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
|
||||
const loader = new GLTFLoader();
|
||||
|
||||
/** Take a graybox stand-in out of the scene AND out of `solids`. */
|
||||
const retire = (obj) => {
|
||||
if (!obj) return;
|
||||
obj.traverse((o) => {
|
||||
const i = solids.indexOf(o);
|
||||
if (i >= 0) solids.splice(i, 1);
|
||||
o.geometry?.dispose();
|
||||
});
|
||||
const i = solids.indexOf(obj);
|
||||
if (i >= 0) solids.splice(i, 1);
|
||||
obj.parent?.remove(obj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Move an existing anchor onto the position Lane E baked, and take their
|
||||
* rating_hint with it. Mutates `pos` in place rather than reassigning it:
|
||||
* `interact.register` and Lane B's corners capture these vectors by
|
||||
* reference, and a reassign would leave them holding a stale one.
|
||||
*/
|
||||
const adoptAnchor = (glb, nodeName, anchorId) => {
|
||||
const node = glb.getObjectByName(nodeName);
|
||||
const anchor = anchors.find((a) => a.id === anchorId);
|
||||
if (!node || !anchor) return false;
|
||||
anchor.pos.setFromMatrixPosition(node.matrixWorld);
|
||||
anchor.ratingHint = node.userData?.rating_hint ?? 1;
|
||||
anchor.collateral = node.userData?.collateral ?? null;
|
||||
return true;
|
||||
};
|
||||
|
||||
const load = async (name) => {
|
||||
try {
|
||||
const gltf = await loader.loadAsync(new URL(`../models/${name}.glb`, import.meta.url).href);
|
||||
@ -379,7 +432,68 @@ export function createWorld(scene, opts = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const [shed, table] = await Promise.all([load('shed_01_v1'), load('shed_table_v1')]);
|
||||
const [shed, table, houseGlb, tree1, tree2] = 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'),
|
||||
]);
|
||||
|
||||
// --- 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
|
||||
// 4 m, which is a real part of why the yard now offers small quads at all.
|
||||
// Every fascia anchor carries rating_hint 0.35 — E encoded DESIGN.md's
|
||||
// "the fascia board is a lie" straight into the asset, and `collateral:
|
||||
// "gutter"` says what it takes with it when it goes.
|
||||
if (houseGlb) {
|
||||
retire(graybox.house);
|
||||
houseGlb.name = 'house_yardside';
|
||||
houseGlb.position.set(HOUSE.x, heightAt(HOUSE.x, HOUSE.z), HOUSE.z);
|
||||
root.add(houseGlb);
|
||||
solids.push(houseGlb);
|
||||
houseGlb.updateWorldMatrix(true, true);
|
||||
for (const [i, id] of ['h1', 'h2', 'h3'].entries()) {
|
||||
adoptAnchor(houseGlb, `fascia_anchor_0${i + 1}`, id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- trees -----------------------------------------------------------
|
||||
// Each tree ships 2-3 branch anchors with descending rating_hint (1.0 at
|
||||
// the fork, 0.76 out where the limb is thin) — the intel DESIGN.md wants
|
||||
// inspection to buy. branch_anchor_01 keeps the original t1/t2 id so
|
||||
// nothing that already references them breaks; the rest are added.
|
||||
for (const [glb, spec] of [[tree1, treeSpecs[0]], [tree2, treeSpecs[1]]]) {
|
||||
if (!glb) continue;
|
||||
const old = graybox.trees.get(spec.id);
|
||||
retire(old);
|
||||
// The graybox canopy was what world.update() swayed — hand that job over.
|
||||
const idx = canopies.findIndex((c) => old && old.getObjectByName('canopy') === c.group);
|
||||
if (idx >= 0) canopies.splice(idx, 1);
|
||||
|
||||
glb.name = `tree_${spec.id}`;
|
||||
glb.position.set(spec.x, heightAt(spec.x, spec.z), spec.z);
|
||||
root.add(glb);
|
||||
const trunk = glb.getObjectByName('trunk');
|
||||
if (trunk) solids.push(trunk);
|
||||
const canopy = glb.getObjectByName('canopy_01') || glb.getObjectByName('canopy');
|
||||
if (canopy?.parent) {
|
||||
canopies.push({ group: canopy.parent, phase: spec.phase, base: canopy.parent.rotation.clone() });
|
||||
}
|
||||
|
||||
glb.updateWorldMatrix(true, true);
|
||||
const suffix = ['', 'b', 'c'];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const node = glb.getObjectByName(`branch_anchor_0${i}`);
|
||||
if (!node) continue;
|
||||
const id = spec.id + suffix[i - 1];
|
||||
if (i === 1) adoptAnchor(glb, `branch_anchor_01`, id);
|
||||
else {
|
||||
const p = new THREE.Vector3().setFromMatrixPosition(node.matrixWorld);
|
||||
const a = makeSwayAnchor(id, p, spec.phase, wind);
|
||||
a.ratingHint = node.userData?.rating_hint ?? 1;
|
||||
anchors.push(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shed) {
|
||||
shed.name = 'shed_01';
|
||||
|
||||
BIN
web/world/models/fence_panel_broken_v1.glb
Normal file
BIN
web/world/models/fence_panel_broken_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/garden_gnome_01_broken_v1.glb
Normal file
BIN
web/world/models/garden_gnome_01_broken_v1.glb
Normal file
Binary file not shown.
@ -1,263 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SHADES — Lane C — weather bench</title>
|
||||
<style>
|
||||
:root { --ink:#d8d8e0; --gold:#ffd23d; --neon:#3dff8b; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; overflow:hidden; background:#000;
|
||||
font:13px/1.45 "Courier New", ui-monospace, monospace; color:var(--ink); }
|
||||
canvas { display:block; }
|
||||
#hud { position:fixed; top:10px; left:10px; background:rgba(6,6,12,.75); padding:8px 12px;
|
||||
border:1px solid #26263a; z-index:3; min-width:250px; }
|
||||
#hud b { color:var(--gold); }
|
||||
#hud .warn { color:#ff6; font-weight:bold; }
|
||||
#hud .bad { color:#f66; font-weight:bold; }
|
||||
#ctl { position:fixed; bottom:10px; left:10px; background:rgba(6,6,12,.8); padding:8px 12px;
|
||||
border:1px solid #26263a; z-index:3; }
|
||||
#ctl button { background:#1d1d2b; color:var(--ink); border:1px solid #666; font:inherit;
|
||||
padding:4px 9px; cursor:pointer; }
|
||||
#ctl button:hover { border-color:var(--neon); color:var(--neon); }
|
||||
#ctl input[type=range] { width:220px; vertical-align:middle; }
|
||||
#note { position:fixed; top:10px; right:10px; background:rgba(6,6,12,.75); padding:8px 12px;
|
||||
border:1px solid #26263a; z-index:3; max-width:280px; color:#8a8a99; }
|
||||
.bar { display:inline-block; width:90px; height:7px; border:1px solid #555; vertical-align:middle; }
|
||||
.bar i { display:block; height:100%; background:var(--neon); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<div id="hud"></div>
|
||||
<div id="note">
|
||||
<b>Lane C bench.</b> Graybox stand-in for Lane A's yard — this exists to drive
|
||||
weather.js / skyfx.js / debris.js before M0 lands. The sail here is a MOCK
|
||||
(Lane B owns the real one); it's a bare node grid so debris impulse is visible.
|
||||
<br><br>drag = orbit · click = start audio
|
||||
</div>
|
||||
<div id="ctl"></div>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from './vendor/three.module.js';
|
||||
import { loadStorm, createWind } from './js/weather.js';
|
||||
import { createSkyFx } from './js/skyfx.js';
|
||||
import { createDebris } from './js/debris.js';
|
||||
|
||||
const canvas = document.getElementById('c');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(2, devicePixelRatio));
|
||||
renderer.shadowMap.enabled = true;
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x9fc4e8);
|
||||
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 500);
|
||||
|
||||
// --- graybox yard: 30×20 m, origin centre (stands in for Lane A's world.js) ---
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(30, 20),
|
||||
new THREE.MeshStandardMaterial({ color: 0x4a7c3f, roughness: 1 }),
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.receiveShadow = true;
|
||||
scene.add(ground);
|
||||
|
||||
// Lane A's landed yard (THREADS): t1 (-9,2), t2 (8,-2), house edge at z=-9.9
|
||||
const TREES = [{ x: -9, z: 2 }, { x: 8, z: -2 }];
|
||||
for (const tr of TREES) {
|
||||
const trunk = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.2, 0.28, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x5a3d24 }),
|
||||
);
|
||||
trunk.position.set(tr.x, 2, tr.z);
|
||||
trunk.castShadow = true;
|
||||
scene.add(trunk);
|
||||
const canopy = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(3, 12, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x285f23 }),
|
||||
);
|
||||
canopy.position.set(tr.x, 5, tr.z);
|
||||
canopy.castShadow = true;
|
||||
scene.add(canopy);
|
||||
}
|
||||
// house edge along north (-Z), for scale
|
||||
const house = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(30, 3.2, 1),
|
||||
new THREE.MeshStandardMaterial({ color: 0x8a8f96 }),
|
||||
);
|
||||
house.position.set(0, 1.6, -10.4);
|
||||
scene.add(house);
|
||||
// the thing you're protecting — Lane A's gardenBed rect
|
||||
const bed = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(6, 0.25, 4),
|
||||
new THREE.MeshStandardMaterial({ color: 0x6b4a2f }),
|
||||
);
|
||||
bed.position.set(1, 0.12, 2);
|
||||
scene.add(bed);
|
||||
// 1.7 m reference person
|
||||
const ref = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(0.25, 1.2, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffd27a }),
|
||||
);
|
||||
ref.position.set(2, 0.85, 2);
|
||||
ref.castShadow = true;
|
||||
scene.add(ref);
|
||||
const player = { pos: ref.position, carrying: null, busy: false };
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xfff4e0, 2.2);
|
||||
sun.position.set(-12, 18, 6);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(1024, 1024);
|
||||
scene.add(sun);
|
||||
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 0.9);
|
||||
scene.add(hemi);
|
||||
|
||||
// --- MOCK sail (Lane B owns the real cloth) — a bare node grid so we can see
|
||||
// debris shove it and drive the creak/flog audio off corner loads.
|
||||
const N = 9;
|
||||
const nodes = [];
|
||||
for (let v = 0; v < N; v++) {
|
||||
for (let u = 0; u < N; u++) {
|
||||
nodes.push({ x: -4 + (u / (N - 1)) * 8, y: 3.2, z: -3 + (v / (N - 1)) * 6 });
|
||||
}
|
||||
}
|
||||
const sailGeo = new THREE.BufferGeometry();
|
||||
sailGeo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(nodes.length * 3), 3));
|
||||
const sailPts = new THREE.Points(sailGeo, new THREE.PointsMaterial({ color: 0xe8c46a, size: 0.14 }));
|
||||
scene.add(sailPts);
|
||||
const mockSail = {
|
||||
nodes,
|
||||
corners: [
|
||||
{ anchorId: 'h1', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
|
||||
{ anchorId: 'h3', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
|
||||
{ anchorId: 'p1', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
|
||||
{ anchorId: 'p2', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
|
||||
],
|
||||
};
|
||||
|
||||
// --- weather ---
|
||||
const params = new URLSearchParams(location.search);
|
||||
const stormName = params.get('storm') || 'storm_02_wildnight';
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
wind.setShelters(TREES.map((t) => ({ x: t.x, z: t.z, radius: 3, strength: 0.45, length: 14 })));
|
||||
|
||||
const ticker = [];
|
||||
const sky = createSkyFx({ scene, camera, wind, sun, hemi, onEvent: (s) => ticker.unshift(s) });
|
||||
const debris = createDebris({
|
||||
wind, scene, player,
|
||||
onEvent: (s) => ticker.unshift(s),
|
||||
onHitPlayer: (p, impact) => ticker.unshift(`KNOCKED DOWN by ${p.model} (${impact.toFixed(0)})`),
|
||||
});
|
||||
addEventListener('pointerdown', () => sky.unlockAudio(), { once: true });
|
||||
|
||||
// --- controls ---
|
||||
let t = 0, playing = true, rate = 1;
|
||||
const ctl = document.getElementById('ctl');
|
||||
ctl.innerHTML = `
|
||||
<button id="play">pause</button>
|
||||
<button id="r1">1×</button><button id="r4">4×</button><button id="r0">0.25×</button>
|
||||
<button id="reset">reset</button>
|
||||
<button id="break">break a corner</button>
|
||||
<button id="crate">throw a crate</button>
|
||||
<input id="scrub" type="range" min="0" max="${def.duration}" step="0.1" value="0">
|
||||
`;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
$('play').onclick = () => { playing = !playing; $('play').textContent = playing ? 'pause' : 'play'; };
|
||||
$('r1').onclick = () => { rate = 1; };
|
||||
$('r4').onclick = () => { rate = 4; };
|
||||
$('r0').onclick = () => { rate = 0.25; };
|
||||
$('reset').onclick = () => { t = 0; debris.clear(); ticker.length = 0; mockSail.corners.forEach((c) => { c.broken = false; }); };
|
||||
$('break').onclick = () => { const c = mockSail.corners.find((x) => !x.broken); if (c) { c.broken = true; ticker.unshift(`${c.hw.name} BLOWS at ${c.anchorId.toUpperCase()}!`); } };
|
||||
$('crate').onclick = () => debris.spawn({ model: 'BlueCrate_v2', lateral: (Math.random() * 6 - 3), text: 'crate!' }, t);
|
||||
$('scrub').oninput = (e) => { t = parseFloat(e.target.value); debris.clear(); };
|
||||
|
||||
let yaw = 0.7, pitch = 0.28, dist = 26, dragging = false, lx = 0, ly = 0;
|
||||
addEventListener('pointerdown', (e) => { dragging = true; lx = e.clientX; ly = e.clientY; });
|
||||
addEventListener('pointerup', () => { dragging = false; });
|
||||
addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return;
|
||||
yaw -= (e.clientX - lx) * 0.005; pitch = Math.min(1.3, Math.max(0.05, pitch + (e.clientY - ly) * 0.004));
|
||||
lx = e.clientX; ly = e.clientY;
|
||||
});
|
||||
addEventListener('wheel', (e) => { dist = Math.min(60, Math.max(8, dist + e.deltaY * 0.02)); });
|
||||
|
||||
function resize() {
|
||||
const w = innerWidth, h = innerHeight;
|
||||
renderer.setSize(w, h);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
addEventListener('resize', resize); resize();
|
||||
|
||||
// --- loop: fixed-dt sim, rAF only drives the clock (PLAN3D §0) ---
|
||||
const DT = 1 / 60;
|
||||
let acc = 0, last = performance.now();
|
||||
const hud = document.getElementById('hud');
|
||||
const probe = new THREE.Vector3();
|
||||
const w = new THREE.Vector3();
|
||||
const posAttr = sailGeo.getAttribute('position');
|
||||
|
||||
function frame(now) {
|
||||
const real = Math.min(0.1, (now - last) / 1000);
|
||||
last = now;
|
||||
if (playing) acc += real * rate;
|
||||
|
||||
while (acc >= DT) {
|
||||
acc -= DT;
|
||||
t += DT;
|
||||
if (t > def.duration) t = 0;
|
||||
|
||||
// mock cloth: nodes just bob with local wind so debris has something to hit
|
||||
for (const n of nodes) {
|
||||
probe.set(n.x, n.y, n.z);
|
||||
wind.sample(probe, t, w);
|
||||
const sp = Math.hypot(w.x, w.z);
|
||||
n.y += ((3.2 + Math.sin(t * 3 + n.x) * sp * 0.02) - n.y) * 0.08;
|
||||
}
|
||||
// mock loads so the creak layer has something to track
|
||||
probe.set(0, 3.2, 0);
|
||||
const sp = wind.speedAt(probe, t);
|
||||
mockSail.corners.forEach((c, i) => {
|
||||
c.load = c.broken ? 0 : sp * sp * 0.021 * (0.7 + i * 0.16);
|
||||
});
|
||||
|
||||
debris.step(DT, t, { player, sail: mockSail });
|
||||
sky.step(DT, t, { sail: mockSail });
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) posAttr.setXYZ(i, nodes[i].x, nodes[i].y, nodes[i].z);
|
||||
posAttr.needsUpdate = true;
|
||||
|
||||
camera.position.set(
|
||||
Math.sin(yaw) * Math.cos(pitch) * dist,
|
||||
Math.sin(pitch) * dist + 1.5,
|
||||
Math.cos(yaw) * Math.cos(pitch) * dist,
|
||||
);
|
||||
camera.lookAt(0, 2, 0);
|
||||
|
||||
$('scrub').value = t.toFixed(1);
|
||||
probe.set(0, 1.7, 0);
|
||||
wind.sample(probe, t, w);
|
||||
const speed = Math.hypot(w.x, w.z);
|
||||
const tg = wind.gustTelegraph(t);
|
||||
const worst = Math.max(...mockSail.corners.map((c) => (c.broken ? 0 : c.load / c.hw.rating)));
|
||||
hud.innerHTML = `
|
||||
<div><b>${def.name}</b> — ${stormName}</div>
|
||||
<div>t <b>${t.toFixed(1)}</b> / ${def.duration}s (${rate}×)</div>
|
||||
<div>wind <b>${speed.toFixed(1)}</b> m/s (${(speed * 3.6).toFixed(0)} km/h)</div>
|
||||
<div>dir ${(wind.dirAt(t)).toFixed(2)} rad</div>
|
||||
<div>rain <span class="bar"><i style="width:${wind.rainAt(t) * 100}%"></i></span></div>
|
||||
<div>worst <span class="bar"><i style="width:${Math.min(100, worst * 100)}%;background:${worst > 0.8 ? '#f66' : '#3dff8b'}"></i></span></div>
|
||||
<div>debris ${debris.pieces.length} audio ${sky.audio.ready ? sky.audio.state : '(click)'}</div>
|
||||
<div>flash ${sky.flash.toFixed(2)}</div>
|
||||
${tg ? `<div class="warn">GUST INBOUND ${tg.eta.toFixed(1)}s pow ${tg.power.toFixed(0)}</div>` : '<div> </div>'}
|
||||
${ticker.slice(0, 3).map((s) => `<div class="bad">${s}</div>`).join('')}
|
||||
`;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
window.__bench = { wind, sky, debris, mockSail, def, get t() { return t; } };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user