Compare commits
23 Commits
302972cc6e
...
0f5f4d8368
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f5f4d8368 | ||
|
|
d3e849fcc3 | ||
|
|
7cd25a5d2a | ||
|
|
de86aa1662 | ||
|
|
323352fe50 | ||
|
|
7f3ef69685 | ||
|
|
0dba2d8891 | ||
|
|
6083151af6 | ||
|
|
76421a7f86 | ||
|
|
41ad12dede | ||
|
|
31d9946a04 | ||
|
|
e7639c4264 | ||
|
|
8791eccc08 | ||
|
|
6c11368202 | ||
|
|
703dbc499f | ||
|
|
18099c8e6f | ||
|
|
c8a9128c17 | ||
|
|
06ec4cbea2 | ||
|
|
6382e604b8 | ||
|
|
31a887bf75 | ||
|
|
11f27c493c | ||
|
|
383471d0f5 | ||
|
|
84f647a90c |
@ -4,14 +4,8 @@
|
||||
{
|
||||
"name": "shades3d",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": ["server.py"],
|
||||
"port": 8801
|
||||
},
|
||||
{
|
||||
"name": "shades-proto",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": ["-m", "http.server", "8642", "--directory", "prototype"],
|
||||
"port": 8642
|
||||
"runtimeArgs": ["server.py", "--port", "8811"],
|
||||
"port": 8811
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -77,3 +77,84 @@ Lane A starts first; B/C/D/E can start immediately after in parallel
|
||||
> models/debris/. Verify every export by rendering a contact sheet against the
|
||||
> 1.7 m ref capsule (the 3D=models/_thumbnails pattern). Commit script AND
|
||||
> GLBs. Log in THREADS.md.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# SPRINT 2 prompts (assembly — fire A/B/C/E together, D at gate 1)
|
||||
|
||||
Same rules: own clone (`~/Documents/shades-lane<X>` on m3ultra), branch `lane/<x>`,
|
||||
rebase onto latest main FIRST (it moved: all lanes merged + importmap + path
|
||||
fixes + M3 clip pack). Read THREADS.md from the [I] integrator entries down,
|
||||
then SPRINT2.md in full — the six decisions at the top are final, stop
|
||||
re-deciding them.
|
||||
|
||||
## Lane A — Sprint 2
|
||||
|
||||
> You are Lane A on SHADES 3D, Sprint 2. Rebase onto main, read THREADS.md's
|
||||
> [I] entries and SPRINT2.md §Lane A. Your sprint IS the assembly: in main.js
|
||||
> swap stub wind → createWind (storm_01 calm phases, storm_02 for the storm),
|
||||
> placeholder → await createPlayer (importmap already landed), add the sail
|
||||
> view + rig step, skyfx + debris + unlockAudio, dress the yard with Lane E's
|
||||
> GLBs (house per decision 6 — read fascia_anchor_* from the GLB), rework
|
||||
> anchors per decision 2 (posts in, p3 added, tree branch_anchor_* live, with
|
||||
> the new quad-area assert), then HUD (loads in kN) and the four-phase machine.
|
||||
> Post "gate 1" in THREADS.md the moment weather+player+sail are live in the
|
||||
> yard so Lane D starts. Small commits, selftest green after each, you remain
|
||||
> merge shepherd per PLAN3D §6.
|
||||
|
||||
## Lane B — Sprint 2
|
||||
|
||||
> You are Lane B on SHADES 3D, Sprint 2. Rebase onto main, read THREADS.md [I]
|
||||
> entries and SPRINT2.md §Lane B + decisions 3/4/5. Land in this order: (1) the
|
||||
> decision-4 API — repair(i), trim(i,delta), cornerPos(i) — matching Lane D's
|
||||
> existing call sites in interact.js, with contract entries + asserts; (2)
|
||||
> decision 5 — consume debris.pieces in sail.step() with a momentum assert;
|
||||
> (3) the coverageOver ray-origin fix (heightAt, not y=0); (4) the prep-phase
|
||||
> picking adapter over RiggingSession once Lane A's anchor markers exist —
|
||||
> coordinate in THREADS; (5) the joint tuning session with Lane C against real
|
||||
> m/s storms, then re-run the §7 gate against REAL wind and log the constants;
|
||||
> (6) after C lands vertical gusts, the decision-3 assert (flat-horizontal no
|
||||
> longer dominant).
|
||||
|
||||
## Lane C — Sprint 2
|
||||
|
||||
> You are Lane C on SHADES 3D, Sprint 2. Rebase onto main, read THREADS.md [I]
|
||||
> entries and SPRINT2.md §Lane C + decision 3. Land: (1) vertical gust
|
||||
> component in storm JSON (downdraft fraction, ~0.25 default, validator +
|
||||
> asserts) — this closes the flat-horizontal loophole with Lane B; (2) freeze
|
||||
> and document the debris.pieces shape in contracts.js for B's integrator;
|
||||
> (3) rain-vs-sail occlusion so the garden visibly stays dry under cloth
|
||||
> (cheap — coordinate the API with B, don't ray-test every drop); (4) the
|
||||
> joint storm-tuning session with B (your THREADS ask — if storm_02 can't
|
||||
> break a carabiner rig, raise the curve, it's a data edit); (5) verify skyfx
|
||||
> light restoration inside the real main.js phase transitions once Lane A
|
||||
> wires it.
|
||||
|
||||
## Lane D — Sprint 2 (start at gate 1)
|
||||
|
||||
> You are Lane D on SHADES 3D, Sprint 2. Rebase onto main FIRST — the M3 clip
|
||||
> pack landed: player_anims.glb now carries 17 clips (ClimbLadder, Crank, Dig,
|
||||
> PickUp, Carry/CarryTurn/CarryIdle, StandUp, TakeCover, StumbleBack,
|
||||
> PlantSeeds beside your original six; names logged in THREADS). Also note the
|
||||
> integrator fixed /world/ → ./ relative paths in player.js and dev_player.html.
|
||||
> Wait for Lane A's "gate 1" THREADS entry, then: (1) verify controls + camera
|
||||
> feel in the real yard (slopes, world.solids collision), tune speeds to yard
|
||||
> scale, gust shove + knockdown from real wind and real debris hits; (2) wire
|
||||
> the full spare loop — shed_table pickup_anchor → Carry/CarryIdle while
|
||||
> carrying → repair(i) consumes the spare (B is landing repair/trim/cornerPos
|
||||
> to your call sites, decision 4); (3) prompts track cornerPos(i) live; (4)
|
||||
> wire Crank to trim(i), TakeCover as the storm shelter verb, StumbleBack for
|
||||
> gust knockback — your state machine, your call on transitions. The §7
|
||||
> one-mid-storm-repair scenario must be playable by hand before you're done.
|
||||
|
||||
## Lane E — Sprint 2 (small)
|
||||
|
||||
> You are Lane E on SHADES 3D, Sprint 2. Rebase onto main, read SPRINT2.md
|
||||
> §Lane E. Small sprint: (1) canopy sway handles — verify your canopy_* nodes
|
||||
> sway cleanly when Lane A drives them, add sway_hint props if per-tree tuning
|
||||
> is needed; (2) a 512² sail cloth weave atlas + tear decal strip so the
|
||||
> membrane reads as fabric; (3) storm dressing set: wheelie bin (mass_hint),
|
||||
> washing line, garden gnome — same one-script determinism + contact-sheet
|
||||
> acceptance; (4) when Lane A's yard is dressed, render a contact sheet of the
|
||||
> assembled yard from the game camera for DESIGN.md.
|
||||
|
||||
132
SPRINT2.md
Normal file
132
SPRINT2.md
Normal file
@ -0,0 +1,132 @@
|
||||
# SPRINT 2 — ASSEMBLY (instructions for Opus 4.8 lanes)
|
||||
|
||||
*Sprint 1 verdict: every module is built and proven in isolation — 121/121
|
||||
selftest asserts green on merged main — but the game is not assembled. main.js
|
||||
still drives the M0 placeholder capsule and stub wind. Sprint 2 is one thing:
|
||||
**wire the proven modules into one playable storm.** Read THREADS.md from your
|
||||
last entry down before starting; the integrator [I] entry lists what changed
|
||||
under you.*
|
||||
|
||||
## Decisions (made — stop waiting on them)
|
||||
|
||||
1. **Lanes run on m3ultra.** Lane D's recommendation is adopted: the M1 Ultra
|
||||
(`johnking@100.91.239.7`) is an asset-build box you SSH to; GLBs get committed;
|
||||
the game never needs it at runtime. PLAN3D §0 is amended by this line.
|
||||
2. **Sail-area problem (B's 70–192 m² finding): fix the yard, not the physics.**
|
||||
Lane A: move p1/p2 in to roughly (−4.5, 5.5) and (4.0, 6.0), add a third post
|
||||
p3 near (0, 7), and register the trees' `branch_anchor_*` empties (E shipped
|
||||
them with `rating_hint`) as anchors. Target: at least three pickable quads in
|
||||
the 18–45 m² range that can shade the garden bed, verified by a new a.test
|
||||
assert that enumerates quad areas. The huge quads stay possible — the load
|
||||
bars teaching "you cannot span the whole yard" is design working as intended.
|
||||
3. **Flat-horizontal loophole: Lane C closes it with vertical gust structure.**
|
||||
Real gusts aren't horizontal; add a per-gust vertical component (downdraft
|
||||
fraction in storm JSON, default ~0.25 of gust power, validated) so a
|
||||
horizontal plate carries real load. Lane B adds the assert: over 8 directions
|
||||
in storm_02 wind, flat-horizontal peak load ≥ 60% of flat-pitched peak (i.e.
|
||||
no longer a free lunch). Ponding stays out of scope (M4 water spike).
|
||||
4. **repair/trim seam: Lane B conforms to Lane D's call sites** (D landed first,
|
||||
duck-typed): add `repair(i)` (→ repairCorner with the spare's hw),
|
||||
`trim(i, delta)` (→ trimCorner) and `cornerPos(i) -> Vector3` (live world
|
||||
position, fresh vector) to the rig object. Contract entries + b.test asserts.
|
||||
5. **debris↔sail seam: option (b)** — Lane B reads `debris.pieces`
|
||||
({x,y,z,vx,vy,vz,r,mass}) inside `sail.step()` and applies impulses; momentum
|
||||
bookkeeping stays in the one integrator. Lane C freezes the `pieces` shape.
|
||||
6. **House GLB: no re-cut.** Lane A reads `fascia_anchor_*` positions out of
|
||||
`house_yardside_v1.glb` at load and places anchors there (data wins over the
|
||||
yard constants). E's 2.80 m fascia replaces the 2.6 m graybox number.
|
||||
|
||||
## Lane A — assemble the game (this is the sprint)
|
||||
|
||||
main.js boot(), in order; keep each step behind a small commit:
|
||||
1. Stub wind → `createWind(storm)` — `storm_01_gentle` for prep/forecast calm,
|
||||
`storm_02_wildnight` when the storm phase starts. Call
|
||||
`wind.setSheltersFromTrees(...)` after the yard builds, per C's ask.
|
||||
2. Placeholder → `await createPlayer(scene, world, cameraRig, {wind, interact})`
|
||||
(async boot; D says same first three args). Delete the placeholder factory.
|
||||
3. Sail: `const view = await createSailView(rig); scene.add(view); view.update()`
|
||||
per frame after `rig.step(dt, wind, t)`. B's THREADS entry has the exact shape.
|
||||
4. `createSkyFx({scene, camera, wind, sun, hemi})` + `unlockAudio()` on first
|
||||
input; `createDebris({heightAt: world.heightAt, onHitPlayer: player.knockdown})`,
|
||||
`debris.setModels()` from `models/debris/` (glob the dir).
|
||||
5. Yard dressing: swap graybox house → `house_yardside_v1.glb` (decision 6),
|
||||
shed + shed_table (D's spare pickup), sail posts as `sail_post_v1.glb` rotated
|
||||
about `rake_pivot`, fence set, grass billboards off `textures/grass_atlas.png`.
|
||||
6. Anchor rework per decision 2.
|
||||
7. HUD: loads in **kN** (B's units note), per-corner bars vs rating, wind meter +
|
||||
gust telegraph banner, garden % (wire `rig.coverageOver(world.gardenBed)` →
|
||||
HP → E's `plants_full/tattered/dead` visibility swap), phase banner, forecast
|
||||
card (storm JSON summary: peak wind, change time), aftermath screen (garden %,
|
||||
corners lost, budget delta).
|
||||
8. Phase machine: forecast (show card, Enter) → prep (rigging UI live, budget
|
||||
$80, optional timer OFF this sprint) → storm (90 s, storm_02) → aftermath.
|
||||
Acceptance: `python3 server.py` → rig a sail with the mouse, press Enter, watch
|
||||
storm_02 try to kill it, repair a corner mid-storm, see the aftermath screen.
|
||||
60 fps during the storm on this box. Selftest stays green after every merge.
|
||||
|
||||
## Lane B — sail in the world
|
||||
|
||||
1. Decision 4 API (repair/trim/cornerPos) + asserts.
|
||||
2. Decision 5: consume `debris.pieces` in step(); assert momentum is conserved
|
||||
within tolerance on a crate-through-sail scenario.
|
||||
3. Prep-phase picking adapter: RiggingSession → clicks. Raycast against Lane A's
|
||||
anchor markers (A exposes `world.anchorMarkers` if you need meshes — agree in
|
||||
THREADS), corner cycling + tension dial + spare purchase, HUD summary line
|
||||
from `summary()`. This unblocks A step 8.
|
||||
4. Joint tuning session with C (their THREADS ask): retune cloth ρ against real
|
||||
m/s storms; then re-run the §7 gate against REAL wind (current assert used the
|
||||
stub) — flat cheap rig cascades in storm_02, twisted mixed rig + one repair
|
||||
survives. Log tuned constants in THREADS.
|
||||
5. Small fix: `coverageOver()` rays start at `heightAt(x,z)`, not y=0 (your nit).
|
||||
6. Decision 3 assert (flat-horizontal no longer dominant) once C lands vertical gusts.
|
||||
|
||||
## Lane C — weather in the game
|
||||
|
||||
1. Decision 3: vertical gust component in storm JSON + validator + asserts.
|
||||
2. Decision 5: freeze and document `debris.pieces` shape in contracts.js.
|
||||
3. Rain must react to the sail: cheap occlusion — sample `rig.coverageOver` cells
|
||||
or raycast a handful of drops so the garden visibly stays dry under cloth.
|
||||
(Coordinate the API with B; don't ray-test every drop.)
|
||||
4. Storm tuning session with B (see B-4).
|
||||
5. skyfx: verify light restoration on `dispose()` inside the real main.js scene —
|
||||
A will call you if teardown flickers phase transitions.
|
||||
6. Consider retiring weather_demo.html once main.js hosts the storm — your call,
|
||||
it stops earning its place when the game IS the bench.
|
||||
|
||||
## Lane D — player in the storm
|
||||
|
||||
1. After A's step 2 swap: verify controls + camera feel in the real yard (slopes,
|
||||
fence collision against `world.solids`), tune walk/run speeds against yard
|
||||
scale, make gust shove + knockdown fire from real wind and real debris hits.
|
||||
2. Wire the spare pickup loop end-to-end: shed_table `pickup_anchor` → carrying →
|
||||
`repair(i)` consumes it (B's decision-4 API). The §7 "one mid-storm repair"
|
||||
scenario must be playable by hand.
|
||||
3. Interaction prompts follow `cornerPos(i)` live (flogging corner = moving prompt).
|
||||
4. Kill dev_player.html only when the main game covers everything it proves.
|
||||
|
||||
## Lane E — polish targets (small sprint)
|
||||
|
||||
1. Wind-reactive foliage handles: verify canopy nodes sway cleanly when A drives
|
||||
them; add `sway_hint` custom props if A needs per-tree tuning.
|
||||
2. Sail cloth texture (512² weave/UV-stripe atlas) so the membrane reads as
|
||||
fabric, + a simple tear decal strip for M3.
|
||||
3. Storm dressing set: wheelie bin (debris, `mass_hint`), washing line, garden
|
||||
gnome (collateral scoring bait). One script run, same acceptance as before.
|
||||
4. Contact-sheet the assembled yard from A's camera for DESIGN.md.
|
||||
|
||||
## Order & gates
|
||||
|
||||
```
|
||||
gate 1: A steps 1-4 land → the yard has weather, a person and a live sail
|
||||
gate 2: B-3 picking + A-8 → the loop is playable start to finish
|
||||
gate 3: B/C tuning + D-2 → §7 scenario passes BY HAND and in selftest
|
||||
```
|
||||
Definition of done = gate 3 + THREADS updated + selftest green + a 90 s
|
||||
storm_02 run captured (screenshots or video) for the log.
|
||||
|
||||
## For John (human-only items)
|
||||
|
||||
- Mixamo login on the M1 for D's M3 wishlist (`tools/character/mixamo_wishlist.txt`
|
||||
— Climbing Ladder, Turning Key, Digging). Not blocking this sprint.
|
||||
- Two dev servers float around: another session holds :8801; this repo's
|
||||
launch.json now uses :8809. Kill either freely; `server.py --port N` works.
|
||||
411
THREADS.md
411
THREADS.md
@ -81,6 +81,92 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
`~/Documents/shades-laneD/` and work there** — B, C and E already have their own. PLAN3D §0 says to
|
||||
do this; it's the one house rule that has to hold or the whole lane model stops working.
|
||||
|
||||
[D] 2026-07-16 — Worktree collision: mine, sorry — acknowledged and fixed. I was launched in the shared
|
||||
`~/Documents/shades/` and branched there, which is what moved HEAD under Lane A. Now on my own
|
||||
worktree at `~/Documents/shades-laneD/` (branch `lane/d`); shared checkout handed back clean on
|
||||
`main` at 8d76340, nothing of A's touched. My asset commit 027fb99 had already been swept into main
|
||||
ahead of M0 — leaving it, it's what player.js loads.
|
||||
|
||||
[D] 2026-07-16 — 🔑 **ANSWERS A's OPEN QUESTION + the §2 asset gap — the libraries are REAL, on the OTHER
|
||||
box.** Lane A is right that `3D=models/`, `character_kit/`, `mixamo-fetch/` and `FBX/` do not exist on
|
||||
`m3ultra`. They exist on the **M1 Ultra**, which is up and reachable over SSH right now
|
||||
(`ssh johnking@100.91.239.7`, hostname `ultra.local`, no password needed). Verified there:
|
||||
· `~/Documents/FBX/` — Running.fbx, Falling.fbx, Crouch To Stand.fbx, Reaction.fbx, Death.fbx…
|
||||
· `~/Documents/3D=models/animations/` — 32 clips incl. Happy Idle.fbx, Walk.fbx, Start Walking.fbx
|
||||
· `~/Documents/character_kit/` (rigged/, scripts/merge_anims.py), `~/Documents/mixamo-fetch/`,
|
||||
`~/Documents/3D-STORE/` (Lane E: `clean_glbs/` + `build_booth_room_v23.py` are there too, in
|
||||
addition to the `Destroyulater/3D-STORE/` copy A found here), and Blender 5.0.1.
|
||||
**So §2's inventory isn't wrong, it's just written from the M1 — and §0 meant it: "all asset paths
|
||||
below are local there".** This does NOT mean lanes should move. The copies rule already resolves it:
|
||||
build the asset ON the M1, commit the GLB, and the game never needs that box again. That's what I did
|
||||
— `player_anims.glb` was built there and is committed; `python3 server.py` on m3ultra needs nothing
|
||||
remote. **Recommendation: lanes stay on m3ultra; the M1 is an asset-build box you SSH to.** Lane E,
|
||||
that's your unblock too if you want Poly Haven/reference work — Blender is over there.
|
||||
|
||||
[D] 2026-07-16 — ⚠️ **BLOCKS THE PLAYER SWAP — Lane A, one line in index.html.** Every vendored addon
|
||||
imports from the **bare specifier `'three'`** (`vendor/addons/**/*.js` all end `} from 'three';`).
|
||||
index.html has no importmap, so the moment it imports player.js it dies with "Failed to resolve
|
||||
module specifier 'three'". Nothing hit this before because main/world/camera.js import
|
||||
`../vendor/three.module.js` directly and use no addons — I'm the first lane to need one, and it isn't
|
||||
optional: SkeletonUtils + GLTFLoader are what the DEVMANUAL rig rules mandate. Fix is the 90sDJsim
|
||||
line, in `<head>` before the module script:
|
||||
`<script type="importmap">{ "imports": { "three": "/world/vendor/three.module.js",
|
||||
"three/addons/": "/world/vendor/addons/" } }</script>`
|
||||
(selftest.html does NOT need it — d.test.js only imports the zero-dep sim, which is why it's green.)
|
||||
**Lane E: this will land on you too** the moment you load a GLB. Alternative if you'd rather not add
|
||||
a map: rewrite the 12 addon files' `from 'three'` → a relative path — but that forks the vendor drop
|
||||
from upstream, so I'd take the importmap.
|
||||
|
||||
[D] 2026-07-16 — **PLAYER LANDED** on `lane/d`, rebased on M0, ready for the boot() swap.
|
||||
`player.sim.js` (deterministic core, zero imports) · `player.js` (rig/view + `createPlayer`) ·
|
||||
`interact.js` (hold-E + `wireYardActions`) · `js/tests/d.test.js` · `dev_player.html` (my mock
|
||||
harness — Lane A owns the real shell; I never touched main.js/index.html/selftest.html).
|
||||
Selftest: **38 pass / 3 skip**, 20 of them Lane D. Verified in a real scene, not just asserts:
|
||||
head bone **1.715 m** at fig scale 0.983, all 6 clips bound, walk/run at the tuned speeds, knockdown
|
||||
lies the body down and drops the carried spare, get-up returns upright, hold-E radial fires once.
|
||||
`checkContract('player', createPlayer(...))` → **CONFORMS**, and it clamps to `world.heightAt()`.
|
||||
**Lane A: swap `createPlaceholderPlayer(scene, world, cameraRig)` → `await createPlayer(scene, world,
|
||||
cameraRig, {wind, interact})` — same first three args, deliberately — add the importmap above, and
|
||||
delete the placeholder.** It's async (two GLB fetches), so boot() must await it.
|
||||
|
||||
[D] 2026-07-16 — ❗ **CONTRACT NEEDS FROM LANE B — not urgent, but §5-D.4 can't finish without them.**
|
||||
PLAN3D §4's `sailRig` exposes corners/attach/step/coverageOver/events but nothing to ACT on a corner,
|
||||
and repairs are Lane D's whole job. `interact.js:wireYardActions` already calls these, duck-typed, so
|
||||
they no-op harmlessly until you land them — nothing breaks meanwhile:
|
||||
· `sailRig.repair(i)` — re-rig corner i (I gate it on the player carrying a spare, 2.5 s hold, and
|
||||
I consume the spare). Needed for the M2 "one mid-storm repair must be survivable" line in §7.
|
||||
· `sailRig.trim(i, delta)` — per-corner turnbuckle, ±tension at ONE corner (1.2 s hold). This is
|
||||
§5-D.4's "new vs prototype, makes corners individual".
|
||||
· `corner.pos` (or `sailRig.cornerPos(i)`) → world Vector3 — I need somewhere to put the prompt.
|
||||
Live-read each frame, so a flogging corner's prompt tracks it.
|
||||
Shout if the shapes fight your sim and I'll adapt — you own sail.js, I'll move.
|
||||
|
||||
[D] 2026-07-16 — 📌 **PLAN3D §5-D.1 is not buildable as written — the peds cannot go through Blender.**
|
||||
§5-D.1 says merge clips onto a ped via the character_kit pipeline. That pipeline cannot accept a ped:
|
||||
the ped GLBs encode metre scale as a **node scale of 0.01 on `mixamorig*:Hips`** with every child bone
|
||||
in centimetres (Spine T=+10.05, LeftLeg T=+42.8). Blender bones have no rest scale, so the glTF
|
||||
importer silently drops that 0.01 — straight after import the rig already reads Hips at 0.99 (metres)
|
||||
while HeadTop_End reads 76.88 (centimetres) and LeftToe_End sits **96 m under the floor**. Exploded
|
||||
before a single clip is merged. (It's also why `dancer.glb` is 30x small — its base, Hum_M_1.fbx, is
|
||||
an FBX with no such trick, head bone 0.0563 m. And `merge_anims.py`'s own comment warns about exactly
|
||||
this class of bug from the other end.) **What I did instead:** ship the ped byte-identical and carry
|
||||
the clips beside it in an anim-only GLB (`player_anims.glb`, 677 kB, no mesh) — which is precisely the
|
||||
shape 90sDJsim already ships as `peds/idle.glb` + `peds/walk.glb`, so it's the house pattern, not a
|
||||
workaround. Retarget is at load: canonicalise the bone namespace, keep rotation tracks only.
|
||||
Rebuild: `tools/character/build_player_anims.py` (header has the full why + the ssh one-liner).
|
||||
Two gotchas worth knowing if you touch rigs:
|
||||
· three.js **GLTFLoader strips `:` from node names** (reserved in property paths), so at runtime the
|
||||
bones are `mixamorigHips`, never `mixamorig:Hips`. `_canon` still works — both sides sanitise
|
||||
identically, which is *why* a mixamorig4 clip binds to a mixamorig12 ped.
|
||||
· Blender 5.0 **removed `Action.fcurves`** (slotted actions — they're under
|
||||
`layers[].strips[].channelbags[]`), so `character_kit/scripts/merge_anims.py` no longer runs there
|
||||
as written. My script handles both layouts.
|
||||
|
||||
[D] 2026-07-16 — M3 clips are **queued, not fetched**: `tools/character/mixamo_wishlist.txt` (Climbing
|
||||
Ladder, Turning Key, Digging + repair/storm extras). `mixamo-fetch` needs a **manual Google login in a
|
||||
real browser** — its README is explicit that Claude never sees the password — so this one wants John,
|
||||
not a lane. Everything M0–M2 needs is already on disk and in `player_anims.glb`.
|
||||
|
||||
[A] 2026-07-16 — ❓ **OPEN QUESTION, needs a human.** PLAN3D §0 says lanes run on "the M1 Ultra
|
||||
(`johnking@100.91.239.7`, Tailscale)", but this box is `m3ultra` and already has
|
||||
`~/Documents/shades-laneB/` and `shades-laneE/` checked out — so lanes are in fact running here, and
|
||||
@ -88,6 +174,95 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
other machine, this isn't a path fix, it's a decision about where lanes run. Flagging rather than
|
||||
guessing.
|
||||
|
||||
[B] 2026-07-16 — **sail.js + rigging.js landed on `lane/b`, rebased on M0.** `checkContract('sailRig')`
|
||||
conforms; `js/tests/b.test.js` runs 28 asserts green. 3D verlet cloth, N=10, structural/shear/bend,
|
||||
5 iterations at a fixed 1/60 substep, wind per FACE. `step(dt, wind, t)` takes ragged frame dt and
|
||||
does its own fixed-dt substepping — asserted that a 4-24 ms ragged loop converges on the fixed-dt
|
||||
trace, so what selftest proves actually applies to the running game.
|
||||
|
||||
[B] 2026-07-16 — **⚠️ UNITS CHANGED — Lane A (HUD) read this one.** `corner.load` and `hw.rating` are in
|
||||
NEWTONS now, not the prototype's arbitrary scale. I retuned `HARDWARE` in contracts.js to real WLLs
|
||||
(carabiner 1200 N, shackle 3200 N, rated 6500 N) under the standing note in that file that Lane B
|
||||
owns these numbers — costs and tier shape untouched, and $80 still buys rated hardware on at most 2
|
||||
of 4 corners (asserted). **HUD: show `load/1000` as kN.** A 5×5 m sail pulls ~1-4 kN per corner in a
|
||||
34 m/s storm, which is exactly why real shade sails use 3 kN+ shackles. That's DESIGN.md's Kerbal
|
||||
trick working — the number on the meter is one you could take to a hardware shop.
|
||||
|
||||
[B] 2026-07-16 — thanks for the `sway(t)` clarification, it caught a real bug: I had it as an offset and
|
||||
was adding it to `pos`, which would have flung every tree-anchored corner to double its coordinates.
|
||||
Also consuming `world.sunDir` and `world.gardenBed` as specified (centre+size rect; a hit along
|
||||
sunDir means shaded). One nit: `coverageOver()` starts its rays at y=0 rather than `heightAt(x,z)`.
|
||||
On ±0.3 m terrain under a 3 m sail that's ~0.2 m of shadow error — not worth a contract change now,
|
||||
flagging so it isn't a surprise later.
|
||||
|
||||
[B] 2026-07-16 — **⚠️ FINDING FOR LANE A — the yard's anchors imply enormous sails.** Every 4-anchor quad
|
||||
a player can pick from the 7 fixed anchors, by area: h1,h2,t1,p1 = 70 m² · h2,h3,t2,p2 = 71 m² ·
|
||||
t1,t2,p1,p2 = 111 m² · h1,h3,t1,t2 = 133 m² · h2,t1,p1,p2 = 143 m² · h1,h3,p1,p2 = **192 m²**.
|
||||
Real domestic shade sails are 20-50 m², and DESIGN.md itself pictures "a 30 m² kite". Wind load
|
||||
scales with area, so at 192 m² nothing affordable on an $80 budget survives a real storm. The sim is
|
||||
saying "you cannot span the whole yard", which is correct physics and arguably correct design — but
|
||||
it means the natural, obvious pick (house corners out to both posts) is an instant loss. Options in
|
||||
my order of preference: (1) more anchors, closer together, so a sensible 25-40 m² quad exists at all,
|
||||
(2) posts moved in, (3) accept it and let the prep-phase load bars teach it. Not my call — flagging
|
||||
with numbers rather than guessing. Nothing blocks on it; M1 is playable either way.
|
||||
|
||||
[B] 2026-07-16 — **❓ OPEN — the flat-horizontal loophole. Needs Lane C, or the water spike.** DESIGN.md's
|
||||
core tension is "big, flat, low = great shade, death in a storm". My sim disagrees, and it is right
|
||||
to. Peak corner load over 8 wind directions, same footprint: flat *pitched* 3.06 kN, hypar 1.86 kN,
|
||||
flat *horizontal* **1.14 kN** — the lowest of all three. A horizontal plate in horizontal wind
|
||||
genuinely has almost no drag. What kills real flat sails is ponding (water weight), flutter and
|
||||
leeward suction, none of which are in scope for me: ponding is DESIGN.md's second prototype spike,
|
||||
and proper separated-flow aero is not happening in a hand-rolled cloth sim. So a player who plants
|
||||
four posts at equal height currently gets the *safest* possible rig, which is the exact inverse of
|
||||
the design's intent. Not fixable inside sail.js. Lane C: a vertical gust component would load a
|
||||
horizontal sail and would partly close this.
|
||||
|
||||
[B] 2026-07-16 — **the thesis assert is scored on WORST CASE over 8 wind directions, not per-direction.**
|
||||
PLAN3D §5-B says "twisted peak < flat peak, same storm". Per-direction is a false assert and I won't
|
||||
ship it: from the one angle where a flat sail sits edge-on it genuinely beats the hypar, and forcing
|
||||
that green would mean tuning the sim into a lie. Worst-case is also the honest game question, since
|
||||
Lane C's storms veer and the player never gets to pick the wind. Result: flat worst 3.06 kN (from S)
|
||||
vs hypar worst 1.86 kN (from N) — the hypar sheds 39% off its worst moment. Thesis holds.
|
||||
|
||||
[B] 2026-07-16 — two notes for whoever next reads sail.js, because both look "simplifiable" and aren't.
|
||||
(1) Corner load is read from each constraint's **XPBD Lagrange multiplier** (|λ|/dt²), NOT from
|
||||
`FABRIC_K × leftover stretch`. After a fixed 5 iterations the leftover stretch is *solver error*, not
|
||||
fabric strain, so the obvious reading measures the solver — it came out ~50× hot, 60 kN peaks on a
|
||||
5×5 sail. The `statics` assert is what keeps this honest: corner reactions must sum to the real
|
||||
aerodynamic + weight force on the fabric (Newton's third law). It balances to 8.3%. If someone
|
||||
"simplifies" the load reading, that assert is what goes red. (2) The **tension dial was remapped**
|
||||
off the prototype's `rest = rest/tension`, which asks for 29% pre-strain at dial 1.4 and put 68 kN on
|
||||
a corner before any wind blew. It is now a real pre-strain (0.10/dial → 4% at 1.4).
|
||||
|
||||
[B] 2026-07-16 — **BUG worth knowing about, fixed:** a corner that blew was marked `broken` but never got
|
||||
its mass back, so it stayed pinned — a "blown" corner sat welded in mid-air and the sail quietly went
|
||||
dead instead of flogging. PLAN3D §5-B wants flogging emergent from the freed node, and it is now. The
|
||||
cascade test missed it entirely because it forced the break by hand and called `_repin()` itself; the
|
||||
replacement drives a real overload failure and asserts the corner tears free of its anchor and keeps
|
||||
moving. Lesson for other lanes: a test that sets up state by hand can pass over a dead code path.
|
||||
|
||||
[B] 2026-07-16 — **Lane D — your API is ready.** `sailRig.repairCorner(i, hw)` re-pins a blown corner
|
||||
(your 2.5 s hold-E; returns false if it isn't broken). `sailRig.trimCorner(i, ±delta)` is the
|
||||
per-corner turnbuckle (your 1.2 s hold; clamps 0.85-1.15, scales rest lengths near that corner only).
|
||||
Both emit on `sailRig.events`. Spare count lives on `RiggingSession.spares` — gate `canUse()` on it
|
||||
and decrement on use.
|
||||
|
||||
[B] 2026-07-16 — **Lane A — wiring the rendered sail.** `const view = await createSailView(rig);
|
||||
scene.add(view);` then `view.update()` each frame after `rig.step()`. Returns a THREE.Group,
|
||||
double-sided, `castShadow` on — the shadow IS the product. three is imported lazily inside it so the
|
||||
sim core stays headless-runnable. Verified headless (100 verts / 162 tris, normals recomputed, verts
|
||||
track the sim) but **not yet eyeballed in a browser** — it wants a look once it's in main.js. The
|
||||
prep-phase picking UI is NOT landed: it needs your camera and anchor markers to raycast against. All
|
||||
the rules behind it are done and tested in `RiggingSession` (rigging.js), so it's a thin
|
||||
click-to-session adapter once M0's camera is available to me.
|
||||
|
||||
[B] 2026-07-16 — selftest convention, slightly off your stub and I think worth keeping: my asserts live
|
||||
next to the code in `js/sail.selftest.js` and `js/rigging.selftest.js` as exported `[name, fn]`
|
||||
arrays, and `js/tests/b.test.js` is a 3-line adapter that feeds them to your Suite. The reason is
|
||||
that those modules ALSO run under plain `node web/world/js/sail.selftest.js` — no browser, no server,
|
||||
~7 s — which is how the cloth got proven before M0 existed. Same array both ways, so the two
|
||||
harnesses cannot drift. `contracts.js` importing three is no obstacle to that: node imports
|
||||
`three.module.js` fine.
|
||||
[E] 2026-07-16 — ✅ **A's §2 correction independently confirmed** — I hit the same wall from the asset side
|
||||
before M0 landed: `3D-STORE` is at `~/Documents/Destroyulater/3D-STORE/`, and `character_kit` / `FBX` /
|
||||
`3D=models` / `mixamo-fetch` exist nowhere on this box. My inputs were relocated, not missing, so §5-E
|
||||
@ -165,3 +340,239 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
fascia sits at 2.80 (2.90 m ridge), and the facade is 9.20 m against a 30 m north edge. Want me to
|
||||
re-cut it to your numbers, or will you read `fascia_anchor_*` off the GLB when you swap the graybox?
|
||||
Either way it's one constant for me — the script regenerates everything.
|
||||
[C] 2026-07-16 — ⚠️ **THE WORKTREE COLLISION WAS ME, NOT LANE D — please don't redirect D on my
|
||||
account.** Before I'd realised Lane A was live in the shared checkout, I ran `git checkout -b lane/c`
|
||||
inside `~/Documents/shades/`, and a few minutes later `git checkout main` + `git branch -D lane/c` to
|
||||
put it back the way I found it. That second command is exactly the "HEAD moved off `lane/a` onto
|
||||
`main`" symptom A describes, and the timing lines up. Nothing was lost — the delete reported
|
||||
`was 8338c0f`, i.e. no commits had landed on my branch during the window — and A's read of the damage
|
||||
is right, it just has the wrong lane's name on it. Lane C has been on `~/Documents/shades-laneC/`
|
||||
since. (Lane D does also appear to be committing from the shared tree — `027fb99` landed on `main`
|
||||
from there — so A's ask of D still stands on its own merits. I'm only disowning the checkout.)
|
||||
Apologies for the noise; PLAN3D §0's clone rule earns its keep.
|
||||
|
||||
[C] 2026-07-16 — **LANE C LANDED on `lane/c` — weather.js, skyfx.js, debris.js, 2 storms.** Rebased on
|
||||
M0; `c.test.js` is live (19 asserts) and Lane A's selftest reads **37 pass / 3 skip**. The stub wind
|
||||
can be retired whenever A likes — `createWind()` is a drop-in for `createStubWind()`.
|
||||
· `wind.sample(pos,t)` / `wind.gustTelegraph(t)` per contract; `checkContract('wind', …)` clean.
|
||||
· Gusts are a **precomputed timeline**, not an integrator. The prototype accumulated `gustT += dt`;
|
||||
we can't, because sample(pos,t) is called by everyone at arbitrary t and out of order. Same
|
||||
envelope though — telegraph 1.5 / ramp 0.8 / hold 1.7 / fade 1.0, straight off the prototype.
|
||||
· Storms are **data**: `data/storms/*.json`, validated on load (throws loud — a typo in a storm is
|
||||
a content bug and should not silently blow calm). Tune curves without touching code.
|
||||
|
||||
[C] 2026-07-16 — **CONTRACT — one addition, backward compatible.** `wind.sample(pos, t, out?)` takes an
|
||||
optional third arg: pass a Vector3 and it writes into it instead of allocating. Lane B, please use it
|
||||
— per-face sampling on a 10×10 grid at 60 Hz is ~5k Vector3 allocations/sec otherwise. Two-arg calls
|
||||
behave exactly as specified, and unlike the stub the returned vector is freshly allocated and yours
|
||||
to keep (the contract's "clone before you store it" rule still holds for stub-era code, it's just no
|
||||
longer necessary against the real wind).
|
||||
|
||||
[C] 2026-07-16 — **ASKS, one per lane. All degrade silently — nothing here blocks a merge.**
|
||||
· **Lane A** — trees don't shelter anything until you tell me where they are:
|
||||
`wind.setSheltersFromTrees(world.anchors.filter(a => a.type === 'tree'))` after the yard builds.
|
||||
Unset = no wind shadows, which is just a flatter yard. Also `createSkyFx({scene, camera, wind,
|
||||
sun, hemi})` — it modulates YOUR lights and hands them back on `dispose()`, it doesn't own them;
|
||||
and `createDebris({heightAt: world.heightAt})` so debris bounces off your terrain, not y=0.
|
||||
skyfx needs `unlockAudio()` on the first click/keydown (browser rule) or the storm is silent.
|
||||
· **Lane B** — ❓ **the debris-vs-sail seam is your call.** I have the impulse maths but not your
|
||||
nodes: contracts exposes `sailRig.corners`, not the cloth. Two options — (a) I keep driving it and
|
||||
you expose `sailRig.nodes` (array of `{x,y,z}`; I push them out of the sphere and let your verlet
|
||||
turn that into velocity — written and duck-typed, it lights up the moment `nodes` exists), or
|
||||
(b) you read `debris.pieces` (`{x,y,z,vx,vy,vz,r,mass}`) in `sail.step` and do it yourself, since
|
||||
you own the integrator. I'd take (b) if you want the momentum bookkeeping in one place. Say which
|
||||
and I'll match it.
|
||||
· **Lane D** — `createDebris({onHitPlayer: (piece, impact) => …})` fires when something big enough
|
||||
actually connects (impact = |v|·mass, threshold 25, so a tub rolling past your ankles doesn't
|
||||
floor you). The knockdown state machine is yours per §5-D.3; I only report the hit.
|
||||
· **Lane E** — I need `web/world/models/debris/{BlueCrate_v2,BlackTub_v2,WhiteTub_v2,WoodenBin_v2}.glb`
|
||||
(your §5-E.8; A confirmed the sources are real at `~/Documents/Destroyulater/3D-STORE/clean_glbs/`).
|
||||
Until they land debris renders as graybox boxes, so this is cosmetic, not blocking.
|
||||
`debris.setModels({name: Object3D})`. Collision is one sphere per piece — radii in `MODEL_SPEC` in
|
||||
debris.js assume a ~0.6 m crate; if you scale them differently, tell me rather than fighting it.
|
||||
|
||||
[C] 2026-07-16 — **Lane B: tune cloth ρ against these, not against the stub.** Wind is in real m/s and
|
||||
the stub is not (its 8→34 ramp is the prototype's pixel-ish scale wearing m/s units — A says as much
|
||||
in `createStubWind`'s doc). `storm_01_gentle`: sustained peaks 6.5, worst gust 11.3 m/s (41 km/h) —
|
||||
the sail should breathe and nothing should break. `storm_02_wildnight`: sustained peaks 20 (72 km/h),
|
||||
worst gust 32.3 (116 km/h, BOM 'destructive'), southerly change swings 2.09 rad at t=55–59 with the
|
||||
peak landing just after it. That change is the design: the corners that were slack all storm are the
|
||||
ones that cop it. PLAN3D §7 (flat cheap rig must cascade-fail in storm_02; twisted mixed rig with one
|
||||
repair must survive) is a **joint B+C gate** — I can't assert it without your cloth, so I've asserted
|
||||
the wind half (`storm_02 is genuinely violent, storm_01 is not`). Ping me when sail.js lands and we'll
|
||||
tune together; if storm_02 can't break a carabiner rig I'll raise the curve — that's a data edit.
|
||||
|
||||
[C] 2026-07-16 — Notes on my own files, so nobody trips over them:
|
||||
· `weather.core.js` imports **nothing** — no THREE, no DOM, no Date.now. Deliberate: it makes the §4
|
||||
determinism rule structural rather than a promise, and it means the whole suite also runs headless
|
||||
via `node web/world/js/tests/run-node.mjs` (~1 s, no browser, no server). Tuning a storm curve
|
||||
through a browser round trip is miserable. `weather.js` is the thin THREE adapter over it.
|
||||
· Cost of that: `weather.core.js` carries its own copy of mulberry32 rather than importing
|
||||
`contracts.rng` — identical algorithm and output, it just can't import a file that pulls in THREE.
|
||||
`debris.js` and `skyfx.js` do use `contracts.rng`. Not thrilled about the duplication; the
|
||||
alternative was giving up node-side testing of the one module everything else depends on.
|
||||
· Asserts live in `js/tests/weather.selftest.js` as a plain case list; `c.test.js` and the node
|
||||
runner are two harnesses over the same list, so they can't drift.
|
||||
· `weather_demo.html` is a Lane C bench (mock sail, storm scrub, 4×, throw-a-crate) on its own URL —
|
||||
it touches nothing of yours. Delete it whenever it stops earning its place.
|
||||
· **Lane A:** a.test.js's 'gust telegraph always gives at least 1.2 s of warning' is now also
|
||||
asserted against the real wind in c.test.js, per your note in the stub. Yours to drop when the
|
||||
stub goes.
|
||||
|
||||
[C] 2026-07-16 — Three bugs worth knowing about, because the shapes recur:
|
||||
· Advected noise: I had `drift = U(t)·advect·t`, which is not an integral — when U or dir moved it
|
||||
yanked the whole accumulated field sideways: a **6.8 m/s jump in one frame** at the southerly
|
||||
change. Now integrated once at build time into a table. If you ever advect anything by time,
|
||||
integrate it.
|
||||
· Debris friction: `v *= 0.86` per frame while grounded is `0.86^60` per second — glue, not scrape.
|
||||
It pinned a 9 kg crate at 0.7 m/s in a 19 m/s wind. Anything per-frame that should be per-second
|
||||
needs dt.
|
||||
· `storm_02`'s southerly change blew **north** in its first draft: the wind vector blows toward
|
||||
`(cos d, sin d)` and contracts puts north at -Z, so a southerly needs `sin(d) < 0`. Worth a second
|
||||
look at anything that reasons about wind direction.
|
||||
All three were caught by an assert or the bench rather than by reading, which is the argument for both.
|
||||
|
||||
[I] 2026-07-16 — **INTEGRATION PASS (main).** All four lane branches merged to main (b → e → c → d;
|
||||
THREADS conflicts resolved keep-both). Added the importmap D+E asked for to index.html AND
|
||||
selftest.html (relative form: `./vendor/…` — D's `/world/…` spelling 404s on the repo-root server).
|
||||
Same absolute-path bug fixed in dev_player.html and player.js GLB URLs (`/world/models/…` →
|
||||
`./models/…`) — the ped never loaded under `server.py`; it does now, verified in dev_player.html.
|
||||
Selftest on merged main: **121 pass / 0 skip / 0 fail** (E's suite lit up as promised).
|
||||
launch.json now runs `--port 8809` (8801 was held by another session). Next work: SPRINT2.md.
|
||||
|
||||
[I] 2026-07-16 — **M3 CLIP PACK LANDED — the mixamo wishlist is fetched and baked.** John supplied a
|
||||
logged-in Mixamo session; 11 clips downloaded Without Skin @30fps (subs where Mixamo has no such
|
||||
clip: Turning Key→Pulling Lever, Standing Up Ready→Standing Up, Covering Head→Taking Cover; bonus
|
||||
find: Dig And Plant Seeds. Hammering/Sweeping/Bracing don't exist — skipped). FBXs now canonical in
|
||||
the M1's ~/Documents/FBX/; CLIPS extended in build_player_anims.py (names: ClimbLadder, Crank, Dig,
|
||||
PickUp, Carry, CarryTurn, CarryIdle, StandUp, TakeCover, StumbleBack, PlantSeeds); rebuilt on the M1
|
||||
(Blender 5.0.1, 17 NLA tracks, 2.3 MB) and committed. Verified: GLTFLoader reads all 17 clips with
|
||||
contract names; selftest still 121/0/0. Lane D: your M3 verbs are on disk — wire when ready.
|
||||
|
||||
[A] 2026-07-16 — 🚩 **GATE 1 — the yard is live. LANE D: START.** SPRINT2 §Lane A steps 1–4 are on main.
|
||||
The placeholder capsule and stub wind are gone. `python3 server.py` → real weather, your ped walking
|
||||
in it, a rendered sail overhead with its shadow on the garden bed, rain, debris, storm audio.
|
||||
Selftest **121/0/0** after the assembly — nobody's suite moved. 0.63 ms/frame in mid-storm_02
|
||||
(0.17 sim + 0.45 render) against a 16.67 ms budget, 120 k tris / 74 draw calls, so there is a LOT of
|
||||
headroom to spend. Note my clone runs `--port 8811` (8801 and 8809 are held by other sessions).
|
||||
|
||||
[A] 2026-07-16 — **It works. storm_02, hand-driven end to end, default rig (rated/shackle/shackle/carabiner
|
||||
on h1/h3/p2/p1):** the carabiner blows at **t=45.4 s**, then p2's shackle cascades at **t=56 s — one
|
||||
second after the southerly change at 55**. That is Lane C's design landing exactly as they described
|
||||
it: the corners that were slack all storm are the loaded ones after the change. Coverage over the bed
|
||||
is **1.0 with the rig intact and 0.0 once two corners are gone** — the whole game in one number.
|
||||
Peak corner load 5427 N; cloth never went non-finite. Nothing here is asserted-only; I drove it.
|
||||
|
||||
[A] 2026-07-16 — ❗ **LANE B — two things about wiring your sail, one is a real trap.**
|
||||
· `createSailView(rig)` reads `rig.pos`/`rig.tris`, which don't exist until `attach()` allocates
|
||||
them in `_build()`. Build the view before rigging and it throws on an undefined array — cost me
|
||||
my first boot. Not asking you to change it; just documenting the order.
|
||||
· **Call `SHADES.rigSail(anchorIds, hwChoices, tension)`, NOT `rig.attach()` directly**, from your
|
||||
picking adapter. `attach()` replaces the corners array and can change the grid, so the view must
|
||||
be rebuilt and interact re-wired (its targets close over corner objects, and stale closures point
|
||||
at corners the sim no longer steps). `rigSail()` does attach + view rebuild + re-wire behind one
|
||||
door, and it's `async`. Ids are stable so re-wiring replaces rather than stacking duplicates.
|
||||
· Your view is now **eyeballed in a browser**, as you asked: it bellies, catches light, and its
|
||||
shadow lands on the bed. Screenshot going in DESIGN.md with the assembled-yard sheet.
|
||||
· FYI the default rig I boot with is the prototype's AUTO loadout and spans most of the yard — it's
|
||||
your 70–192 m² finding, visible from orbit. Decision 2 (my step 6) shrinks it; not a cloth fault.
|
||||
|
||||
[A] 2026-07-16 — ❗ **LANE D — `knockdown(t, dirX, dirZ)` takes the sim clock first, not the impact.**
|
||||
Lane C's `onHitPlayer(piece, impact)` hands you an impact magnitude, and the obvious wiring —
|
||||
`knockdown(impact)` — jams ~40 into the state machine's start time and you never get up. I wired it
|
||||
`knockdown(windT, piece.vx, piece.vz)`, so you also fall the way the crate was travelling. Flagging
|
||||
in case anything else calls it. Also: `player.pos` is a plain `{x,y,z}`, not a `Vector3` — contracts.js
|
||||
says Vector3. Everything only reads `.x/.y/.z` so it duck-types fine everywhere (camera, wind, HUD)
|
||||
and I'm NOT asking you to change it; I'll relax the contract's wording instead. Your ped, all six
|
||||
clips, walk/run and the yard clamp are confirmed working in the real yard.
|
||||
|
||||
[A] 2026-07-16 — ✅ **LANE C — your §Lane C.5 ask, answered with evidence: `dispose()` restores the lights
|
||||
exactly.** Tested inside the real main.js scene, not a bench. After a 40 s storm_02 dragged sun to
|
||||
1.067 and hemi to 1.132, a bare `sky.dispose()` with no rebuild put them back at **exactly 2.0 and
|
||||
1.8**. I also ran three full forecast→storm→forecast cycles to see if anything compounds: sun settles
|
||||
at 1.939 → 1.941 → 1.951, i.e. converging on storm_01's calm-day target, not decaying. No leak, no
|
||||
flicker. Two notes: (1) I **rebuild** skyfx on every phase change rather than re-pointing it, because
|
||||
it reads `wind.def.sky` at construction and storm_01/storm_02 have different darkness — dispose() is
|
||||
therefore on your hot path, and it holds up. (2) `dispose()` restores sun/hemi but leaves `scene.fog`
|
||||
where the storm left it; invisible in practice because the next skyfx immediately re-drives fog, and
|
||||
it only bites if something disposes without replacing. Your call whether that's worth a line.
|
||||
Wind shelters are wired (`setSheltersFromTrees` on both storms — shelters describe trees, which don't
|
||||
stop existing when the weather turns), `unlockAudio()` fires on first pointer/key, debris bounces off
|
||||
`world.heightAt`, and all four of Lane E's crate/tub GLBs load into `setModels`.
|
||||
|
||||
[A] 2026-07-16 — 🔧 `SHADES.step(dt)` and `SHADES.render()` are exposed on the debug api. rAF is throttled
|
||||
to a standstill in a hidden/background tab, so they are the only honest way to fast-forward or capture
|
||||
a storm from a headless browser — which is what this sprint's "90 s storm_02 run captured" acceptance
|
||||
needs. Same code path the rAF loop uses; no test-only branch that can drift. Everything I reported
|
||||
above was measured through them.
|
||||
|
||||
[E] 2026-07-16 — 🐛 **LANE A — two of my "handles" were broken and are now fixed. Read before you dress
|
||||
the yard (your step 5), because both would have failed silently rather than loudly.**
|
||||
· **Canopy sway.** world.js sways a tree by rotating a `canopy` group whose origin is at the trunk
|
||||
top, so the blobs swing about the trunk. My trees shipped `canopy_01..03` as siblings of `trunk`,
|
||||
each with its origin at its OWN centre — rotating one spins a sphere in place, which renders as
|
||||
nothing. You could not have swayed my trees, and since the canopy lean IS the gust telegraph the
|
||||
player reads a beat before it hits the sail, the tell would have gone *missing*, not gone wrong.
|
||||
Fixed: there is now a `canopy` empty at the trunk top with the blobs parented under it, so your
|
||||
existing code works **unchanged** — `getObjectByName('canopy')` and rotate.
|
||||
· **`rake_pivot`.** Same trap, worse. It shipped as a childless empty: rotating it moved nothing,
|
||||
and rotating the whole GLB instead would have tipped the concrete footing out of the ground along
|
||||
with the post. Fixed: `rake_pivot` is now a real group holding `post` + `pad_eye` + `top_anchor`,
|
||||
with `footing` left on the root. Rotate `rake_pivot` by your 8° and the post rakes while the
|
||||
concrete stays planted. Asserted both ways in e.test.js (head must move >0.3 m, footing <0.01 m).
|
||||
Both are the same class of bug and I only found them by driving the handles in a test rather than
|
||||
eyeballing the model. If you add a handle to anything, rotate it in an assert.
|
||||
|
||||
[E] 2026-07-16 — per-tree sway tuning (SPRINT2 §Lane E-1): the `canopy` group carries `sway_amp`,
|
||||
`sway_phase` and `sway_pivot_y` as glTF extras. gum_01 is big and heavy-limbed at amp 0.85; gum_02 is
|
||||
whippy at 1.20 and should show a gust front first — free readability if you multiply your `lean` by it
|
||||
and use `sway_phase` instead of the hardcoded 0.7 / 2.9. Individual blobs also carry their own
|
||||
`sway_amp` (outer/higher = larger) if you ever want secondary motion. Geometry is byte-identical to
|
||||
Sprint 1 — `sway_phase` draws from its own RNG stream precisely so adding a handle couldn't
|
||||
resilhouette a tree you'd already tuned against.
|
||||
|
||||
[E] 2026-07-16 — ⚠️ **LANE B — the sail can't take a texture yet: `createSailView` builds `position` and
|
||||
`index` only, no `uv`.** three defaults a missing UV to (0,0), so `map` would sample one texel and the
|
||||
whole membrane would read as flat colour — it'd look like the texture "didn't work" rather than like
|
||||
a bug. `sail_weave.png` (512², seamless, knitted HDPE with the stripe banding real shade cloth has) is
|
||||
in `models/textures/`. The recipe, against your `N*N` grid:
|
||||
const N = rig.N, uv = new Float32Array(N * N * 2);
|
||||
for (let j = 0, k = 0; j < N; j++)
|
||||
for (let i = 0; i < N; i++, k += 2) { uv[k] = i / (N - 1); uv[k + 1] = j / (N - 1); }
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||
const tex = await new THREE.TextureLoader().loadAsync('/world/models/textures/sail_weave.png');
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(6, 6); // ~6 tiles across a 5 m sail
|
||||
tex.colorSpace = THREE.SRGBColorSpace; // r175: colorSpace, not encoding
|
||||
mat.map = tex; // keep mat.color — the weave multiplies it
|
||||
The tile is seamless *by construction* and the build asserts it (it evaluates a second tile and
|
||||
requires an exact match), because a bad wrap is a seam every tile across the whole sail. Shout if you'd
|
||||
rather I ship it at a different density. `sail_tears.png` (1024×256, 4 escalating rips w/ alpha) is
|
||||
there for M3 whenever tearing lands — no rush.
|
||||
|
||||
[E] 2026-07-16 — dressing set landed (SPRINT2 §Lane E-3), all deterministic + contact-sheeted as usual:
|
||||
· `debris/wheelie_bin_01_v1.glb` — 240 L kerbside bin, 0.58×0.68×1.12 m, `mass_hint` 12 (empty; a
|
||||
full one doesn't blow over). `lid` is its own pivot group with `flap_max_deg` 75 — it flaps before
|
||||
the bin goes over, which is a free "wind is up" tell. Lane C: it's in debris/, so your glob has it.
|
||||
· `washing_line_01_v1.glb` — a Hills Hoist, 2.84×2.84×2.28 m. `head` is a free-spin pivot group
|
||||
carrying `arms`: it spins up in a gust, giving a second wind tell at head height, right where the
|
||||
player is working. Not debris — it's cemented in.
|
||||
· `garden_gnome_01_v1.glb` — 0.36 m, `mass_hint` 4.5, `breakable`, `collateral_value` 25. Scoring
|
||||
bait per DESIGN.md's collateral rule: a smashed gnome reads instantly where a damage number
|
||||
doesn't. Lane A, he wants to be somewhere a flogging sail can reach him.
|
||||
|
||||
[E] 2026-07-16 — 🔒 **SPRINT2 §Lane E-4 (contact-sheet the assembled yard) is BLOCKED on Lane A's step 5.**
|
||||
Checked main at de86aa1: `world.js` and `main.js` contain zero `_v1.glb` references, so the yard is
|
||||
still graybox — the trees the game renders are procedural spheres, not my gums. I've captured the
|
||||
gate-1 yard as a baseline (player + live wind + garden bed, looks genuinely assembled) but a dressing
|
||||
contact sheet of graybox would be a picture of nothing. **Ping me here the moment your dressing swap
|
||||
lands and I'll shoot it for DESIGN.md same session.** Everything you need is above; nothing of mine is
|
||||
blocking you.
|
||||
|
||||
[E] 2026-07-16 — 👀 art note for whoever owns lighting (A?), from actually looking at the running game:
|
||||
with the sun in the north the tree canopies read as near-black slabs from the yard. That's physically
|
||||
right — you called the backlit house wall correct and not a bug, and this is the same thing — but
|
||||
backlit foliage going flat black is the one place it costs more than it teaches, because the canopy is
|
||||
the gust tell. Cheap fixes in your court: lift the hemisphere light's ground colour, or I can bake a
|
||||
little emissive into the leaf material so gums stay readable from underneath. Say the word and it's
|
||||
one constant in my palette — not touching it unprompted since lighting is yours.
|
||||
|
||||
@ -1,188 +1,6 @@
|
||||
{
|
||||
"blender": "5.1.2",
|
||||
"assets": [
|
||||
{
|
||||
"name": "ref_capsule",
|
||||
"dims": [
|
||||
0.4,
|
||||
0.4,
|
||||
1.7
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"head_height",
|
||||
"ref_capsule",
|
||||
"ref_capsule_mesh"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_gum_01",
|
||||
"dims": [
|
||||
4.5522,
|
||||
4.956,
|
||||
7.9702
|
||||
],
|
||||
"tris": 396,
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"branch_anchor_03",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"canopy_03",
|
||||
"tree_gum_01",
|
||||
"trunk"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_gum_02",
|
||||
"dims": [
|
||||
3.8871,
|
||||
2.7787,
|
||||
5.4972
|
||||
],
|
||||
"tris": 288,
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"tree_gum_02",
|
||||
"trunk"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_post",
|
||||
"dims": [
|
||||
0.13,
|
||||
0.13,
|
||||
2.03
|
||||
],
|
||||
"tris": 24,
|
||||
"nodes": [
|
||||
"fence_post",
|
||||
"post"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_panel",
|
||||
"dims": [
|
||||
2.4,
|
||||
0.054,
|
||||
1.8194
|
||||
],
|
||||
"tris": 324,
|
||||
"nodes": [
|
||||
"fence_panel",
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "gate",
|
||||
"dims": [
|
||||
1.045,
|
||||
0.0615,
|
||||
1.75
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"gate",
|
||||
"gate_frame",
|
||||
"gate_palings",
|
||||
"hinge_axis",
|
||||
"hinges"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "house_yardside",
|
||||
"dims": [
|
||||
9.2,
|
||||
1.0547,
|
||||
2.9
|
||||
],
|
||||
"tris": 200,
|
||||
"nodes": [
|
||||
"door",
|
||||
"fascia",
|
||||
"fascia_anchor_01",
|
||||
"fascia_anchor_02",
|
||||
"fascia_anchor_03",
|
||||
"gutter",
|
||||
"house_yardside",
|
||||
"roof",
|
||||
"wall",
|
||||
"window"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_01",
|
||||
"dims": [
|
||||
2.58,
|
||||
1.9708,
|
||||
2.2224
|
||||
],
|
||||
"tris": 96,
|
||||
"nodes": [
|
||||
"door_anchor",
|
||||
"doors",
|
||||
"roof",
|
||||
"shed_01",
|
||||
"shell"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_table",
|
||||
"dims": [
|
||||
1.6,
|
||||
0.6,
|
||||
0.9
|
||||
],
|
||||
"tris": 72,
|
||||
"nodes": [
|
||||
"pickup_anchor",
|
||||
"shed_table",
|
||||
"table_frame",
|
||||
"table_top"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_bed",
|
||||
"dims": [
|
||||
3.0,
|
||||
1.2,
|
||||
0.8609
|
||||
],
|
||||
"tris": 2580,
|
||||
"nodes": [
|
||||
"bed",
|
||||
"garden_bed",
|
||||
"plants_dead",
|
||||
"plants_full",
|
||||
"plants_tattered",
|
||||
"soil"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "sail_post",
|
||||
"dims": [
|
||||
@ -201,128 +19,7 @@
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "ladder_01",
|
||||
"dims": [
|
||||
0.455,
|
||||
0.075,
|
||||
3.0
|
||||
],
|
||||
"tris": 276,
|
||||
"nodes": [
|
||||
"ladder",
|
||||
"ladder_01",
|
||||
"ladder_base",
|
||||
"ladder_top"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shackle",
|
||||
"dims": [
|
||||
0.0569,
|
||||
0.019,
|
||||
0.0744
|
||||
],
|
||||
"tris": 560,
|
||||
"nodes": [
|
||||
"bow",
|
||||
"pin",
|
||||
"shackle"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "carabiner",
|
||||
"dims": [
|
||||
0.049,
|
||||
0.009,
|
||||
0.1027
|
||||
],
|
||||
"tris": 476,
|
||||
"nodes": [
|
||||
"body",
|
||||
"carabiner",
|
||||
"gate"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "turnbuckle",
|
||||
"dims": [
|
||||
0.0292,
|
||||
0.0341,
|
||||
0.1955
|
||||
],
|
||||
"tris": 728,
|
||||
"nodes": [
|
||||
"body",
|
||||
"eye_a",
|
||||
"eye_b",
|
||||
"turnbuckle"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tramp_01",
|
||||
"dims": [
|
||||
2.9555,
|
||||
2.9555,
|
||||
0.78
|
||||
],
|
||||
"tris": 976,
|
||||
"nodes": [
|
||||
"legs",
|
||||
"mat",
|
||||
"pad",
|
||||
"rim",
|
||||
"tramp_01"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": [
|
||||
{
|
||||
"file": "BlueCrate_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.36,
|
||||
0.29
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "BlackTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WhiteTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WoodenBin_v2.glb",
|
||||
"dims": [
|
||||
0.35,
|
||||
0.36,
|
||||
0.31
|
||||
],
|
||||
"sane": true
|
||||
}
|
||||
]
|
||||
"debris": []
|
||||
}
|
||||
@ -110,7 +110,13 @@ PAL = {
|
||||
"plant_full": "#5F8A3E",
|
||||
"plant_tatty": "#7A8446",
|
||||
"plant_dead": "#8A7550",
|
||||
"mat_black": "#2E2E30", # trampoline mat
|
||||
"mat_black": "#2E2E30", # trampoline mat, bin wheels
|
||||
"bin_green": "#3F5B44", # kerbside wheelie bin
|
||||
"bin_lid": "#C4A63A", # recycling-yellow lid
|
||||
"line_white": "#DCD9CF", # clothes line, gnome beard
|
||||
"gnome_skin": "#E0A986",
|
||||
"gnome_coat": "#3E6FA8",
|
||||
"gnome_hat": "#B33C36",
|
||||
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
||||
}
|
||||
|
||||
@ -252,6 +258,20 @@ def add_tube_between(name, p0, p1, radius, material, parent=None, verts=8):
|
||||
return obj
|
||||
|
||||
|
||||
def parent_keep_transform(child, parent):
|
||||
"""Blender's Ctrl+P "Keep Transform": reparent without moving the child.
|
||||
|
||||
Everything else in this script keeps its root empty at the origin so that
|
||||
`obj.parent = root` needs no parent-inverse juggling. The canopy handle is
|
||||
the one exception — its pivot has to sit at the trunk top — so the blobs need
|
||||
the inverse or they leap upward by the trunk height on parenting.
|
||||
"""
|
||||
bpy.context.view_layer.update()
|
||||
child.parent = parent
|
||||
child.matrix_parent_inverse = parent.matrix_world.inverted()
|
||||
return child
|
||||
|
||||
|
||||
def add_empty(name, location=(0, 0, 0), parent=None, size=0.15):
|
||||
bpy.ops.object.empty_add(type='PLAIN_AXES', location=location)
|
||||
obj = _active()
|
||||
@ -403,7 +423,8 @@ def build_ref_capsule(name):
|
||||
return root
|
||||
|
||||
|
||||
def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
|
||||
def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name,
|
||||
sway_amp=1.0):
|
||||
"""Eucalypt: pale chalky trunk, sparse olive canopy, low branches that a
|
||||
landscaper would actually strap a sail to."""
|
||||
rng = rng_for(seed_name)
|
||||
@ -442,8 +463,23 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
|
||||
|
||||
join_group(trunk_parts, "trunk", root)
|
||||
|
||||
# Canopy: separate nodes — Lane A sways these, and only these.
|
||||
# Canopy. `canopy` is the SWAY HANDLE: an empty at the trunk top that world.js
|
||||
# rotates, with the blobs hanging off it as children so they swing about the
|
||||
# trunk the way a real canopy does. Parenting them to the root instead — which
|
||||
# is what shipped in Sprint 1 — leaves each blob's pivot at its own centre, so
|
||||
# a lean just spins a sphere in place and the tree never visibly moves. The
|
||||
# canopy lean IS the gust telegraph the player reads (world.js), so a canopy
|
||||
# that can't sway silently costs the game its tell. Asserted in e.test.js.
|
||||
top = (lean * trunk_h, 0, trunk_h)
|
||||
canopy_grp = add_empty("canopy", top, root, size=0.6)
|
||||
canopy_grp["sway_amp"] = sway_amp # per-tree lean multiplier
|
||||
# Own RNG stream on purpose: drawing sway_phase from `rng` would consume a
|
||||
# value and shift every blob draw after it, silently reshaping a tree the
|
||||
# other lanes have already tuned against. Adding a handle must not move
|
||||
# geometry.
|
||||
canopy_grp["sway_phase"] = round(rng_for(f"{seed_name}:sway").uniform(0, math.tau), 3)
|
||||
canopy_grp["sway_pivot_y"] = round(trunk_h, 3)
|
||||
|
||||
for i in range(canopy_blobs):
|
||||
ang = math.tau * i / canopy_blobs + rng.uniform(-0.3, 0.3)
|
||||
off = spread * rng.uniform(0.10, 0.24)
|
||||
@ -453,9 +489,10 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
|
||||
r = spread * rng.uniform(0.24, 0.32)
|
||||
blob = add_ico(f"canopy_{i + 1:02d}", r, (cx, cy, cz),
|
||||
leaf_a if i % 2 == 0 else leaf_b,
|
||||
parent=root, subdiv=2,
|
||||
scale=(1.0, 1.0, rng.uniform(0.55, 0.75)),
|
||||
subdiv=2, scale=(1.0, 1.0, rng.uniform(0.55, 0.75)),
|
||||
jitter=r * 0.10, rng=rng)
|
||||
parent_keep_transform(blob, canopy_grp)
|
||||
# Secondary motion if Lane A wants it: outer/higher blobs travel further.
|
||||
blob["sway_amp"] = round(0.6 + 0.4 * (cz / height), 3)
|
||||
|
||||
# branch_anchor_* — what Lane B queries. Empties, at the limb tips.
|
||||
@ -471,13 +508,17 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
|
||||
|
||||
|
||||
def build_tree_gum_01(name):
|
||||
# Big, heavy-limbed: leans less for the same wind.
|
||||
return _gum_tree(name, height=8.4, canopy_blobs=3, spread=6.0,
|
||||
anchor_heights=[2.6, 3.4, 4.3], seed_name=name)
|
||||
anchor_heights=[2.6, 3.4, 4.3], seed_name=name,
|
||||
sway_amp=0.85)
|
||||
|
||||
|
||||
def build_tree_gum_02(name):
|
||||
# Smaller and whippier — it should show a gust front first.
|
||||
return _gum_tree(name, height=5.6, canopy_blobs=2, spread=4.4,
|
||||
anchor_heights=[2.3, 3.1], seed_name=name)
|
||||
anchor_heights=[2.3, 3.1], seed_name=name,
|
||||
sway_amp=1.20)
|
||||
|
||||
|
||||
def build_fence_post(name):
|
||||
@ -788,26 +829,40 @@ def build_sail_post(name):
|
||||
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
H, R = 4.0, 0.048
|
||||
|
||||
# The footing is cast into the ground and stays put — only the post rakes.
|
||||
join_group([add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc,
|
||||
verts=14),
|
||||
add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc,
|
||||
verts=14)], "footing", root)
|
||||
join_group([add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12),
|
||||
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark,
|
||||
verts=12),
|
||||
add_cyl(f"{name}_cap", R * 1.15, 0.02, (0, 0, H), dark,
|
||||
verts=12)], "post", root)
|
||||
# Pad eye at the head — where the corner chain actually clips on.
|
||||
join_group([add_box(f"{name}_padeye", (0.012, 0.07, 0.09),
|
||||
(0, 0, H - 0.10), dark),
|
||||
add_arc_tube(f"{name}_eye", 0.026, 0.008, 0, math.tau, dark,
|
||||
segs=10, center=(0, 0, H - 0.02), plane='XZ')],
|
||||
"pad_eye", root)
|
||||
|
||||
e = add_empty("top_anchor", (0, 0, H - 0.02), root, size=0.2)
|
||||
# rake_pivot is a GROUP, not a marker. Everything above the footing hangs off
|
||||
# it, so rotating it rakes the post while the concrete stays level in the
|
||||
# ground. Shipping it as a childless empty (as Sprint 1 did) means rotating
|
||||
# it moves nothing, and rotating the whole GLB instead tips the footing out
|
||||
# of the dirt with it. Same trap as the canopy handle. Asserted in e.test.js.
|
||||
rake = add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25)
|
||||
rake["rake_axis"] = "x/z — rake AWAY from the load (DESIGN.md)"
|
||||
rake["rake_default_deg"] = 8
|
||||
|
||||
above = []
|
||||
above.append(join_group([
|
||||
add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12),
|
||||
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12),
|
||||
add_cyl(f"{name}_cap", R * 1.15, 0.02, (0, 0, H), dark, verts=12),
|
||||
], "post"))
|
||||
# Pad eye at the head — where the corner chain actually clips on.
|
||||
above.append(join_group([
|
||||
add_box(f"{name}_padeye", (0.012, 0.07, 0.09), (0, 0, H - 0.10), dark),
|
||||
add_arc_tube(f"{name}_eye", 0.026, 0.008, 0, math.tau, dark, segs=10,
|
||||
center=(0, 0, H - 0.02), plane='XZ'),
|
||||
], "pad_eye"))
|
||||
|
||||
e = add_empty("top_anchor", (0, 0, H - 0.02), size=0.2)
|
||||
e["anchor_type"] = "post"
|
||||
e["rating_hint"] = 0.9
|
||||
add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25)
|
||||
above.append(e)
|
||||
for o in above:
|
||||
parent_keep_transform(o, rake)
|
||||
stamp(root, name, "hardware")
|
||||
root["post_height"] = H
|
||||
root["rake_note"] = "rotate about rake_pivot; rake away from the load"
|
||||
@ -953,9 +1008,239 @@ def build_tramp_01(name):
|
||||
return root
|
||||
|
||||
|
||||
def build_wheelie_bin_01(name):
|
||||
"""240 L kerbside bin — 1.10 m, ~12 kg empty. The `lid` is its own node: it
|
||||
flaps before the bin goes over, which is a free tell that the wind is up."""
|
||||
root = add_empty(name)
|
||||
body_m = get_material("Mat_BinBody", PAL["bin_green"], 0.75)
|
||||
lid_m = get_material("Mat_BinLid", PAL["bin_lid"], 0.7)
|
||||
wheel_m = get_material("Mat_Rubber", PAL["mat_black"], 0.95)
|
||||
W, D, H = 0.58, 0.74, 1.02
|
||||
|
||||
body = [add_cone(f"{name}_shell", 0.40, 0.34, H, (0, 0, H / 2 + 0.06),
|
||||
body_m, verts=4, rot=(0, 0, math.radians(45)))]
|
||||
body.append(add_box(f"{name}_spine", (0.10, 0.06, H * 0.8),
|
||||
(0, D / 2 - 0.06, H * 0.5), body_m))
|
||||
join_group(body, "bin_body", root)
|
||||
|
||||
lid_pivot = (0, D / 2 - 0.10, H + 0.07)
|
||||
lid_grp = add_empty("lid", lid_pivot, root, size=0.2)
|
||||
lid = join_group([
|
||||
add_box(f"{name}_lid_plate", (W, D * 0.92, 0.035),
|
||||
(0, 0.02, H + 0.085), lid_m),
|
||||
add_box(f"{name}_lid_lip", (W, 0.04, 0.05), (0, -D / 2 + 0.10, H + 0.07),
|
||||
lid_m),
|
||||
], "lid_plate")
|
||||
parent_keep_transform(lid, lid_grp)
|
||||
lid_grp["flap_axis"] = "x"
|
||||
lid_grp["flap_max_deg"] = 75
|
||||
|
||||
wheels = [add_cyl(f"{name}_wheel_{sx}", 0.075, 0.05,
|
||||
(sx * (W / 2 - 0.06), D / 2 - 0.10, 0.075), wheel_m,
|
||||
verts=10, rot=(0, math.pi / 2, 0))
|
||||
for sx in (-1, 1)]
|
||||
join_group(wheels, "wheels", root)
|
||||
|
||||
stamp(root, name, "debris")
|
||||
root["mass_hint"] = 12.0 # empty; a full one does not blow over
|
||||
root["tumble_hint"] = "topples about the wheel axle first"
|
||||
return root
|
||||
|
||||
|
||||
def build_washing_line_01(name):
|
||||
"""A Hills Hoist. Australian back yards have exactly one, and it is the
|
||||
perfect storm prop: the `head` freewheels, so it spins up in a gust — a
|
||||
second wind tell, at head height, right where the player is working."""
|
||||
root = add_empty(name)
|
||||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
|
||||
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
line_m = get_material("Mat_Line", PAL["line_white"], 0.9)
|
||||
H, ARM = 2.05, 1.42
|
||||
|
||||
join_group([
|
||||
add_cyl(f"{name}_socket", 0.14, 0.10, (0, 0, 0.05), conc, verts=12),
|
||||
add_cyl(f"{name}_mast", 0.038, H, (0, 0, H / 2), steel, verts=10),
|
||||
], "mast", root)
|
||||
|
||||
# Everything above the collar spins.
|
||||
head = add_empty("head", (0, 0, H), root, size=0.4)
|
||||
head["spin_axis"] = "y"
|
||||
head["free_spin"] = True
|
||||
head["spin_hint"] = "freewheels; spin rate ~ wind speed"
|
||||
|
||||
parts = []
|
||||
for i in range(4):
|
||||
a = math.tau * i / 4
|
||||
tip = (math.cos(a) * ARM, math.sin(a) * ARM, H - 0.16)
|
||||
parts.append(add_tube_between(f"{name}_arm_{i}", (0, 0, H), tip, 0.018,
|
||||
steel, verts=6))
|
||||
parts.append(add_tube_between(f"{name}_stay_{i}", (0, 0, H + 0.22), tip,
|
||||
0.008, steel, verts=4))
|
||||
# Four courses of line between the arm tips.
|
||||
for ring in range(4):
|
||||
rr = ARM * (0.45 + 0.18 * ring)
|
||||
for i in range(4):
|
||||
a0, a1 = math.tau * i / 4, math.tau * (i + 1) / 4
|
||||
z = H - 0.16 + 0.02 * ring
|
||||
parts.append(add_tube_between(
|
||||
f"{name}_line_{ring}_{i}",
|
||||
(math.cos(a0) * rr, math.sin(a0) * rr, z),
|
||||
(math.cos(a1) * rr, math.sin(a1) * rr, z), 0.004, line_m, verts=3))
|
||||
spun = join_group(parts, "arms", None)
|
||||
parent_keep_transform(spun, head)
|
||||
|
||||
stamp(root, name, "prop")
|
||||
root["height"] = H
|
||||
return root
|
||||
|
||||
|
||||
def build_garden_gnome_01(name):
|
||||
"""37 cm of painted concrete. He is scoring bait: DESIGN.md's collateral rule
|
||||
wants something the player can fail to protect, and a smashed gnome reads
|
||||
instantly where a damage number does not."""
|
||||
root = add_empty(name)
|
||||
skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8)
|
||||
coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85)
|
||||
hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85)
|
||||
beard = get_material("Mat_Beard", PAL["line_white"], 0.9)
|
||||
base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
|
||||
parts = [
|
||||
add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10),
|
||||
add_cone(f"{name}_body", 0.072, 0.045, 0.16, (0, 0, 0.10), coat, verts=10),
|
||||
add_ico(f"{name}_head", 0.042, (0, 0, 0.205), skin, subdiv=2),
|
||||
add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (0, -0.020, 0.176),
|
||||
beard, verts=8, rot=(math.radians(14), 0, 0)),
|
||||
add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (0, 0.004, 0.295), hat,
|
||||
verts=10),
|
||||
add_ico(f"{name}_nose", 0.011, (0, -0.038, 0.208), skin, subdiv=1),
|
||||
]
|
||||
join_group(parts, "gnome", root)
|
||||
stamp(root, name, "prop")
|
||||
root["mass_hint"] = 4.5
|
||||
root["collateral_value"] = 25 # $ — Lane A's aftermath screen
|
||||
root["breakable"] = True
|
||||
return root
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9)
|
||||
# ============================================================================
|
||||
def save_png(arr, name):
|
||||
"""arr: (h, w, 4) float32 RGBA in 0..1, row 0 = BOTTOM (bpy's convention).
|
||||
Blender ships no PIL, so every texture here is numpy -> bpy's image API."""
|
||||
import numpy as np # noqa: F401
|
||||
h, w = arr.shape[0], arr.shape[1]
|
||||
os.makedirs(TEXTURES_DIR, exist_ok=True)
|
||||
out = os.path.join(TEXTURES_DIR, f"{name}.png")
|
||||
img = bpy.data.images.new(name, w, h, alpha=True)
|
||||
img.pixels.foreach_set(arr.reshape(-1))
|
||||
img.filepath_raw = out
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
bpy.data.images.remove(img)
|
||||
return out, os.path.getsize(out) // 1024
|
||||
|
||||
|
||||
def build_sail_textures():
|
||||
"""Shade-cloth weave + tear decals (SPRINT2 §Lane E-2).
|
||||
|
||||
sail_weave.png is SEAMLESS and meant to tile: every frequency is an integer
|
||||
number of cycles across the image, so the wrap is exact. Lane B sets
|
||||
wrapS/wrapT = RepeatWrapping and repeat ≈ (6,6) on a ~5 m sail.
|
||||
|
||||
Deliberately subtle — luminance rides in a narrow band so it multiplies the
|
||||
base colour rather than replacing it. A high-contrast weave reads as burlap,
|
||||
and this is knitted HDPE shade cloth.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
SIZE, K = 512, 64 # K threads across; 512/64 = 8 px per thread
|
||||
|
||||
def weave_lum(X, Y):
|
||||
# Over-under: in one checker cell the weft rides on top, in the next the
|
||||
# warp. Every frequency is an integer number of cycles across SIZE, which
|
||||
# is what makes the wrap exact.
|
||||
warp = 0.5 + 0.5 * np.cos(2 * np.pi * K * X / SIZE)
|
||||
weft = 0.5 + 0.5 * np.cos(2 * np.pi * K * Y / SIZE)
|
||||
over = (((X * K) // SIZE) + ((Y * K) // SIZE)) % 2 == 0
|
||||
knit = np.where(over, weft, warp)
|
||||
# The knit banding real shade cloth has, every 8th thread — the "UV stripe".
|
||||
stripe = 1.0 - 0.045 * ((((X * K) // SIZE) % 8) == 0)
|
||||
stripe *= 1.0 - 0.030 * ((((Y * K) // SIZE) % 8) == 0)
|
||||
# No per-pixel noise: at ±0.012 it was invisible, but it is incompressible
|
||||
# and took the PNG from 18 KB to 323 KB. The knit carries it alone.
|
||||
return np.clip((0.80 + 0.20 * knit) * stripe, 0.0, 1.0).astype(np.float32)
|
||||
|
||||
Y, X = np.mgrid[0:SIZE, 0:SIZE]
|
||||
lum = weave_lum(X, Y)
|
||||
|
||||
# Prove it tiles. Lane B is being told "RepeatWrapping, repeat ~(6,6)" — if
|
||||
# the wrap isn't exact that's a visible seam every tile across the whole sail,
|
||||
# so evaluating one tile to the right must reproduce this one exactly.
|
||||
Y2, X2 = np.mgrid[0:SIZE, SIZE:2 * SIZE]
|
||||
if not np.array_equal(lum, weave_lum(X2, Y2)):
|
||||
raise AssertionError("sail_weave is not seamless — it would seam on repeat")
|
||||
|
||||
weave = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
|
||||
weave[:, :, 0] = lum
|
||||
weave[:, :, 1] = lum
|
||||
weave[:, :, 2] = lum * 0.985 # a hair warm, so white cloth isn't clinical
|
||||
weave[:, :, 3] = 1.0
|
||||
p1, kb1 = save_png(weave, "sail_weave")
|
||||
print(f" sail_weave.png {SIZE}x{SIZE}, seamless, {K} threads, {kb1} KB")
|
||||
|
||||
# --- tear decals ------------------------------------------------------
|
||||
# A strip of 4, RGBA, alpha 0 everywhere but the rip. Overlay on a damaged
|
||||
# panel for M3. Each tear = a jagged slit with frayed threads pulling out of
|
||||
# both lips, because fabric fails along the weave, not in a clean line.
|
||||
TW, TH = 1024, 256
|
||||
cell = TH
|
||||
tears = np.zeros((TH, TW, 4), dtype=np.float32)
|
||||
|
||||
def stamp(px, x, y, rgb, a):
|
||||
xi, yi = int(round(x)), int(round(y))
|
||||
if px <= xi < px + cell and 0 <= yi < TH: # clip inside this decal's cell
|
||||
tears[yi, xi, 0:3] = rgb
|
||||
tears[yi, xi, 3] = a
|
||||
|
||||
# Four escalating rips. Each is a LENS, not a slit: fabric under tension
|
||||
# parts widest in the middle and tapers to a point at both ends. A
|
||||
# constant-width gap reads as a drawn line, which is what the first pass did.
|
||||
for c in range(4):
|
||||
r = rng_for(f"sail_tear_{c}")
|
||||
px = c * cell
|
||||
length = cell * (0.48 + 0.09 * c)
|
||||
max_gap = cell * (0.055 + 0.042 * c) # the 4th gapes ~4x the 1st
|
||||
x0 = px + (cell - length) / 2
|
||||
steps = int(length)
|
||||
yy = cell * 0.5
|
||||
lips = []
|
||||
for s in range(steps):
|
||||
t = s / max(1, steps - 1)
|
||||
yy = max(cell * 0.3, min(cell * 0.7, yy + r.uniform(-1.1, 1.1)))
|
||||
half = max_gap * (math.sin(math.pi * t) ** 0.7)
|
||||
jag = r.uniform(-0.08, 0.08) * max_gap # ragged, not spiky
|
||||
top, bot = yy - half + jag, yy + half + jag
|
||||
for y in np.arange(top, bot, 0.5):
|
||||
stamp(px, x0 + s, y, (0.10, 0.09, 0.08), 1.0) # the gap
|
||||
if half > 1.5:
|
||||
lips.append((x0 + s, top, +1, half)) # +1 = toward the gap
|
||||
lips.append((x0 + s, bot, -1, half))
|
||||
# Threads pulling off both lips and bridging the gap. These are the tell:
|
||||
# without them a lens of dark pixels is a hole, not a tear. Length scales
|
||||
# with the LOCAL gap so some strands span it completely.
|
||||
for _ in range(int(55 + c * 30)):
|
||||
x, y, into, half = lips[r.randrange(len(lips))]
|
||||
span = half * r.uniform(0.5, 1.9)
|
||||
for s in np.arange(0.0, span, 0.5):
|
||||
stamp(px, x + r.uniform(-0.6, 0.6), y + into * (s + 1.0),
|
||||
(0.82, 0.76, 0.62), 1.0)
|
||||
p2, kb2 = save_png(tears, "sail_tears")
|
||||
print(f" sail_tears.png {TW}x{TH}, 4 decals, alpha, {kb2} KB")
|
||||
return [p1, p2]
|
||||
|
||||
|
||||
def build_grass_atlas():
|
||||
"""4-tuft billboard atlas, 2x2 cells. Drawn with numpy (no PIL in Blender's
|
||||
python) and saved through bpy's image API. Lane A instances quads with this."""
|
||||
@ -997,16 +1282,8 @@ def build_grass_atlas():
|
||||
blade(px, py, base_x, py + 2, h, lean,
|
||||
cell * rng.uniform(0.012, 0.022), rgb)
|
||||
|
||||
os.makedirs(TEXTURES_DIR, exist_ok=True)
|
||||
out = os.path.join(TEXTURES_DIR, "grass_atlas.png")
|
||||
bimg = bpy.data.images.new("grass_atlas", SIZE, SIZE, alpha=True)
|
||||
bimg.pixels.foreach_set(img.reshape(-1))
|
||||
bimg.filepath_raw = out
|
||||
bimg.file_format = 'PNG'
|
||||
bimg.save()
|
||||
bpy.data.images.remove(bimg)
|
||||
kb = os.path.getsize(out) // 1024
|
||||
print(f" grass_atlas.png {SIZE}x{SIZE}, {CELLS*CELLS} tufts, {kb} KB")
|
||||
out, kb = save_png(img, "grass_atlas")
|
||||
print(f" grass_atlas.png {SIZE}x{SIZE}, {CELLS * CELLS} tufts, {kb} KB")
|
||||
return out
|
||||
|
||||
|
||||
@ -1020,11 +1297,11 @@ ASSETS = [
|
||||
nodes=["ref_capsule_mesh", "head_height"]),
|
||||
dict(name="tree_gum_01", fn=build_tree_gum_01,
|
||||
dims=((3.0, 7.5), (3.0, 7.5), (7.5, 9.5)),
|
||||
nodes=["trunk", "canopy_01", "canopy_02", "canopy_03",
|
||||
nodes=["trunk", "canopy", "canopy_01", "canopy_02", "canopy_03",
|
||||
"branch_anchor_01", "branch_anchor_02", "branch_anchor_03"]),
|
||||
dict(name="tree_gum_02", fn=build_tree_gum_02,
|
||||
dims=((2.0, 5.5), (2.0, 5.5), (5.0, 6.5)),
|
||||
nodes=["trunk", "canopy_01", "canopy_02",
|
||||
nodes=["trunk", "canopy", "canopy_01", "canopy_02",
|
||||
"branch_anchor_01", "branch_anchor_02"]),
|
||||
dict(name="fence_post", fn=build_fence_post,
|
||||
dims=((0.10, 0.16), (0.10, 0.16), (1.95, 2.10)),
|
||||
@ -1064,10 +1341,19 @@ ASSETS = [
|
||||
dict(name="turnbuckle", fn=build_turnbuckle,
|
||||
dims=((0.015, 0.05), (0.015, 0.05), (0.12, 0.20)),
|
||||
nodes=["body", "eye_a", "eye_b"]),
|
||||
# Lands in models/debris/ — Lane C spawns debris from that directory.
|
||||
# These land in models/debris/ — Lane C globs that directory to spawn from.
|
||||
dict(name="tramp_01", fn=build_tramp_01, dir=DEBRIS_DIR,
|
||||
dims=((2.8, 3.1), (2.8, 3.1), (0.70, 0.85)),
|
||||
nodes=["mat", "rim", "pad", "legs"]),
|
||||
dict(name="wheelie_bin_01", fn=build_wheelie_bin_01, dir=DEBRIS_DIR,
|
||||
dims=((0.50, 0.70), (0.65, 0.85), (1.00, 1.20)),
|
||||
nodes=["bin_body", "lid", "lid_plate", "wheels"]),
|
||||
dict(name="washing_line_01", fn=build_washing_line_01,
|
||||
dims=((2.7, 3.1), (2.7, 3.1), (2.0, 2.4)),
|
||||
nodes=["mast", "head", "arms"]),
|
||||
dict(name="garden_gnome_01", fn=build_garden_gnome_01,
|
||||
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
|
||||
nodes=["gnome"]),
|
||||
]
|
||||
|
||||
|
||||
@ -1382,6 +1668,7 @@ def main():
|
||||
build_all(only)
|
||||
reset_to_empty()
|
||||
build_grass_atlas()
|
||||
build_sail_textures()
|
||||
debris = [] if no_debris else copy_debris()
|
||||
|
||||
failures = []
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 191 KiB |
@ -46,6 +46,21 @@ CLIPS = [
|
||||
("Falling", f"{FBX}/Falling.fbx"), # limb flail; player.js pitches the root itself
|
||||
("CrouchToStand", f"{FBX}/Crouch To Stand.fbx"),
|
||||
("Reaction", f"{FBX}/Reaction.fbx"), # stagger on a debris glance
|
||||
# --- M3 verbs, fetched from Mixamo 2026-07-16 (see tools/character/mixamo_wishlist.txt).
|
||||
# Substitutions where Mixamo has no such clip: Turning Key → Pulling Lever,
|
||||
# Standing Up Ready → Standing Up, Covering Head → Taking Cover.
|
||||
# Hammering / Sweeping Floor / Bracing don't exist on Mixamo — skipped.
|
||||
("ClimbLadder", f"{FBX}/Climbing Ladder.fbx"),
|
||||
("Crank", f"{FBX}/Pulling Lever.fbx"), # turnbuckle work
|
||||
("Dig", f"{FBX}/Digging.fbx"),
|
||||
("PickUp", f"{FBX}/Picking Up Object.fbx"),
|
||||
("Carry", f"{FBX}/Carrying.fbx"),
|
||||
("CarryTurn", f"{FBX}/Carrying Turn.fbx"),
|
||||
("CarryIdle", f"{FBX}/Box Idle.fbx"),
|
||||
("StandUp", f"{FBX}/Standing Up.fbx"),
|
||||
("TakeCover", f"{FBX}/Taking Cover.fbx"), # hail/debris shelter
|
||||
("StumbleBack", f"{FBX}/Stumble Backwards.fbx"),
|
||||
("PlantSeeds", f"{FBX}/Dig And Plant Seeds.fbx"),# garden repair verb
|
||||
]
|
||||
|
||||
PREFIX_RE = re.compile(r"mixamorig\d*:")
|
||||
|
||||
43
tools/character/mixamo_wishlist.txt
Normal file
43
tools/character/mixamo_wishlist.txt
Normal file
@ -0,0 +1,43 @@
|
||||
# SHADES animation wishlist — Mixamo search terms, one per line. (Lane D, for M3)
|
||||
#
|
||||
# Same pipeline as 90sDJsim's dj_wishlist.txt and the op-shop clerk's wishlist.txt:
|
||||
# cd ~/Documents/mixamo-fetch
|
||||
# node fetch.cjs login # a real browser opens; John logs in by hand, once
|
||||
# cp <this file> ./shades_wishlist.txt
|
||||
# node fetch.cjs anims # loops the wishlist, downloads each "Without Skin"
|
||||
# then add the new FBX to CLIPS in tools/character/build_player_anims.py and rebuild the pack.
|
||||
#
|
||||
# NOTE: fetch.cjs needs a MANUAL Google login in a real browser window (its README is explicit that
|
||||
# Claude never sees the password), so this list is queued for John to run — not something a lane
|
||||
# fetches on its own. Everything M0-M2 needs is already on disk and already in player_anims.glb:
|
||||
# Idle / Walk / Run / Falling / CrouchToStand / Reaction.
|
||||
#
|
||||
# '#' and blank lines ignored. Names are fuzzy search terms (top hit wins); a miss is skipped.
|
||||
# In-place clips only — the game translates the player itself, and player.js keeps rotation tracks
|
||||
# only, so anything with big root travel is wasted.
|
||||
|
||||
## --- M3: the repair verbs (PLAN3D §5-D.1) ---
|
||||
Climbing Ladder
|
||||
Turning Key
|
||||
Digging
|
||||
|
||||
## --- M3: worth having while the browser is open (DESIGN.md "limited hands" toolset) ---
|
||||
Hammering
|
||||
Sweeping Floor
|
||||
Picking Up Object
|
||||
Carrying Box
|
||||
Standing Up Ready
|
||||
|
||||
## --- storm reactions (better than reusing Reaction for everything) ---
|
||||
Covering Head
|
||||
Bracing
|
||||
Stumble Backwards
|
||||
|
||||
## --- FETCH LOG 2026-07-16 (fetched via browser session on m3ultra, John's Mixamo login) ---
|
||||
# Landed in ~/Documents/FBX/ on the M1 and baked into player_anims.glb (17 clips total):
|
||||
# Climbing Ladder ✓ · Pulling Lever ✓ (sub for Turning Key — no such clip on Mixamo)
|
||||
# Digging ✓ · Dig And Plant Seeds ✓ (bonus, garden verb) · Picking Up Object ✓
|
||||
# Carrying ✓ + Carrying Turn ✓ + Box Idle ✓ (the Carrying Box family)
|
||||
# Standing Up ✓ (sub for Standing Up Ready) · Taking Cover ✓ (sub for Covering Head)
|
||||
# Stumble Backwards ✓
|
||||
# Genuinely absent from Mixamo, skipped: Hammering · Sweeping Floor · Bracing
|
||||
29
web/world/data/storms/storm_01_gentle.json
Normal file
29
web/world/data/storms/storm_01_gentle.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "Sea Breeze",
|
||||
"blurb": "Fresh afternoon breeze, chance of a light shower. Good day to test a rig.",
|
||||
"rating": 1,
|
||||
"seed": 1017,
|
||||
"duration": 90,
|
||||
|
||||
"baseCurve": [[0, 3.0], [20, 5.0], [50, 6.5], [75, 6.0], [90, 5.5]],
|
||||
|
||||
"gusts": {
|
||||
"firstAt": 4,
|
||||
"minGap": 6,
|
||||
"maxGap": 14,
|
||||
"powBase": 2,
|
||||
"powRand": 3,
|
||||
"powRamp": 2
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],
|
||||
"dirWander": { "amp": 0.35, "rate": 0.09 },
|
||||
|
||||
"spatial": { "amp": 0.15, "scale": 12, "advect": 0.5 },
|
||||
|
||||
"events": [],
|
||||
|
||||
"rain": { "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] },
|
||||
|
||||
"sky": { "darkness": 0.15, "cloudScroll": 0.02 }
|
||||
}
|
||||
41
web/world/data/storms/storm_02_wildnight.json
Normal file
41
web/world/data/storms/storm_02_wildnight.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "Wild Night",
|
||||
"blurb": "Front crossing after dark. Southerly change around the hour mark, damaging gusts. Rig for the swing, not the lull.",
|
||||
"rating": 4,
|
||||
"seed": 20260716,
|
||||
"duration": 90,
|
||||
|
||||
"_comment": "Sustained builds 7 -> 20 m/s (25 -> 72 km/h); worst gusts land near 33 m/s (~120 km/h), which is BOM 'destructive' and is what shreds a flat drum-tight cheap rig. Peak arrives just AFTER the southerly change, so the corners that were slack all storm are the ones that cop it.",
|
||||
|
||||
"_dirCurve_comment": "Radians in the XZ plane; the wind blows TOWARD (cos d, sin d). contracts.js puts north at -Z, so a southerly (blowing toward the north, into the house) needs sin(d) < 0. Starts ~0.85 = blowing toward the SE, i.e. the hot NW'er before a change; slews to ~-1.35 = blowing toward the NNE, i.e. a SSW buster off the open south side of the yard. 55->59s is the slew: ~110 deg in four seconds.",
|
||||
|
||||
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
|
||||
|
||||
"gusts": {
|
||||
"firstAt": 3,
|
||||
"minGap": 5.5,
|
||||
"maxGap": 11,
|
||||
"powBase": 3,
|
||||
"powRand": 5,
|
||||
"powRamp": 7
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
||||
"dirWander": { "amp": 0.25, "rate": 0.13 },
|
||||
|
||||
"spatial": { "amp": 0.2, "scale": 11, "advect": 0.5 },
|
||||
|
||||
"events": [
|
||||
{ "t": 38, "type": "debris", "model": "BlueCrate_v2", "lateral": -3.5, "mass": 9, "text": "a crate comes through the fence line" },
|
||||
{ "t": 52, "type": "lightning", "power": 0.7 },
|
||||
{ "t": 55, "type": "windchange", "telegraph": 6, "over": 6, "text": "the wind swings around" },
|
||||
{ "t": 64, "type": "lightning", "power": 1.0 },
|
||||
{ "t": 66, "type": "debris", "model": "BlackTub_v2", "lateral": 2.0, "mass": 5, "text": "someone's tub is airborne" },
|
||||
{ "t": 74, "type": "debris", "model": "WoodenBin_v2", "lateral": -1.0, "mass": 14, "text": "the neighbour's bin lets go" },
|
||||
{ "t": 79, "type": "lightning", "power": 0.5 }
|
||||
],
|
||||
|
||||
"rain": { "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
|
||||
|
||||
"sky": { "darkness": 0.8, "cloudScroll": 0.09 }
|
||||
}
|
||||
203
web/world/dev_player.html
Normal file
203
web/world/dev_player.html
Normal file
@ -0,0 +1,203 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SHADES — Lane D player harness</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #6f7f8c; overflow: hidden; font: 12px/1.5 ui-monospace, Menlo, monospace; }
|
||||
canvas { display: block; }
|
||||
#hud { position: fixed; top: 8px; left: 8px; color: #fff; text-shadow: 0 1px 2px #000; white-space: pre; pointer-events: none; }
|
||||
#panel { position: fixed; top: 8px; right: 8px; color: #fff; text-shadow: 0 1px 2px #000; text-align: right; }
|
||||
#panel button { font: inherit; margin: 1px; }
|
||||
#panel input { vertical-align: middle; }
|
||||
#prompt { position: fixed; left: 50%; bottom: 64px; transform: translateX(-50%); color: #fff;
|
||||
text-shadow: 0 1px 3px #000; font-size: 15px; text-align: center; pointer-events: none; }
|
||||
#bar { width: 160px; height: 5px; background: #0006; margin: 5px auto 0; border-radius: 3px; overflow: hidden; }
|
||||
#fill { height: 100%; width: 0; background: #ffd54a; }
|
||||
</style>
|
||||
<!--
|
||||
REQUIRED, don't delete: every vendored addon (GLTFLoader, SkeletonUtils, …) imports from the bare
|
||||
specifier 'three', so any page that pulls in player.js needs this map. index.html has no importmap
|
||||
yet because nothing there uses an addon — flagged for Lane A in THREADS.md, since the placeholder
|
||||
swap will need it too.
|
||||
-->
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<div id="hud"></div>
|
||||
<div id="panel">
|
||||
wind <input id="wind" type="range" min="0" max="40" step="0.5" value="4"> <span id="wv">4</span> m/s<br>
|
||||
dir <input id="dir" type="range" min="0" max="6.28" step="0.01" value="0"><br>
|
||||
<button id="gust">gust (+18, 1.6s)</button>
|
||||
<button id="knock">knockdown</button>
|
||||
<button id="stag">stagger</button>
|
||||
</div>
|
||||
<div id="prompt"></div>
|
||||
|
||||
<!--
|
||||
Lane D dev harness. NOT the game — Lane A owns index.html, world.js, camera.js, hud.js.
|
||||
Everything here that isn't player.js / interact.js is a throwaway mock standing in until M0 lands:
|
||||
the ground, the wind, the camera and the prompt UI. PLAN3D §0 says lanes develop against contracts
|
||||
+ mocks until Lane A's skeleton merges; this is that mock.
|
||||
-->
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { loadPlayer, KeyboardInput, STATES } from './js/player.js';
|
||||
import { Interact, wireYardActions } from './js/interact.js';
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('c'), antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x8fa6b6);
|
||||
scene.fog = new THREE.Fog(0x8fa6b6, 30, 90);
|
||||
|
||||
const cam = new THREE.PerspectiveCamera(55, 1, 0.1, 300);
|
||||
const resize = () => {
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
cam.aspect = innerWidth / innerHeight; cam.updateProjectionMatrix();
|
||||
};
|
||||
addEventListener('resize', resize); resize();
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xfff3e0, 2.4);
|
||||
sun.position.set(-8, 14, 6); sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(2048, 2048);
|
||||
Object.assign(sun.shadow.camera, { left: -18, right: 18, top: 18, bottom: -18, near: 1, far: 50 });
|
||||
scene.add(sun, new THREE.HemisphereLight(0xbfd8e8, 0x4a5a3a, 1.1));
|
||||
|
||||
// --- mock yard: 30x20 m, flat. Lane A's world.js replaces this (and gives real terrain height). ---
|
||||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(30, 20),
|
||||
new THREE.MeshLambertMaterial({ color: 0x6f8f4e }));
|
||||
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true;
|
||||
scene.add(ground);
|
||||
const grid = new THREE.GridHelper(30, 30, 0x33502a, 0x5d7a45);
|
||||
grid.position.y = 0.01; scene.add(grid);
|
||||
|
||||
// scale references: a 4 m sail post and a 1.7 m capsule. PLAN3D §5-D.1 wants the player to read
|
||||
// "small person" beside a 4 m post — this is how we check that by eye.
|
||||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.08, 4, 12),
|
||||
new THREE.MeshLambertMaterial({ color: 0xcfd4d8 }));
|
||||
post.position.set(-4, 2, -3); post.castShadow = true; scene.add(post);
|
||||
const capsule = new THREE.Mesh(new THREE.CapsuleGeometry(0.28, 1.7 - 0.56, 6, 12),
|
||||
new THREE.MeshLambertMaterial({ color: 0xd08a5a }));
|
||||
capsule.position.set(-2.6, 0.85, -3); capsule.castShadow = true; scene.add(capsule);
|
||||
|
||||
// interact fixtures: a shed table (pick up a spare) and a broken sail corner (re-rig)
|
||||
const table = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.06, 0.6),
|
||||
new THREE.MeshLambertMaterial({ color: 0x8a6b45 }));
|
||||
table.position.set(5, 0.8, 2); table.castShadow = true; scene.add(table);
|
||||
const cornerPos = new THREE.Vector3(0, 2.6, -5);
|
||||
const cornerDot = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 10),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff5252 }));
|
||||
cornerDot.position.copy(cornerPos); scene.add(cornerDot);
|
||||
|
||||
// --- mock wind, standing in for Lane C's weather.js wind.sample(pos,t) ---
|
||||
const windEl = document.getElementById('wind'), dirEl = document.getElementById('dir');
|
||||
const wv = document.getElementById('wv');
|
||||
let gustUntil = -1, gustAdd = 0;
|
||||
const wind = {
|
||||
sample(_pos, t) {
|
||||
const base = +windEl.value;
|
||||
const extra = t < gustUntil ? gustAdd : 0;
|
||||
const a = +dirEl.value;
|
||||
const s = base + extra;
|
||||
return new THREE.Vector3(Math.cos(a) * s, 0, Math.sin(a) * s);
|
||||
},
|
||||
};
|
||||
windEl.oninput = () => { wv.textContent = windEl.value; };
|
||||
|
||||
// --- player ---
|
||||
const { sim, view, step } = await loadPlayer(scene, {
|
||||
start: { x: 0, y: 0, z: 3 },
|
||||
height: 1.72,
|
||||
groundAt: () => 0, // Lane A's world.js supplies the real terrain height
|
||||
});
|
||||
const input = new KeyboardInput();
|
||||
|
||||
// --- interactions (the real thing: interact.js + wireYardActions) ---
|
||||
const interact = new Interact();
|
||||
const corner = { anchorId: 'post_nw', broken: true, load: 0, pos: cornerPos };
|
||||
wireYardActions(interact, {
|
||||
sailRig: {
|
||||
corners: [corner],
|
||||
repair: () => { corner.broken = false; cornerDot.material.color.set(0x4caf50); },
|
||||
trim: () => { cornerDot.scale.setScalar(cornerDot.scale.x * 1.08); },
|
||||
},
|
||||
world: { shedTable: { pos: table.position } },
|
||||
});
|
||||
|
||||
document.getElementById('gust').onclick = () => { gustAdd = 18; gustUntil = clock.t + 1.6; };
|
||||
document.getElementById('knock').onclick = () => sim.knockdown(clock.t, +windEl.value ? 1 : 0, 0);
|
||||
document.getElementById('stag').onclick = () => sim.staggerHit(clock.t);
|
||||
|
||||
// --- mock third-person camera. Lane A's camera.js replaces this; RMB-drag orbits. ---
|
||||
const orbit = { yaw: Math.PI, pitch: 0.28, dist: 6 };
|
||||
let drag = false;
|
||||
addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
addEventListener('mousedown', (e) => { if (e.button === 2) drag = true; });
|
||||
addEventListener('mouseup', () => { drag = false; });
|
||||
addEventListener('mousemove', (e) => {
|
||||
if (!drag) return;
|
||||
orbit.yaw -= e.movementX * 0.005;
|
||||
orbit.pitch = Math.max(-0.2, Math.min(1.1, orbit.pitch + e.movementY * 0.004));
|
||||
});
|
||||
addEventListener('wheel', (e) => { orbit.dist = Math.max(2.5, Math.min(14, orbit.dist + e.deltaY * 0.01)); });
|
||||
|
||||
const hud = document.getElementById('hud');
|
||||
const promptEl = document.getElementById('prompt');
|
||||
const DT = 1 / 60;
|
||||
const clock = { t: 0, acc: 0, last: performance.now() };
|
||||
|
||||
function frame(now) {
|
||||
requestAnimationFrame(frame);
|
||||
// rAF drives the VIEW; the sim is stepped at a fixed dt so it matches selftest exactly
|
||||
let elapsed = Math.min(0.25, (now - clock.last) / 1000);
|
||||
clock.last = now;
|
||||
clock.acc += elapsed;
|
||||
while (clock.acc >= DT) {
|
||||
clock.acc -= DT;
|
||||
clock.t += DT;
|
||||
step(DT, clock.t, input.read(orbit.yaw), wind);
|
||||
interact.step(DT, clock.t, sim, input.holding);
|
||||
}
|
||||
|
||||
// camera: shoulder-follow, orbits on RMB
|
||||
const h = 1.5;
|
||||
cam.position.set(
|
||||
sim.pos.x + Math.sin(orbit.yaw) * Math.cos(orbit.pitch) * orbit.dist,
|
||||
sim.pos.y + h + Math.sin(orbit.pitch) * orbit.dist,
|
||||
sim.pos.z + Math.cos(orbit.yaw) * Math.cos(orbit.pitch) * orbit.dist);
|
||||
cam.lookAt(sim.pos.x, sim.pos.y + 1.1, sim.pos.z);
|
||||
|
||||
const near = interact.nearest(sim);
|
||||
promptEl.innerHTML = near
|
||||
? `[E] ${interact.labelOf(near, sim)}<div id="bar"><div id="fill" style="width:${(interact.progress * 100).toFixed(0)}%"></div></div>`
|
||||
: '';
|
||||
|
||||
hud.textContent =
|
||||
`state ${sim.state}${sim.busy ? ' (busy)' : ''}\n` +
|
||||
`clip ${STATES[sim.state].clip}\n` +
|
||||
`speed ${sim.speed.toFixed(2)} m/s\n` +
|
||||
`pos ${sim.pos.x.toFixed(1)}, ${sim.pos.z.toFixed(1)}\n` +
|
||||
`wind ${sim.windSpeed.toFixed(1)} m/s (base ${sim.windBase.toFixed(1)}, gust ${sim.gust.toFixed(1)})\n` +
|
||||
`shove ${Math.hypot(sim.shove.x, sim.shove.z).toFixed(2)} m/s\n` +
|
||||
`exposure ${sim.exposure.toFixed(2)} / ${sim.tune.knockSustain}\n` +
|
||||
`pitch ${sim.pitch.toFixed(2)}\n` +
|
||||
`carrying ${sim.carrying || '—'}\n` +
|
||||
`bound ${view.clipNames.join(' ')}\n` +
|
||||
`\nWASD move · shift run · E hold · RMB orbit`;
|
||||
|
||||
renderer.render(scene, cam);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// expose for console poking / screenshot checks
|
||||
Object.assign(window, { sim, view, interact, scene, cam, orbit, clock, THREE });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -30,9 +30,19 @@
|
||||
<div id="dev">booting…</div>
|
||||
<div id="help">WASD move · shift run · RMB drag orbit · wheel zoom · Enter next phase</div>
|
||||
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "./vendor/three.module.js",
|
||||
"three/addons/": "./vendor/addons/" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import { boot } from './js/main.js';
|
||||
boot();
|
||||
// boot() is async now — it fetches two storm defs, the ped, the clip pack
|
||||
// and Lane E's debris GLBs. Surface a failure on the page rather than
|
||||
// letting it die as an unhandled rejection behind a blue screen.
|
||||
boot().catch((err) => {
|
||||
console.error(err);
|
||||
document.getElementById('dev').textContent = `BOOT FAILED — ${err.message} (see console)`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -38,16 +38,25 @@ export const SPARE_COST = 15;
|
||||
/**
|
||||
* Hardware tiers, ported from prototype/game.js.
|
||||
*
|
||||
* `rating` is nominal kN. The ABSOLUTE numbers are placeholders inherited from
|
||||
* the 2D prototype's load scale — Lane B owns retuning them against the 3D
|
||||
* cloth's real load output. What must survive retuning is the SHAPE: three
|
||||
* tiers, roughly 1x / 2x / 4.5x strength at 1x / 3x / 6x price, so a mixed rig
|
||||
* is always the interesting choice and one dodgy corner is always affordable.
|
||||
* `rating` is a working load limit in NEWTONS — retuned by Lane B against the
|
||||
* 3D cloth's real load output, per the standing note that Lane B owns these
|
||||
* numbers. Costs are the prototype's, untouched.
|
||||
*
|
||||
* The 2D prototype's 9/19/40 were on an arbitrary scale. The 3D cloth reports
|
||||
* real newtons (a 5x5 m sail pulls ~1-4 kN per corner in a 34 m/s storm), so
|
||||
* these are real WLLs: a cheap carabiner really does let go around 1.2 kN, a
|
||||
* rated 8 mm shackle really does hold 6.5 kN. That is the DESIGN.md "Kerbal
|
||||
* trick" — leave the game able to size real hardware.
|
||||
*
|
||||
* The SHAPE that had to survive retuning, and did: three tiers at 1x / 3x / 6x
|
||||
* price, where $80 buys rated hardware on at most two of four corners. A mixed
|
||||
* rig stays the interesting choice and you are always picking which corner to
|
||||
* leave dodgy. Asserted in js/tests/b.test.js.
|
||||
*/
|
||||
export const HARDWARE = [
|
||||
{ name: 'carabiner', cost: 5, rating: 9, color: 0xe2b04a },
|
||||
{ name: 'shackle', cost: 15, rating: 19, color: 0xc8d2d8 },
|
||||
{ name: 'rated shackle', cost: 30, rating: 40, color: 0x7ee0ff },
|
||||
{ name: 'carabiner', cost: 5, rating: 1200, color: 0xe2b04a },
|
||||
{ name: 'shackle', cost: 15, rating: 3200, color: 0xc8d2d8 },
|
||||
{ name: 'rated shackle', cost: 30, rating: 6500, color: 0x7ee0ff },
|
||||
];
|
||||
|
||||
/** Game phases, in loop order. */
|
||||
|
||||
254
web/world/js/debris.js
Normal file
254
web/world/js/debris.js
Normal file
@ -0,0 +1,254 @@
|
||||
'use strict';
|
||||
// SHADES — Lane C — debris: things that should have been tied down.
|
||||
//
|
||||
// Hand-rolled kinematic tumble (PLAN3D §5-C.4). No physics engine, no deps.
|
||||
// Deterministic: step(dt, t), seeded RNG, no Date.now — so a storm replays.
|
||||
//
|
||||
// Spawns off `debris` events in the storm JSON, upwind of the yard, and lets the
|
||||
// wind field carry it across. Drag goes with speed², same as the sail, so the
|
||||
// same gust that spikes a corner is the one that launches the neighbour's bin.
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { rng } from './contracts.js';
|
||||
|
||||
const RHO = 1.2; // air density, kg/m³
|
||||
const GRAVITY = -9.81;
|
||||
// Deceleration while resting on the ground, m/s². Drag in a 19 m/s wind gives a
|
||||
// 9 kg crate ~7 m/s², so it still skitters downwind — which is the whole point.
|
||||
const GROUND_FRICTION = 3.5;
|
||||
|
||||
// Fallback specs for Lane E's debris set (3D-STORE crates/tubs). Radius is the
|
||||
// collision sphere, not the render bounds — a crate is boxy, but a sphere is
|
||||
// what you can afford to test 6 of per node per frame.
|
||||
const MODEL_SPEC = {
|
||||
BlueCrate_v2: { r: 0.30, mass: 9, cd: 1.05 },
|
||||
BlackTub_v2: { r: 0.34, mass: 5, cd: 1.10 },
|
||||
WhiteTub_v2: { r: 0.34, mass: 5, cd: 1.10 },
|
||||
WoodenBin_v2: { r: 0.42, mass: 14, cd: 1.05 },
|
||||
LibraryTrolley_v1: { r: 0.45, mass: 22, cd: 0.95 },
|
||||
};
|
||||
const DEFAULT_SPEC = { r: 0.35, mass: 8, cd: 1.05 };
|
||||
|
||||
/**
|
||||
* @param {object} o
|
||||
* @param {object} o.wind from weather.js
|
||||
* @param {THREE.Object3D} o.scene
|
||||
* @param {Object<string,THREE.Object3D>} [o.models] name -> template (Lane E's GLBs)
|
||||
* @param {function} [o.onHitPlayer] (piece, impact) — Lane D knocks the player down
|
||||
* @param {function} [o.onEvent] (text) — HUD ticker
|
||||
* @param {object} [o.bounds] {x, z} half-extents before despawn
|
||||
*/
|
||||
export function createDebris(o = {}) {
|
||||
const wind = o.wind;
|
||||
const scene = o.scene || null;
|
||||
const models = o.models || {};
|
||||
const bounds = o.bounds || { x: 26, z: 20 };
|
||||
// contracts.js documents world.heightAt as the thing Lane C bounces debris off.
|
||||
// Flat fallback so this still runs against a graybox yard.
|
||||
const groundAt = o.heightAt || (() => o.groundY ?? 0);
|
||||
const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e);
|
||||
|
||||
const pieces = [];
|
||||
const w = new THREE.Vector3();
|
||||
const probe = new THREE.Vector3();
|
||||
|
||||
/** Graybox stand-in so a missing GLB can't break Lane A's merge. */
|
||||
function placeholder(spec) {
|
||||
const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8);
|
||||
const m = new THREE.MeshStandardMaterial({ color: 0x8a6a3a, roughness: 0.9 });
|
||||
return new THREE.Mesh(g, m);
|
||||
}
|
||||
|
||||
function spawn(ev, t) {
|
||||
const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) };
|
||||
if (Number.isFinite(ev.mass)) spec.mass = ev.mass;
|
||||
|
||||
// upwind of the yard, offset sideways, so it crosses the whole thing
|
||||
const d = wind.dirAt(t);
|
||||
const dx = Math.cos(d), dz = Math.sin(d);
|
||||
const lat = ev.lateral ?? 0;
|
||||
const dist = ev.spawnDist ?? 18;
|
||||
const x = -dx * dist - dz * lat;
|
||||
const z = -dz * dist + dx * lat;
|
||||
const y = groundAt(x, z) + spec.r + (ev.height ?? 0.2 + rand() * 1.2);
|
||||
|
||||
const tmpl = models[ev.model];
|
||||
const mesh = tmpl ? tmpl.clone(true) : placeholder(spec);
|
||||
mesh.castShadow = true;
|
||||
if (scene) scene.add(mesh);
|
||||
|
||||
// already moving — it's been blowing across the neighbour's yard for a while
|
||||
probe.set(x, y, z);
|
||||
wind.sample(probe, t, w);
|
||||
|
||||
const piece = {
|
||||
model: ev.model,
|
||||
x, y, z,
|
||||
vx: w.x * 0.8, vy: 0, vz: w.z * 0.8,
|
||||
// spin axis is arbitrary but seeded; rate scales with airspeed in step()
|
||||
sx: rand() * 2 - 1, sy: rand() * 2 - 1, sz: rand() * 2 - 1,
|
||||
spin: 0,
|
||||
phase: rand() * 6.283, // so two crates don't hop in lockstep
|
||||
r: spec.r, mass: spec.mass, cd: spec.cd,
|
||||
area: Math.PI * spec.r * spec.r,
|
||||
hitPlayer: false,
|
||||
mesh,
|
||||
alive: true,
|
||||
};
|
||||
pieces.push(piece);
|
||||
if (ev.text && o.onEvent) o.onEvent(ev.text);
|
||||
return piece;
|
||||
}
|
||||
|
||||
function despawn(p) {
|
||||
p.alive = false;
|
||||
if (scene && p.mesh) scene.remove(p.mesh);
|
||||
}
|
||||
|
||||
const debris = {
|
||||
get pieces() { return pieces; },
|
||||
|
||||
/** Lane E's GLBs, once they land. name -> Object3D template. */
|
||||
setModels(map) { Object.assign(models, map); return debris; },
|
||||
|
||||
/** Manual spawn — handy for tuning and for Lane A's debug keys. */
|
||||
spawn,
|
||||
|
||||
/**
|
||||
* @param {number} dt fixed step
|
||||
* @param {number} t storm time
|
||||
* @param {object} [world] {player, sail} — both optional, both duck-typed
|
||||
*/
|
||||
step(dt, t, world = {}) {
|
||||
// storm JSON drives the spawns; poll the window so nothing is missed
|
||||
if (wind) {
|
||||
for (const ev of wind.eventsBetween(t - dt, t)) {
|
||||
if (ev.type === 'debris') spawn(ev, t);
|
||||
}
|
||||
}
|
||||
|
||||
const player = world.player;
|
||||
const sail = world.sail;
|
||||
|
||||
for (let i = pieces.length - 1; i >= 0; i--) {
|
||||
const p = pieces[i];
|
||||
|
||||
probe.set(p.x, p.y, p.z);
|
||||
wind.sample(probe, t, w);
|
||||
|
||||
// drag against the AIR, not the ground: F = ½ρ Cd A |w-v| (w-v)
|
||||
const rx = w.x - p.vx, ry = w.y - p.vy, rz = w.z - p.vz;
|
||||
const rel = Math.hypot(rx, ry, rz);
|
||||
const k = 0.5 * RHO * p.cd * p.area * rel / p.mass;
|
||||
p.vx += rx * k * dt;
|
||||
p.vy += ry * k * dt + GRAVITY * dt;
|
||||
p.vz += rz * k * dt;
|
||||
|
||||
// A tumbling bluff body doesn't just get shoved, it gets picked up: lift
|
||||
// flips sign as it rolls, which is why a bin HOPS across a yard instead
|
||||
// of sliding. Wind is horizontal (weather.js keeps y=0), so without this
|
||||
// there is no vertical force at all once it's down and it just skates.
|
||||
p.vy += (0.5 * rel * rel * Math.sin(p.spin * 1.7 + p.phase) / p.mass) * dt;
|
||||
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.z += p.vz * dt;
|
||||
|
||||
// ground
|
||||
const floor = groundAt(p.x, p.z) + p.r;
|
||||
if (p.y <= floor) {
|
||||
p.y = floor;
|
||||
if (p.vy < -0.5) {
|
||||
// a real impact: bounce, and lose some tangential speed to the hit
|
||||
p.vy = -p.vy * 0.32; // dead-ish, it's a plastic tub
|
||||
p.vx *= 0.72; p.vz *= 0.72;
|
||||
} else {
|
||||
if (p.vy < 0) p.vy = 0;
|
||||
// Resting: rolling friction as a dt-scaled DECELERATION, not a
|
||||
// per-frame multiplier. `v *= 0.86` every frame is 0.86^60 per
|
||||
// second — that isn't scrape, it's glue, and it pinned a 9 kg crate
|
||||
// at 0.7 m/s in a 19 m/s wind.
|
||||
const sp = Math.hypot(p.vx, p.vz);
|
||||
if (sp > 1e-4) {
|
||||
const drop = Math.min(sp, GROUND_FRICTION * dt);
|
||||
p.vx -= (p.vx / sp) * drop;
|
||||
p.vz -= (p.vz / sp) * drop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tumble rate follows airspeed — becalmed debris shouldn't keep spinning
|
||||
p.spin += rel * 0.35 * dt;
|
||||
if (p.mesh) {
|
||||
p.mesh.position.set(p.x, p.y, p.z);
|
||||
p.mesh.rotation.set(p.sx * p.spin, p.sy * p.spin, p.sz * p.spin);
|
||||
}
|
||||
|
||||
// --- sphere vs player: knockdown ---
|
||||
// Contract gives us player.pos; the knockdown itself is Lane D's (§5-D.3),
|
||||
// so we just report the hit and let them run the state machine.
|
||||
if (player && player.pos && !p.hitPlayer) {
|
||||
const px = player.pos.x, pz = player.pos.z;
|
||||
const py = player.pos.y + 0.9; // centre of mass, not feet
|
||||
const dsq = (p.x - px) ** 2 + (p.y - py) ** 2 + (p.z - pz) ** 2;
|
||||
const hit = p.r + 0.35;
|
||||
if (dsq < hit * hit) {
|
||||
const impact = Math.hypot(p.vx, p.vy, p.vz) * p.mass;
|
||||
// a bin rolling gently past your ankles shouldn't floor you
|
||||
if (impact > 25 && o.onHitPlayer) {
|
||||
p.hitPlayer = true; // one knockdown per piece
|
||||
o.onHitPlayer(p, impact);
|
||||
p.vx *= 0.4; p.vz *= 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- sphere vs sail nodes: impulse ---
|
||||
// Duck-typed: lights up the moment Lane B exposes nodes, silent until
|
||||
// then. See THREADS — B owns sail.js, so this is the seam we agreed on.
|
||||
if (sail && sail.nodes) applyToSail(p, sail);
|
||||
|
||||
if (p.y < groundAt(p.x, p.z) - 5 || Math.abs(p.x) > bounds.x || Math.abs(p.z) > bounds.z) {
|
||||
despawn(p);
|
||||
pieces.splice(i, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Drop everything (phase change, restart). */
|
||||
clear() {
|
||||
for (const p of pieces) despawn(p);
|
||||
pieces.length = 0;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Shove any cloth node the piece is intersecting, and lose some of the piece's
|
||||
* own momentum doing it. Expects sail.nodes: [{x,y,z,px,py,pz}] (verlet, so we
|
||||
* move position and let the integrator turn it into velocity).
|
||||
*/
|
||||
function applyToSail(p, sail) {
|
||||
const nodes = sail.nodes;
|
||||
const reach = p.r + 0.15;
|
||||
const reachSq = reach * reach;
|
||||
let hits = 0;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const n = nodes[i];
|
||||
const dx = n.x - p.x, dy = n.y - p.y, dz = n.z - p.z;
|
||||
const dsq = dx * dx + dy * dy + dz * dz;
|
||||
if (dsq > reachSq || dsq < 1e-9) continue;
|
||||
const d = Math.sqrt(dsq);
|
||||
// push the node out to the sphere surface along the contact normal
|
||||
const push = (reach - d) / d;
|
||||
n.x += dx * push; n.y += dy * push; n.z += dz * push;
|
||||
hits++;
|
||||
}
|
||||
if (hits) {
|
||||
const drag = Math.min(0.5, (hits * p.mass) / 400);
|
||||
p.vx *= 1 - drag; p.vy *= 1 - drag; p.vz *= 1 - drag;
|
||||
if (sail.onDebrisHit) sail.onDebrisHit(p, hits);
|
||||
}
|
||||
}
|
||||
|
||||
return debris;
|
||||
}
|
||||
181
web/world/js/interact.js
Normal file
181
web/world/js/interact.js
Normal file
@ -0,0 +1,181 @@
|
||||
/**
|
||||
* interact.js — hold-E actions with radial progress. (Lane D)
|
||||
*
|
||||
* contracts.js: interact.register({id, pos, radius, holdSecs, label, canUse()->bool, onDone()})
|
||||
*
|
||||
* Zero imports (same reason as player.sim.js): the whole thing is fixed-dt and headless-testable.
|
||||
* `pos` is duck-typed {x,y,z}, so a THREE.Vector3 or a plain object both work.
|
||||
*
|
||||
* The busy handshake: this module both ENTERS and LEAVES the player's `busy` state. Nothing else
|
||||
* writes it, so a dropped release cannot strand the player — and if the world takes the player away
|
||||
* mid-hold (a gust puts them down), we notice `player.state` is no longer 'busy' and abort without
|
||||
* stomping on the state the world just set.
|
||||
*/
|
||||
|
||||
export class Interact {
|
||||
constructor() {
|
||||
this.targets = new Map();
|
||||
this.active = null; // the target currently being held
|
||||
this.progress = 0; // 0..1 — the radial
|
||||
this.latched = false; // a completed action re-arms only after E is released (see step)
|
||||
this.events = []; // {type:'done'|'cancel', id, t} — drained by hud.js
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} spec
|
||||
* @param {string} spec.id
|
||||
* @param {object} spec.pos {x,y,z} — read live each step, so it may move (a sail corner does)
|
||||
* @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.onDone] (player, t) -> void
|
||||
* @returns {function} unregister
|
||||
*/
|
||||
register(spec) {
|
||||
if (!spec || !spec.id) throw new Error('interact.register: id required');
|
||||
const target = {
|
||||
radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, ...spec,
|
||||
};
|
||||
this.targets.set(target.id, target);
|
||||
return () => this.unregister(target.id);
|
||||
}
|
||||
|
||||
unregister(id) {
|
||||
if (this.active && this.active.id === id) this.active = null, this.progress = 0;
|
||||
return this.targets.delete(id);
|
||||
}
|
||||
|
||||
labelOf(target, player) {
|
||||
return typeof target.label === 'function' ? target.label(player) : target.label;
|
||||
}
|
||||
|
||||
_usable(target, player) {
|
||||
return !target.canUse || !!target.canUse(player);
|
||||
}
|
||||
|
||||
/** Nearest registered target in range whose canUse() passes. */
|
||||
nearest(player) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (const target of this.targets.values()) {
|
||||
const p = typeof target.pos === 'function' ? target.pos() : target.pos;
|
||||
if (!p) continue;
|
||||
const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z);
|
||||
if (d <= target.radius && d < bestD && this._usable(target, player)) { best = target; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
cancel(t, player) {
|
||||
if (!this.active) return;
|
||||
// only hand the player back if they're still ours — a knockdown mid-hold already re-stated them
|
||||
if (player.state === 'busy') player.setState('idle', t);
|
||||
this.events.push({ type: 'cancel', id: this.active.id, t });
|
||||
this.active = null;
|
||||
this.progress = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} dt @param {number} t
|
||||
* @param {PlayerSim} player
|
||||
* @param {boolean} holding is E held this frame
|
||||
* @returns {{target, progress, label, holding}} for hud.js to draw the prompt + radial
|
||||
*/
|
||||
step(dt, t, player, holding) {
|
||||
// One press, one action: a completed hold latches until E is released. Without this, a held key
|
||||
// re-arms the instant the action finishes and the same action fires every holdSecs forever
|
||||
// (leaning on the shed table would deal you a spare a second, indefinitely).
|
||||
if (!holding) this.latched = false;
|
||||
|
||||
const near = this.nearest(player);
|
||||
|
||||
if (this.active) {
|
||||
const stolen = player.state !== 'busy'; // something else claimed the player
|
||||
if (!holding || near !== this.active || !this._usable(this.active, player) || stolen) {
|
||||
this.cancel(t, player);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.active && holding && !this.latched && near && !player.busy) {
|
||||
this.active = near;
|
||||
this.progress = 0;
|
||||
player.setState('busy', t);
|
||||
}
|
||||
|
||||
if (this.active) {
|
||||
this.progress += dt / Math.max(1e-6, this.active.holdSecs);
|
||||
if (this.progress >= 1) {
|
||||
const done = this.active;
|
||||
this.active = null;
|
||||
this.progress = 0;
|
||||
this.latched = true;
|
||||
player.setState('idle', t); // release busy FIRST — onDone may pickUp(), which refuses while busy
|
||||
if (done.onDone) done.onDone(player, t);
|
||||
this.events.push({ type: 'done', id: done.id, t });
|
||||
}
|
||||
}
|
||||
|
||||
const shown = this.active || near;
|
||||
return {
|
||||
target: shown,
|
||||
progress: this.progress,
|
||||
label: shown ? this.labelOf(shown, player) : '',
|
||||
holding: !!this.active,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the standard yard actions (PLAN3D §5-D.4). Duck-typed against the contracts so Lane D
|
||||
* never edits Lane B's or Lane A's files — anything not yet landed is simply skipped.
|
||||
*
|
||||
* @param {Interact} interact
|
||||
* @param {object} deps {sailRig, world, spares}
|
||||
* sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B)
|
||||
* sailRig.repair(i) -> void [PROPOSED — see THREADS.md]
|
||||
* sailRig.trim(i,d) -> void [PROPOSED — per-corner turnbuckle, see THREADS.md]
|
||||
* world.shedTable -> {pos} (Lane A/E)
|
||||
*/
|
||||
export function wireYardActions(interact, deps = {}) {
|
||||
const { sailRig, world } = deps;
|
||||
const wired = [];
|
||||
|
||||
if (sailRig && Array.isArray(sailRig.corners)) {
|
||||
sailRig.corners.forEach((corner, i) => {
|
||||
// re-rig a broken corner — costs the spare you're carrying
|
||||
wired.push(interact.register({
|
||||
id: `rerig_${i}`,
|
||||
pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
|
||||
radius: 1.8,
|
||||
holdSecs: 2.5,
|
||||
label: 're-rig corner',
|
||||
canUse: (p) => corner.broken && p.carrying === 'spare',
|
||||
onDone: (p) => { p.carrying = null; if (sailRig.repair) sailRig.repair(i); },
|
||||
}));
|
||||
// per-corner turnbuckle trim — new vs the prototype; makes corners individual
|
||||
wired.push(interact.register({
|
||||
id: `trim_${i}`,
|
||||
pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
|
||||
radius: 1.8,
|
||||
holdSecs: 1.2,
|
||||
label: 'tighten turnbuckle',
|
||||
canUse: () => !corner.broken && !!sailRig.trim,
|
||||
onDone: () => sailRig.trim && sailRig.trim(i, +0.1),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (world && world.shedTable) {
|
||||
wired.push(interact.register({
|
||||
id: 'spare_table',
|
||||
pos: world.shedTable.pos,
|
||||
radius: 1.5,
|
||||
holdSecs: 0.6,
|
||||
label: (p) => (p.carrying ? 'hands full' : 'take a spare'),
|
||||
canUse: (p) => !p.carrying, // hands-full rule
|
||||
onDone: (p, t) => p.pickUp('spare', t),
|
||||
}));
|
||||
}
|
||||
|
||||
return () => wired.forEach((un) => un());
|
||||
}
|
||||
@ -1,18 +1,32 @@
|
||||
/**
|
||||
* SHADES — boot, game loop, phase machine. Lane A owns this file.
|
||||
*
|
||||
* Nothing is auto-run on import: index.html calls boot(). That keeps createGame()
|
||||
* importable from selftest.html, which must never construct a WebGLRenderer.
|
||||
* This is the assembly point: every other lane's module is proven in isolation,
|
||||
* and this file is where they become one game. Two rules make that possible and
|
||||
* are worth not breaking:
|
||||
*
|
||||
* The loop is a fixed-dt accumulator. Sim modules only ever see FIXED_DT, never
|
||||
* a real frame delta — that is the whole reason selftest can fast-forward a 90 s
|
||||
* storm in a few milliseconds and get the same numbers the player got.
|
||||
* - **Nothing auto-runs on import.** index.html calls boot(). That keeps
|
||||
* createGame() importable from selftest.html, which must never construct a
|
||||
* WebGLRenderer.
|
||||
* - **The loop is a fixed-dt accumulator.** Sim modules only ever see FIXED_DT,
|
||||
* never a real frame delta. That is the whole reason selftest can fast-forward
|
||||
* a 90 s storm in milliseconds and get the numbers the player got.
|
||||
*/
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { FIXED_DT, PHASES, STORM_LEN, YARD, Emitter, createStubWind } from './contracts.js';
|
||||
import { createWorld, heightAt } from './world.js';
|
||||
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, Emitter } from './contracts.js';
|
||||
import { createWorld } from './world.js';
|
||||
import { createCameraRig } from './camera.js';
|
||||
import { loadStorm, createWind } from './weather.js';
|
||||
import { SailRig, createSailView } from './sail.js';
|
||||
import { createPlayer } from './player.js';
|
||||
import { Interact, wireYardActions } from './interact.js';
|
||||
import { createDebris } from './debris.js';
|
||||
import { createSkyFx } from './skyfx.js';
|
||||
|
||||
/** Which storm each phase runs under (SPRINT2 §Lane A.1). */
|
||||
const CALM_STORM = 'storm_01_gentle';
|
||||
const WILD_STORM = 'storm_02_wildnight';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase machine
|
||||
@ -58,81 +72,91 @@ export function createGame() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M0 placeholder player
|
||||
// Wind router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A 1.7 m capsule that walks. This exists ONLY so the camera has something to
|
||||
* follow and the yard scale is legible before Lane D lands.
|
||||
* One wind object whose identity never changes, delegating to whichever storm
|
||||
* is currently running.
|
||||
*
|
||||
* Lane D: replace the call site in boot() with your player.js factory — the
|
||||
* shape you need to satisfy is `Player` in contracts.js ({pos, carrying, busy,
|
||||
* update}) — then delete this function. Everything else in this file already
|
||||
* talks to you through that contract, so nothing else should need to change.
|
||||
* Every consumer binds to wind exactly once, at construction — the yard closes
|
||||
* over it for tree sway, createPlayer takes it in opts, createDebris reads its
|
||||
* event stream. So swapping storm_01 for storm_02 at the phase change has to be
|
||||
* a re-point, not a re-wire, or half the game would still be sampling the calm
|
||||
* day while the other half is in a gale.
|
||||
*
|
||||
* Shelters are applied to every storm rather than just the active one: they
|
||||
* describe the yard's trees, which don't stop existing when the weather turns.
|
||||
*
|
||||
* @param {object[]} all every wind this session can switch between
|
||||
*/
|
||||
function createPlaceholderPlayer(scene, world, cameraRig) {
|
||||
const WALK = 2.2, RUN = 4.5; // m/s
|
||||
function createWindRouter(all) {
|
||||
let active = all[0];
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(0.28, 1.14, 4, 12),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffd27a, roughness: 0.7 }),
|
||||
);
|
||||
mesh.name = 'player_placeholder';
|
||||
mesh.castShadow = true;
|
||||
scene.add(mesh);
|
||||
const router = {
|
||||
/** The wind currently in force. Assign through use(). */
|
||||
get active() { return active; },
|
||||
use(w) { active = w; return router; },
|
||||
|
||||
const keys = new Set();
|
||||
const onDown = (e) => {
|
||||
keys.add(e.key.toLowerCase());
|
||||
if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(e.key.toLowerCase())) e.preventDefault();
|
||||
};
|
||||
const onUp = (e) => keys.delete(e.key.toLowerCase());
|
||||
addEventListener('keydown', onDown);
|
||||
addEventListener('keyup', onUp);
|
||||
sample: (pos, t, out) => active.sample(pos, t, out),
|
||||
speedAt: (pos, t) => active.speedAt(pos, t),
|
||||
gustTelegraph: (t) => active.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => active.eventsBetween(a, b),
|
||||
rainAt: (t) => active.rainAt(t),
|
||||
dirAt: (t) => active.dirAt(t),
|
||||
|
||||
const pos = new THREE.Vector3(0, heightAt(0, 6), 6);
|
||||
const move = new THREE.Vector3();
|
||||
const fwd = new THREE.Vector3();
|
||||
const right = new THREE.Vector3();
|
||||
let facing = 0;
|
||||
|
||||
const hx = YARD.width / 2 - 0.5, hz = YARD.depth / 2 - 0.5;
|
||||
|
||||
return {
|
||||
pos,
|
||||
carrying: null,
|
||||
busy: false,
|
||||
mesh,
|
||||
|
||||
update(dt) {
|
||||
const yaw = cameraRig.yaw;
|
||||
// Camera-relative: forward is where the camera is looking, flattened.
|
||||
fwd.set(-Math.sin(yaw), 0, -Math.cos(yaw));
|
||||
right.set(Math.cos(yaw), 0, -Math.sin(yaw));
|
||||
|
||||
move.set(0, 0, 0);
|
||||
if (keys.has('w') || keys.has('arrowup')) move.add(fwd);
|
||||
if (keys.has('s') || keys.has('arrowdown')) move.sub(fwd);
|
||||
if (keys.has('d') || keys.has('arrowright')) move.add(right);
|
||||
if (keys.has('a') || keys.has('arrowleft')) move.sub(right);
|
||||
|
||||
if (move.lengthSq() > 0) {
|
||||
move.normalize().multiplyScalar((keys.has('shift') ? RUN : WALK) * dt);
|
||||
pos.x = Math.max(-hx, Math.min(hx, pos.x + move.x));
|
||||
pos.z = Math.max(-hz, Math.min(hz, pos.z + move.z));
|
||||
facing = Math.atan2(move.x, move.z);
|
||||
}
|
||||
pos.y = world.heightAt(pos.x, pos.z);
|
||||
|
||||
mesh.position.set(pos.x, pos.y + 0.85, pos.z); // capsule centre
|
||||
mesh.rotation.y = facing;
|
||||
setShelters(list) {
|
||||
for (const w of all) w.setShelters(list);
|
||||
return router;
|
||||
},
|
||||
setSheltersFromTrees(trees, o = {}) {
|
||||
return router.setShelters(trees.map((tr) => ({
|
||||
x: tr.pos ? tr.pos.x : tr.x,
|
||||
z: tr.pos ? tr.pos.z : tr.z,
|
||||
radius: o.radius ?? tr.radius ?? 3,
|
||||
strength: o.strength ?? 0.45,
|
||||
length: o.length ?? 14,
|
||||
})));
|
||||
},
|
||||
|
||||
dispose() {
|
||||
removeEventListener('keydown', onDown);
|
||||
removeEventListener('keyup', onUp);
|
||||
},
|
||||
get duration() { return active.duration; },
|
||||
get gusts() { return active.gusts; },
|
||||
get def() { return active.def; },
|
||||
get seed() { return active.seed; },
|
||||
get core() { return active.core; },
|
||||
};
|
||||
return router;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debris models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lane E's crates and tubs, keyed by the names storm JSON spawns and debris.js
|
||||
* has radii for. A browser can't glob a directory, so the list is explicit —
|
||||
* and it should stay matched to MODEL_SPEC in debris.js (Lane C's ask: tell them
|
||||
* rather than fighting the radii).
|
||||
*
|
||||
* Missing files are not fatal: debris.js falls back to a graybox box per piece,
|
||||
* which is exactly the degrade-quietly behaviour Lane C designed for.
|
||||
*/
|
||||
const DEBRIS_MODELS = ['BlueCrate_v2', 'BlackTub_v2', 'WhiteTub_v2', 'WoodenBin_v2'];
|
||||
|
||||
async function loadDebrisModels() {
|
||||
const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
|
||||
const loader = new GLTFLoader();
|
||||
const out = {};
|
||||
await Promise.all(DEBRIS_MODELS.map(async (name) => {
|
||||
try {
|
||||
const gltf = await loader.loadAsync(`./models/debris/${name}.glb`);
|
||||
gltf.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
|
||||
out[name] = gltf.scene;
|
||||
} catch (err) {
|
||||
console.warn(`[main] debris model ${name} unavailable, using graybox:`, err.message);
|
||||
}
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -143,7 +167,7 @@ function createPlaceholderPlayer(scene, world, cameraRig) {
|
||||
* @param {object} [opts]
|
||||
* @param {HTMLCanvasElement} [opts.canvas]
|
||||
*/
|
||||
export function boot(opts = {}) {
|
||||
export async function boot(opts = {}) {
|
||||
const canvas = opts.canvas ?? document.getElementById('c');
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
@ -155,33 +179,154 @@ export function boot(opts = {}) {
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
// Lane C: swap createStubWind() for your createWeather(). Everything that
|
||||
// moves reads this one object, so that swap is the whole integration.
|
||||
const wind = createStubWind({ calm: true });
|
||||
// --- 1. weather ---------------------------------------------------------
|
||||
// Both storms load up front: the forecast card needs to read storm_02's shape
|
||||
// before the player has agreed to face it.
|
||||
const [calmDef, wildDef] = await Promise.all([loadStorm(CALM_STORM), loadStorm(WILD_STORM)]);
|
||||
const calmWind = createWind(calmDef);
|
||||
const wildWind = createWind(wildDef);
|
||||
const wind = createWindRouter([calmWind, wildWind]);
|
||||
|
||||
// --- world & camera -----------------------------------------------------
|
||||
const world = createWorld(scene, { wind });
|
||||
const cameraRig = createCameraRig(canvas);
|
||||
cameraRig.setSolids(world.solids);
|
||||
cameraRig.setGround(world.heightAt);
|
||||
|
||||
const player = createPlaceholderPlayer(scene, world, cameraRig);
|
||||
// Lane C: trees don't shelter anything until they're told where they are.
|
||||
wind.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
||||
|
||||
// --- 2. player ----------------------------------------------------------
|
||||
const interact = new Interact();
|
||||
const player = await createPlayer(scene, world, cameraRig, { wind, interact });
|
||||
|
||||
// --- 3. sail ------------------------------------------------------------
|
||||
const rig = new SailRig({ anchors: world.anchors });
|
||||
let sailView = null;
|
||||
|
||||
/**
|
||||
* Attach the cloth across 4 anchors and (re)build its view.
|
||||
*
|
||||
* The order here is load-bearing. createSailView reads rig.pos and rig.tris,
|
||||
* which don't exist until attach() allocates them in _build() — build the view
|
||||
* first and it throws on an undefined array. A re-rig can also change the grid,
|
||||
* so the view has to be rebuilt rather than reused. Both facts make this the
|
||||
* single door that boot and Lane B's picking adapter should come through.
|
||||
*
|
||||
* Re-wiring interact each time is deliberate: its targets close over corner
|
||||
* objects and attach() makes a fresh corners array, so stale closures would
|
||||
* point at corners the sim no longer steps. The ids are stable, so this
|
||||
* replaces the old targets rather than stacking duplicates.
|
||||
*/
|
||||
async function rigSail(anchorIds, hwChoices, tension = 1.0) {
|
||||
rig.attach(anchorIds, hwChoices, tension);
|
||||
if (sailView) {
|
||||
scene.remove(sailView);
|
||||
sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
|
||||
}
|
||||
sailView = await createSailView(rig);
|
||||
scene.add(sailView);
|
||||
wireYardActions(interact, { sailRig: rig, world });
|
||||
return sailView;
|
||||
}
|
||||
|
||||
// Until Lane B's prep-phase picking adapter lands (SPRINT2 §B.3), rig a
|
||||
// default quad so the yard has a live sail and Lane D has something to
|
||||
// repair. Deliberately the prototype's AUTO loadout — one dodgy carabiner
|
||||
// corner. It also spans most of the yard, which is the 70–192 m² problem
|
||||
// decision 2 fixes in step 6, not a fault in the cloth.
|
||||
await rigSail(['h1', 'h3', 'p2', 'p1'], [HARDWARE[2], HARDWARE[1], HARDWARE[1], HARDWARE[0]]);
|
||||
|
||||
const game = createGame();
|
||||
|
||||
// --- dev overlay (temporary — Lane A's hud.js replaces it after M0) ----
|
||||
// --- clocks -------------------------------------------------------------
|
||||
// Two of them, and the distinction matters. `simT` is wall-clock seconds since
|
||||
// boot. `windT` is STORM time — storm JSON is authored with t=0 at the storm's
|
||||
// first gust, so it's phase time during the storm, and off-storm it wraps the
|
||||
// calm day around its own duration so the breeze keeps breathing however long
|
||||
// you spend rigging. Every sim module samples windT; nothing samples simT.
|
||||
let simT = 0;
|
||||
let windT = 0;
|
||||
let acc = 0;
|
||||
|
||||
function windTime() {
|
||||
if (game.phase === 'storm') return game.phaseT;
|
||||
return simT % Math.max(1, calmWind.duration);
|
||||
}
|
||||
|
||||
// --- 4. sky, audio, debris ---------------------------------------------
|
||||
const events = [];
|
||||
const pushEvent = (text) => {
|
||||
events.push({ t: game.phaseT, text });
|
||||
if (events.length > 4) events.shift();
|
||||
};
|
||||
|
||||
const debris = createDebris({
|
||||
wind,
|
||||
scene,
|
||||
heightAt: world.heightAt,
|
||||
// knockdown(t, dirX, dirZ) — the first arg is the sim clock, NOT the impact
|
||||
// magnitude. Passing `impact` here would jam ~40 into the state machine's
|
||||
// start time and the player would never get up. The piece's own velocity is
|
||||
// the direction, so you fall the way the crate was travelling.
|
||||
onHitPlayer: (piece) => player.sim.knockdown(windT, piece.vx, piece.vz),
|
||||
onEvent: pushEvent,
|
||||
});
|
||||
debris.setModels(await loadDebrisModels());
|
||||
|
||||
// skyfx reads the storm's `sky` block at construction (darkness, cloud scroll,
|
||||
// night), so it is rebuilt when the storm changes rather than re-pointed like
|
||||
// wind. dispose() hands world.sun/world.hemi back exactly as they were, which
|
||||
// is what makes that safe to do mid-session.
|
||||
let sky = null;
|
||||
let audioUnlocked = false;
|
||||
function makeSky() {
|
||||
if (sky) sky.dispose();
|
||||
sky = createSkyFx({
|
||||
scene,
|
||||
camera: cameraRig.object,
|
||||
wind,
|
||||
sun: world.sun,
|
||||
hemi: world.hemi,
|
||||
onEvent: pushEvent,
|
||||
});
|
||||
if (audioUnlocked) sky.unlockAudio();
|
||||
return sky;
|
||||
}
|
||||
makeSky();
|
||||
|
||||
// Browsers won't start an AudioContext without a gesture. Without this the
|
||||
// storm is silent, and half of DESIGN.md's threat model is audible.
|
||||
const unlock = () => {
|
||||
if (audioUnlocked) return;
|
||||
audioUnlocked = true;
|
||||
sky?.unlockAudio();
|
||||
removeEventListener('pointerdown', unlock);
|
||||
removeEventListener('keydown', unlock);
|
||||
};
|
||||
addEventListener('pointerdown', unlock);
|
||||
addEventListener('keydown', unlock);
|
||||
|
||||
// --- dev overlay (temporary — hud.js replaces it in step 7) -------------
|
||||
const hud = document.getElementById('dev');
|
||||
const banner = document.getElementById('banner');
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') game.advance();
|
||||
});
|
||||
|
||||
// --- phases -------------------------------------------------------------
|
||||
game.on('phaseChange', ({ to }) => {
|
||||
wind.use(to === 'storm' ? wildWind : calmWind);
|
||||
makeSky();
|
||||
events.length = 0;
|
||||
if (banner) {
|
||||
banner.textContent = to.toUpperCase();
|
||||
banner.style.opacity = '1';
|
||||
setTimeout(() => { banner.style.opacity = '0'; }, 1400);
|
||||
}
|
||||
});
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') game.advance();
|
||||
});
|
||||
|
||||
// --- resize ------------------------------------------------------------
|
||||
// --- resize -------------------------------------------------------------
|
||||
function resize() {
|
||||
const w = canvas.clientWidth || innerWidth;
|
||||
const h = canvas.clientHeight || innerHeight;
|
||||
@ -191,18 +336,19 @@ export function boot(opts = {}) {
|
||||
addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
// --- loop --------------------------------------------------------------
|
||||
// --- loop ---------------------------------------------------------------
|
||||
const clock = new THREE.Clock();
|
||||
let acc = 0;
|
||||
let simT = 0;
|
||||
let frames = 0, fpsT = 0, fps = 0;
|
||||
|
||||
function step(dt, t) {
|
||||
function step(dt) {
|
||||
game.tick(dt);
|
||||
world.update(dt, t);
|
||||
player.update(dt, t);
|
||||
// Lane B: sailRig.step(dt, wind, t) goes here.
|
||||
// Lane C: debris.step(dt, wind, t) goes here.
|
||||
simT += dt;
|
||||
windT = windTime();
|
||||
world.update(dt, windT);
|
||||
player.update(dt, windT);
|
||||
rig.step(dt, wind, windT);
|
||||
debris.step(dt, windT, { player: player.sim, sail: rig });
|
||||
sky?.step(dt, windT, { sail: rig });
|
||||
}
|
||||
|
||||
function frame() {
|
||||
@ -212,28 +358,55 @@ export function boot(opts = {}) {
|
||||
acc += raw;
|
||||
let guard = 0;
|
||||
while (acc >= FIXED_DT && guard++ < 60) {
|
||||
step(FIXED_DT, simT);
|
||||
simT += FIXED_DT;
|
||||
step(FIXED_DT);
|
||||
acc -= FIXED_DT;
|
||||
}
|
||||
|
||||
cameraRig.update(raw, player.pos);
|
||||
sailView?.update();
|
||||
renderer.render(scene, cameraRig.object);
|
||||
|
||||
frames++; fpsT += raw;
|
||||
if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; }
|
||||
if (hud) {
|
||||
const w = wind.sample(player.pos, simT);
|
||||
const wt = windT;
|
||||
const speed = wind.speedAt(player.pos, wt);
|
||||
const tel = wind.gustTelegraph(wt);
|
||||
const worst = rig.corners.reduce((m, c) => Math.max(m, c.load || 0), 0);
|
||||
hud.textContent =
|
||||
`${fps.toFixed(0)} fps | phase ${game.phase} ${game.phaseT.toFixed(1)}s | ` +
|
||||
`wind ${w.length().toFixed(1)} m/s | t ${simT.toFixed(1)}s`;
|
||||
`${fps.toFixed(0)} fps | ${game.phase} ${game.phaseT.toFixed(1)}s | ` +
|
||||
`wind ${speed.toFixed(1)} m/s${tel ? ` | GUST in ${tel.eta.toFixed(1)}s` : ''} | ` +
|
||||
`worst corner ${worst.toFixed(1)} | debris ${debris.pieces.length}` +
|
||||
`${events.length ? ` | ${events[events.length - 1].text}` : ''}`;
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// Handy for poking at the world from the console.
|
||||
const api = { renderer, scene, world, cameraRig, player, game, wind, get simT() { return simT; } };
|
||||
// Handy for poking at the world from the console, and for the selftest-free
|
||||
// hand checks the sprint's acceptance actually turns on.
|
||||
const api = {
|
||||
renderer, scene, world, cameraRig, player, game, wind, rig, rigSail,
|
||||
get sailView() { return sailView; },
|
||||
debris, interact, events,
|
||||
get sky() { return sky; },
|
||||
get simT() { return simT; },
|
||||
windTime,
|
||||
calmWind, wildWind,
|
||||
|
||||
/**
|
||||
* Drive the sim by hand at fixed dt, and draw on demand. rAF is throttled to
|
||||
* a standstill in a hidden tab, so these are the only honest way to
|
||||
* fast-forward a storm or capture one from a headless browser — which is
|
||||
* exactly what this sprint's "90 s storm_02 run captured" acceptance needs.
|
||||
* Same code path the rAF loop uses; no test-only branch to drift.
|
||||
*/
|
||||
step,
|
||||
render() {
|
||||
sailView?.update();
|
||||
renderer.render(scene, cameraRig.object);
|
||||
},
|
||||
};
|
||||
globalThis.SHADES = api;
|
||||
return api;
|
||||
}
|
||||
|
||||
267
web/world/js/player.js
Normal file
267
web/world/js/player.js
Normal file
@ -0,0 +1,267 @@
|
||||
/**
|
||||
* player.js — the small person: rig, clips, camera-relative control. (Lane D)
|
||||
*
|
||||
* The deterministic half lives in player.sim.js; this file is the view. It follows the 90sDJsim
|
||||
* DEVMANUAL "Rigged animated characters" rules, which are law here:
|
||||
* · SkeletonUtils.clone() for instances — a plain .clone() breaks skinned meshes
|
||||
* · height-normalise off the HEAD BONE and plant the feet — Mixamo scale is unreliable, so the
|
||||
* scale factor is always MEASURED, never a blind setScalar of a guessed constant
|
||||
* · canonicalise the bone namespace so any clip binds to any character
|
||||
*
|
||||
* Assets (see models/MODELS.md): player_01.glb is an untouched ped (metre-scale, head bone 1.75 m);
|
||||
* player_anims.glb is an anim-only carrier built by tools/character/build_player_anims.py. The clips
|
||||
* are retargeted onto the ped at load — see _rotOnly for why that is safe at any carrier scale.
|
||||
*/
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js';
|
||||
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
import { PlayerSim, STATES, TUNE } from './player.sim.js';
|
||||
|
||||
export { PlayerSim, STATES, TUNE };
|
||||
|
||||
export const CHAR_URL = './models/player_01.glb';
|
||||
export const ANIM_URL = './models/player_anims.glb';
|
||||
|
||||
/**
|
||||
* Canonicalise the Mixamo skeleton namespace (mixamorig4: vs mixamorig12:) so any clip binds to any
|
||||
* character. Every Mixamo auto-rig upload gets its own numbered namespace and three.js binds tracks
|
||||
* BY NODE NAME — a mismatched clip binds to nothing and plays a silent T-pose, with no error.
|
||||
* (Straight from 90sDJsim index.html. The \d+ is deliberate: a bare "mixamorig:" is already canonical.)
|
||||
*/
|
||||
const _canon = (s) => s.replace(/mixamorig\d+/g, 'mixamorig');
|
||||
|
||||
const canonRig = (r) => {
|
||||
if (!r) return r;
|
||||
if (r.scene) r.scene.traverse((o) => { o.name = _canon(o.name); });
|
||||
if (r.anims) r.anims.forEach((a) => a.tracks.forEach((t) => { t.name = _canon(t.name); }));
|
||||
return r;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared-clip filter: keep limb/spine rotations only. Drop ALL position tracks (a different-scale
|
||||
* source inflates or crumples the target) AND Hips.quaternion (a different-orientation source lays
|
||||
* the target flat). Quaternions are scale-invariant, which is the whole reason a clip carrier at any
|
||||
* scale retargets cleanly onto the metre-scale ped.
|
||||
*
|
||||
* The cost is that a clip can no longer lie the body down — so the knockdown does NOT come from the
|
||||
* Falling clip's root. player.sim.js pitches the root itself, which is also how the fall gets to go
|
||||
* DOWNWIND of the gust that caused it. The clip only supplies the flail.
|
||||
*/
|
||||
const _rotOnly = (c) => new THREE.AnimationClip(c.name, c.duration,
|
||||
c.tracks.filter((t) => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
|
||||
|
||||
const _loadGLTF = (loader, url) => new Promise((res, rej) =>
|
||||
loader.load(url, (g) => res({ scene: g.scene, anims: g.animations }), undefined,
|
||||
() => rej(new Error(`player: failed to load ${url}`))));
|
||||
|
||||
const UP = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
export class PlayerView {
|
||||
/**
|
||||
* @param {object} rig {scene, anims} — the character
|
||||
* @param {Array} clips retargeted AnimationClips
|
||||
* @param {number} height metres to the top of the head
|
||||
*/
|
||||
constructor(rig, clips, height = 1.72) {
|
||||
// root: world position + facing + knockdown pitch. fig: the rig, lifted so its feet sit at y=0.
|
||||
this.root = new THREE.Group();
|
||||
this.root.name = 'player';
|
||||
|
||||
const fig = skeletonClone(rig.scene);
|
||||
this.fig = fig;
|
||||
fig.traverse((o) => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = true; } });
|
||||
this.root.add(fig);
|
||||
this.root.updateWorldMatrix(true, true);
|
||||
|
||||
// measure, then scale — never a blind setScalar (DEVMANUAL). /head/i matches both Head and
|
||||
// HeadTop_End; max() takes the crown, which is what "height" means.
|
||||
const wp = new THREE.Vector3();
|
||||
let headY = 0;
|
||||
fig.traverse((o) => {
|
||||
if (!o.isBone) return;
|
||||
o.getWorldPosition(wp);
|
||||
if (/head/i.test(o.name)) headY = Math.max(headY, wp.y);
|
||||
});
|
||||
if (headY > 1e-4) { fig.scale.setScalar(height / headY); fig.updateWorldMatrix(true, true); }
|
||||
this.height = height;
|
||||
|
||||
// plant the feet: lift the rig so its lowest bone sits on the root's origin
|
||||
let minY = Infinity;
|
||||
fig.traverse((o) => { if (o.isBone) { o.getWorldPosition(wp); minY = Math.min(minY, wp.y); } });
|
||||
if (minY < Infinity) fig.position.y = -minY;
|
||||
|
||||
this.mixer = new THREE.AnimationMixer(fig);
|
||||
this.actions = {};
|
||||
// some characters lack bones a shared clip animates (thumb joints, say) — bind only what exists,
|
||||
// else three.js spams "No target node found" for every missing bone
|
||||
const nodes = new Set();
|
||||
fig.traverse((o) => { if (o.name) nodes.add(o.name); });
|
||||
for (const clip of clips) {
|
||||
const bindable = clip.tracks.filter((t) => nodes.has(t.name.split('.')[0]));
|
||||
if (!bindable.length) continue;
|
||||
const use = bindable.length === clip.tracks.length
|
||||
? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable);
|
||||
this.actions[clip.name] = this.mixer.clipAction(use);
|
||||
}
|
||||
this.current = null;
|
||||
this._axis = new THREE.Vector3();
|
||||
this._qYaw = new THREE.Quaternion();
|
||||
this._qPitch = new THREE.Quaternion();
|
||||
}
|
||||
|
||||
/** @returns {string[]} clip names that bound to at least one bone */
|
||||
get clipNames() { return Object.keys(this.actions); }
|
||||
|
||||
play(name, loop = true, fade = 0.18) {
|
||||
const next = this.actions[name];
|
||||
if (!next || next === this.current) return;
|
||||
next.reset();
|
||||
next.setEffectiveWeight(1);
|
||||
next.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
|
||||
next.clampWhenFinished = !loop;
|
||||
if (this.current) next.crossFadeFrom(this.current, fade, false);
|
||||
next.play();
|
||||
this.current = next;
|
||||
}
|
||||
|
||||
/** Push one sim frame onto the rig. dt drives the mixer only — the sim already stepped. */
|
||||
sync(sim, dt) {
|
||||
const st = STATES[sim.state];
|
||||
this.play(st.clip, st.loop !== false);
|
||||
|
||||
this.root.position.set(sim.pos.x, sim.pos.y, 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.
|
||||
this._qYaw.setFromAxisAngle(UP, sim.facing);
|
||||
if (sim.pitch > 1e-4) {
|
||||
this._axis.set(sim.knockDir.z, 0, -sim.knockDir.x);
|
||||
if (this._axis.lengthSq() < 1e-8) this._axis.set(1, 0, 0);
|
||||
this._qPitch.setFromAxisAngle(this._axis.normalize(), sim.pitch * Math.PI * 0.5);
|
||||
this.root.quaternion.copy(this._qPitch).multiply(this._qYaw);
|
||||
} else {
|
||||
this.root.quaternion.copy(this._qYaw);
|
||||
}
|
||||
|
||||
this.mixer.update(dt);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.mixer.stopAllAction();
|
||||
this.root.removeFromParent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the player and attach it to a scene.
|
||||
* @param {THREE.Scene|THREE.Object3D} scene
|
||||
* @param {object} [opts] {start, facing, height, groundAt, tune, charUrl, animUrl}
|
||||
* @returns {Promise<{sim: PlayerSim, view: PlayerView, step: function}>}
|
||||
*/
|
||||
export async function loadPlayer(scene, opts = {}) {
|
||||
const loader = new GLTFLoader();
|
||||
const [rig, animPack] = await Promise.all([
|
||||
_loadGLTF(loader, opts.charUrl || CHAR_URL),
|
||||
_loadGLTF(loader, opts.animUrl || ANIM_URL),
|
||||
]);
|
||||
canonRig(rig);
|
||||
canonRig(animPack);
|
||||
|
||||
const clips = animPack.anims.map(_rotOnly).filter((c) => c.tracks.length);
|
||||
const view = new PlayerView(rig, clips, opts.height || 1.72);
|
||||
scene.add(view.root);
|
||||
|
||||
const sim = new PlayerSim(opts);
|
||||
|
||||
const missing = Object.values(STATES).map((s) => s.clip).filter((c, i, a) =>
|
||||
a.indexOf(c) === i && !view.actions[c]);
|
||||
if (missing.length) console.warn('player: state machine wants clips that did not bind:', missing);
|
||||
|
||||
return {
|
||||
sim,
|
||||
view,
|
||||
/** Convenience: step the sim then push it to the rig. */
|
||||
step(dt, t, input, wind) {
|
||||
sim.step(dt, t, input, wind);
|
||||
view.sync(sim, dt);
|
||||
return sim.state;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Player` contract factory (contracts.js) — this is the one main.js calls.
|
||||
* Drop-in for createPlaceholderPlayer(scene, world, cameraRig): same first three args, so boot()
|
||||
* only changes which function it calls. Everything the player needs per frame it pulls itself, so
|
||||
* `update(dt, t)` matches the contract's signature exactly.
|
||||
*
|
||||
* @param {THREE.Object3D} scene
|
||||
* @param {object} world contracts World — `heightAt(x,z)` clamps the player to the ground
|
||||
* @param {object} cameraRig contracts Camera — `yaw` is what WASD is relative to
|
||||
* @param {object} [opts] {wind, interact, start, facing, height, tune, charUrl, animUrl}
|
||||
* @returns {Promise<object>} satisfies checkContract('player', …)
|
||||
*/
|
||||
export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
const p = await loadPlayer(scene, {
|
||||
...opts,
|
||||
groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined,
|
||||
start: opts.start || { x: 0, y: 0, z: 6 },
|
||||
});
|
||||
const keyboard = new KeyboardInput();
|
||||
const { sim, view } = p;
|
||||
|
||||
return {
|
||||
get pos() { return sim.pos; },
|
||||
get carrying() { return sim.carrying; },
|
||||
set carrying(v) { sim.carrying = v; },
|
||||
get busy() { return sim.busy; },
|
||||
|
||||
/** @param {number} dt @param {number} t — main.js's fixed-dt loop drives this */
|
||||
update(dt, t) {
|
||||
const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
|
||||
sim.step(dt, t, input, opts.wind);
|
||||
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
|
||||
view.sync(sim, dt);
|
||||
},
|
||||
|
||||
/** The Object3D to follow/frame. Lane A's camera wants this, not the raw rig. */
|
||||
get object() { return view.root; },
|
||||
sim,
|
||||
view,
|
||||
keyboard,
|
||||
dispose() { keyboard.dispose(); view.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard → the sim's input shape. Camera yaw comes from Lane A's camera each frame.
|
||||
* Kept out of PlayerSim so the sim stays headless.
|
||||
*/
|
||||
export class KeyboardInput {
|
||||
constructor(target = window) {
|
||||
this.keys = new Set();
|
||||
this._down = (e) => {
|
||||
this.keys.add(e.code);
|
||||
if (/^(Arrow|Space)/.test(e.code)) e.preventDefault();
|
||||
};
|
||||
this._up = (e) => this.keys.delete(e.code);
|
||||
target.addEventListener('keydown', this._down);
|
||||
target.addEventListener('keyup', this._up);
|
||||
this._target = target;
|
||||
}
|
||||
|
||||
get holding() { return this.keys.has('KeyE'); }
|
||||
|
||||
/** @param {number} camYaw radians */
|
||||
read(camYaw = 0) {
|
||||
const k = this.keys;
|
||||
const x = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0);
|
||||
const z = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0);
|
||||
return { x, z, run: k.has('ShiftLeft') || k.has('ShiftRight'), camYaw };
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._target.removeEventListener('keydown', this._down);
|
||||
this._target.removeEventListener('keyup', this._up);
|
||||
}
|
||||
}
|
||||
246
web/world/js/player.sim.js
Normal file
246
web/world/js/player.sim.js
Normal file
@ -0,0 +1,246 @@
|
||||
/**
|
||||
* player.sim.js — the small person's deterministic core. (Lane D)
|
||||
*
|
||||
* Zero imports, on purpose. PLAN3D §0 requires the sim to be fast-forwardable in selftest with a
|
||||
* fixed dt and no renderer, so nothing in here touches THREE, the DOM, rAF, Date.now() or
|
||||
* Math.random(). `step(dt, t, …)` is the entire clock: same inputs → same trace, every run.
|
||||
*
|
||||
* player.js owns the view (rig, clips, camera). This file owns the truth: where the person is,
|
||||
* what state they're in, and what the wind is doing to them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The state machine, as a table (PLAN3D §5-D.5 asks for a table test).
|
||||
* clip — clip name in player_anims.glb
|
||||
* locked — movement input is ignored, and `player.busy` is true
|
||||
* secs — timed states auto-advance to `next` after this long
|
||||
* Invariant the selftest enforces: every locked state either has a `next` (so it drains on its own)
|
||||
* or is released by an external actor. `busy` is the only externally-released state — interact.js
|
||||
* both enters and leaves it, so a dropped release can't strand the player.
|
||||
*/
|
||||
export const STATES = {
|
||||
idle: { clip: 'Idle', locked: false, loop: true },
|
||||
walk: { clip: 'Walk', locked: false, loop: true },
|
||||
run: { clip: 'Run', locked: false, loop: true },
|
||||
busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' },
|
||||
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' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Tuning. Ported from the 2D prototype's shape (prototype/game.js:250-252), retuned to metres and
|
||||
* m/s per PLAN3D §1 ("port the behaviour, retune the constants").
|
||||
*/
|
||||
export const TUNE = {
|
||||
walkSpeed: 1.5, // m/s — a person crossing a 30 m yard, unhurried
|
||||
runSpeed: 4.4, // m/s — shift
|
||||
accel: 16, // m/s² toward the wanted velocity
|
||||
turnRate: 11, // rad/s — facing chases the movement direction
|
||||
|
||||
// prototype: slow = 1 - min(0.35, wind.speed / 160). Its wind ran ~4 (calm) to ~38 (gust peak),
|
||||
// which is already m/s-shaped, so the curve ports across directly.
|
||||
slowMax: 0.35,
|
||||
slowRef: 160,
|
||||
|
||||
// prototype: push = ws*ws*0.55 — "wind pressure goes with speed², gusts have teeth"; and shove
|
||||
// only applied while wind.gust > 8, never from the base wind.
|
||||
shoveK: 0.0035, // shove accel (m/s²) = shoveK · ws² → ~3.2 m/s² in a 30 m/s gust
|
||||
shoveGustMin: 8, // m/s of gust (over baseline) before the wind can push you at all
|
||||
shoveDamp: 2.5, // 1/s foot-friction bleed → terminal drift ≈ shoveK·ws²/shoveDamp
|
||||
|
||||
// Baseline tracker: contracts.js exposes wind.sample() (total) and wind.gustTelegraph() (before
|
||||
// the gust), but nothing reports gust magnitude DURING the hold. Rather than widen Lane C's
|
||||
// contract, we recover it: a slow EMA of local wind speed is the base curve, and everything above
|
||||
// it is gust. Self-calibrating to whatever storm JSON Lane C authors.
|
||||
baseTrack: 0.25, // 1/s — ~4 s memory; gust holds are ~1.7 s, so they read as gust, not base
|
||||
|
||||
// Knockdown mirrors the sail's failure rule (PLAN3D §1: break after 0.4 s SUSTAINED overload) —
|
||||
// same verb for cloth and for people, so the player reads one language.
|
||||
knockWind: 30, // m/s sustained local wind → you go down
|
||||
knockSustain: 0.5, // s above knockWind before it happens
|
||||
knockBleed: 2, // exposure drains this many × faster than it fills
|
||||
pitchSecs: 0.35, // s for the body to swing down / back up (view reads sim.pitch)
|
||||
};
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
/** Shortest-arc angle step from a toward b, at most `maxStep` radians. */
|
||||
function turnToward(a, b, maxStep) {
|
||||
let d = (b - a) % (Math.PI * 2);
|
||||
if (d > Math.PI) d -= Math.PI * 2;
|
||||
if (d < -Math.PI) d += Math.PI * 2;
|
||||
return a + clamp(d, -maxStep, maxStep);
|
||||
}
|
||||
|
||||
export class PlayerSim {
|
||||
/**
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.start] {x,y,z} spawn, metres
|
||||
* @param {function} [opts.groundAt] (x,z) -> y. Lane A's world.js provides the real one.
|
||||
* @param {object} [opts.tune] overrides for TUNE
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
const s = opts.start || { x: 0, y: 0, z: 0 };
|
||||
this.pos = { x: s.x, y: s.y || 0, z: s.z };
|
||||
this.vel = { x: 0, z: 0 }; // intended (input-driven) velocity
|
||||
this.shove = { x: 0, z: 0 }; // wind-driven velocity, decays through foot friction
|
||||
this.facing = opts.facing || 0; // yaw; models face +Z at 0 (90sDJsim convention)
|
||||
|
||||
this.state = 'idle';
|
||||
this.stateT = 0;
|
||||
this.carrying = null; // contract: player.carrying — one item, hands-full rule
|
||||
this.events = []; // {type:'state'|'drop'|'knockdown', …} drained by the view/HUD
|
||||
|
||||
this.exposure = 0; // s spent above knockWind
|
||||
this.windBase = 0; // EMA baseline (see TUNE.baseTrack)
|
||||
this.windSpeed = 0; // last sampled local speed, m/s — HUD/audio read this
|
||||
this.gust = 0; // windSpeed - windBase, clamped ≥0
|
||||
this.pitch = 0; // 0 upright … 1 flat on the ground
|
||||
this.knockDir = { x: 0, z: 1 }; // which way the body went down
|
||||
|
||||
this.groundAt = opts.groundAt || (() => 0);
|
||||
this.tune = { ...TUNE, ...(opts.tune || {}) };
|
||||
}
|
||||
|
||||
/** contract: player.busy */
|
||||
get busy() { return !!STATES[this.state].locked; }
|
||||
get clip() { return STATES[this.state].clip; }
|
||||
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
|
||||
|
||||
setState(s, t = 0) {
|
||||
if (this.state === s) return false;
|
||||
if (!STATES[s]) throw new Error(`player: unknown state ${s}`);
|
||||
this.state = s;
|
||||
this.stateT = 0;
|
||||
this.events.push({ type: 'state', state: s, t });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drop whatever's carried (knockdown does this per PLAN3D §5-D.3). */
|
||||
drop(t = 0) {
|
||||
if (!this.carrying) return null;
|
||||
const item = this.carrying;
|
||||
this.carrying = null;
|
||||
this.events.push({ type: 'drop', item, t });
|
||||
return item;
|
||||
}
|
||||
|
||||
/** @returns {boolean} true if the pickup was accepted (hands-full rule). */
|
||||
pickUp(item, t = 0) {
|
||||
if (this.carrying || this.busy) return false;
|
||||
this.carrying = item;
|
||||
this.events.push({ type: 'pickup', item, t });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Light hit — a glancing debris clip. Ignored if already down. */
|
||||
staggerHit(t = 0) {
|
||||
if (this.state === 'knocked' || this.state === 'getup') return false;
|
||||
return this.setState('stagger', t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the player on the ground. debris.js (Lane C) calls this on a solid hit; the sim calls it
|
||||
* itself on sustained extreme wind.
|
||||
* @param {number} [dirX] @param {number} [dirZ] which way to fall — defaults to downwind/facing.
|
||||
*/
|
||||
knockdown(t = 0, dirX, dirZ) {
|
||||
if (this.state === 'knocked' || this.state === 'getup') return false;
|
||||
let x = dirX, z = dirZ;
|
||||
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 };
|
||||
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 } });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} dt fixed step, seconds
|
||||
* @param {number} t storm time, seconds
|
||||
* @param {object} input {x,z} camera-relative axes in -1..1, {run}, {camYaw} radians
|
||||
* @param {object} wind contracts wind ({sample(pos,t)->Vector3}) or a plain {x,z} vector
|
||||
*/
|
||||
step(dt, t, input = {}, wind = null) {
|
||||
const T = this.tune;
|
||||
this.stateT += dt;
|
||||
|
||||
// --- local wind, and how much of it is gust ---
|
||||
let wx = 0, wz = 0;
|
||||
if (wind) {
|
||||
const v = typeof wind.sample === 'function' ? wind.sample(this.pos, t) : wind;
|
||||
if (v) { wx = v.x || 0; wz = v.z || 0; }
|
||||
}
|
||||
const ws = Math.hypot(wx, wz);
|
||||
this.windSpeed = ws;
|
||||
this.windBase += (ws - this.windBase) * clamp(dt * T.baseTrack, 0, 1);
|
||||
this.gust = Math.max(0, ws - this.windBase);
|
||||
|
||||
// --- sustained extreme wind puts you down (same rule as a sail corner letting go) ---
|
||||
if (ws > T.knockWind) this.exposure += dt;
|
||||
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
|
||||
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
|
||||
|
||||
const st = STATES[this.state];
|
||||
|
||||
// --- movement ---
|
||||
const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you
|
||||
let wantX = 0, wantZ = 0;
|
||||
if (!st.locked) {
|
||||
const ix = input.x || 0, iz = input.z || 0;
|
||||
const mag = Math.hypot(ix, iz);
|
||||
if (mag > 1e-3) {
|
||||
// camera-relative: at camYaw 0 three.js looks down -Z, so forward = (-sin, -cos).
|
||||
const cy = input.camYaw || 0;
|
||||
const sin = Math.sin(cy), cos = Math.cos(cy);
|
||||
const nx = ix / mag, nz = iz / mag;
|
||||
const dx = nx * cos - nz * sin;
|
||||
const dz = -nx * sin - nz * cos;
|
||||
const target = (input.run ? T.runSpeed : T.walkSpeed) * Math.min(1, mag) * slow;
|
||||
wantX = dx * target; wantZ = dz * target;
|
||||
this.facing = turnToward(this.facing, Math.atan2(dx, dz), T.turnRate * dt);
|
||||
}
|
||||
}
|
||||
// approach the wanted velocity at a fixed accel (both directions — stopping is the same law)
|
||||
const dvx = wantX - this.vel.x, dvz = wantZ - this.vel.z;
|
||||
const dvm = Math.hypot(dvx, dvz);
|
||||
const step = T.accel * dt;
|
||||
if (dvm <= step || dvm < 1e-6) { this.vel.x = wantX; this.vel.z = wantZ; }
|
||||
else { this.vel.x += dvx / dvm * step; this.vel.z += dvz / dvm * step; }
|
||||
|
||||
// --- gust shove: pressure ∝ speed², gust only, never while you're already on the ground ---
|
||||
const grounded = this.state === 'knocked' || this.state === 'getup';
|
||||
if (!grounded && this.gust > T.shoveGustMin && ws > 1e-3) {
|
||||
const a = T.shoveK * ws * ws;
|
||||
this.shove.x += (wx / ws) * a * dt;
|
||||
this.shove.z += (wz / ws) * a * dt;
|
||||
}
|
||||
const bleed = Math.exp(-T.shoveDamp * dt);
|
||||
this.shove.x *= bleed; this.shove.z *= bleed;
|
||||
|
||||
this.pos.x += (this.vel.x + this.shove.x) * dt;
|
||||
this.pos.z += (this.vel.z + this.shove.z) * dt;
|
||||
this.pos.y = this.groundAt(this.pos.x, this.pos.z);
|
||||
|
||||
// --- body pitch: the sim owns it so a knockdown is deterministic and falls DOWNWIND,
|
||||
// which a canned clip can't do. player.js just reads sim.pitch + sim.knockDir. ---
|
||||
const wantPitch = this.state === 'knocked' ? 1
|
||||
: this.state === 'getup' ? Math.max(0, 1 - this.stateT / (st.secs || 1))
|
||||
: 0;
|
||||
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) {
|
||||
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) {
|
||||
this.setState(st.next, t);
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
157
web/world/js/rigging.js
Normal file
157
web/world/js/rigging.js
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* rigging.js — prep-phase rig selection and hardware economy. [Lane B]
|
||||
*
|
||||
* The money half of the sail. Ports the prototype's economy verbatim ($80
|
||||
* budget, $5/$15/$30 hardware, $15 spare) and adds the state machine around it:
|
||||
* which anchors are rigged, what hangs at each corner, how tight, how many
|
||||
* spares in the bag.
|
||||
*
|
||||
* Kept three-free and DOM-free like sail.js so it is testable headless. The
|
||||
* picking/DOM layer is deliberately NOT here yet — it needs Lane A's camera and
|
||||
* anchor markers, which do not exist at time of writing; see createRiggingUI at
|
||||
* the bottom for the seam it will plug into.
|
||||
*/
|
||||
|
||||
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
|
||||
import { orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
|
||||
export { START_BUDGET, SPARE_COST };
|
||||
export const MAX_CORNERS = 4;
|
||||
export const DEFAULT_TENSION = 1.0;
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
const OK = { ok: true };
|
||||
const fail = (reason) => ({ ok: false, reason });
|
||||
|
||||
export class RiggingSession {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {Array} opts.anchors world.anchors — [{id, pos, type, sway?}]
|
||||
* @param {number} opts.budget starting cash
|
||||
*/
|
||||
constructor({ anchors = [], budget = START_BUDGET } = {}) {
|
||||
this.anchors = anchors;
|
||||
this.budget = budget;
|
||||
this.tension = DEFAULT_TENSION;
|
||||
this.spares = 0;
|
||||
/** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */
|
||||
this.picks = [];
|
||||
}
|
||||
|
||||
get spent() { return START_BUDGET - this.budget; }
|
||||
get canStart() { return this.picks.length === MAX_CORNERS; }
|
||||
isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); }
|
||||
pickOf(anchorId) { return this.picks.find((p) => p.anchorId === anchorId) || null; }
|
||||
|
||||
/** Charge (or refund, when amount is negative) against the budget. */
|
||||
_spend(amount) {
|
||||
if (this.budget - amount < 0) return false;
|
||||
this.budget -= amount;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Rig a corner at an anchor, starting on the cheapest hardware (prototype). */
|
||||
rig(anchorId) {
|
||||
const a = this.anchors.find((x) => x.id === anchorId);
|
||||
if (!a) return fail('no such anchor');
|
||||
if (this.isRigged(anchorId)) return fail('already rigged');
|
||||
if (this.picks.length >= MAX_CORNERS) return fail('a sail has four corners');
|
||||
if (!this._spend(HARDWARE[0].cost)) return fail('not enough budget');
|
||||
this.picks.push({ anchorId, hw: HARDWARE[0] });
|
||||
this._reorder();
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unrig a corner and refund its hardware. Not in the prototype (which had no
|
||||
* way back from a misclick) but it is a pure refund, so it costs the economy
|
||||
* nothing and saves the player a restart.
|
||||
*/
|
||||
unrig(anchorId) {
|
||||
const i = this.picks.findIndex((p) => p.anchorId === anchorId);
|
||||
if (i < 0) return fail('not rigged');
|
||||
this.budget += this.picks[i].hw.cost;
|
||||
this.picks.splice(i, 1);
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Cycle a corner's hardware to the next tier, paying (or refunding) the difference. */
|
||||
cycleHardware(anchorId) {
|
||||
const p = this.pickOf(anchorId);
|
||||
if (!p) return fail('not rigged');
|
||||
const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length];
|
||||
if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget');
|
||||
p.hw = next;
|
||||
return OK;
|
||||
}
|
||||
|
||||
setHardware(anchorId, hw) {
|
||||
const p = this.pickOf(anchorId);
|
||||
if (!p) return fail('not rigged');
|
||||
if (!HARDWARE.includes(hw)) return fail('unknown hardware');
|
||||
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
|
||||
p.hw = hw;
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** 0.6 loose (soaks gusts, flogs) .. 1.4 drum tight (no flap, shock-loads). */
|
||||
setTension(v) {
|
||||
this.tension = clamp(v, TENSION_MIN, TENSION_MAX);
|
||||
return this.tension;
|
||||
}
|
||||
|
||||
/** Spares are what Lane D's hold-E re-rig consumes mid-storm. */
|
||||
setSpares(n) {
|
||||
n = Math.max(0, Math.floor(n));
|
||||
const delta = (n - this.spares) * SPARE_COST;
|
||||
if (!this._spend(delta)) return fail('not enough budget');
|
||||
this.spares = n;
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring-order the picks by angle around their ground-plane centroid, so corner
|
||||
* i of the cloth grid always maps to a neighbouring anchor. Without it,
|
||||
* picking anchors in a silly order knots the sail through itself.
|
||||
*/
|
||||
_reorder() {
|
||||
if (this.picks.length < MAX_CORNERS) return;
|
||||
const byId = new Map(this.picks.map((p) => [p.anchorId, p]));
|
||||
const ring = orderRing(this.picks.map((p) => this.anchors.find((a) => a.id === p.anchorId)));
|
||||
this.picks = ring.map((a) => byId.get(a.id));
|
||||
}
|
||||
|
||||
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
|
||||
commit(rig) {
|
||||
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
|
||||
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
|
||||
}
|
||||
|
||||
/** Everything the HUD needs to draw the prep panel, in one read. */
|
||||
get summary() {
|
||||
return {
|
||||
budget: this.budget,
|
||||
spent: this.spent,
|
||||
tension: this.tension,
|
||||
spares: this.spares,
|
||||
canStart: this.canStart,
|
||||
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
|
||||
weakest: this.picks.length
|
||||
? this.picks.reduce((w, p) => (p.hw.rating < w.hw.rating ? p : w)).anchorId
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prep-phase picking UI.
|
||||
*
|
||||
* Deliberately unimplemented: it needs Lane A's camera, renderer canvas and
|
||||
* anchor markers to raycast against, none of which exist yet. RiggingSession
|
||||
* above holds all the rules and is fully tested, so this stays a thin
|
||||
* click-to-session adapter once M0 lands. See THREADS.md.
|
||||
*/
|
||||
export async function createRiggingUI() {
|
||||
throw new Error('rigging UI lands once Lane A has a camera and anchor markers — see THREADS.md');
|
||||
}
|
||||
195
web/world/js/rigging.selftest.js
Normal file
195
web/world/js/rigging.selftest.js
Normal file
@ -0,0 +1,195 @@
|
||||
/**
|
||||
* rigging.selftest.js — assert suite for the prep-phase economy. [Lane B]
|
||||
*
|
||||
* Same shape as sail.selftest.js: exports RIGGING_TESTS as [name, fn] pairs so
|
||||
* one set of asserts runs under both Lane A's selftest.html (via
|
||||
* js/tests/b.test.js) and node.
|
||||
*/
|
||||
|
||||
import { RiggingSession } from './rigging.js';
|
||||
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
|
||||
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
|
||||
/** Lane A's real yard (THREADS: "yard layout is now FACT"), trimmed to what the economy needs. */
|
||||
export const ANCHORS = [
|
||||
{ id: 'h1', type: 'house', pos: { x: -5, y: 2.6, z: -9.9 } },
|
||||
{ id: 'h2', type: 'house', pos: { x: 0, y: 2.6, z: -9.9 } },
|
||||
{ id: 'h3', type: 'house', pos: { x: 5, y: 2.6, z: -9.9 } },
|
||||
{ id: 't1', type: 'tree', pos: { x: -9, y: 3.2, z: 2 } },
|
||||
{ id: 't2', type: 'tree', pos: { x: 8, y: 3.1, z: -2 } },
|
||||
{ id: 'p1', type: 'post', pos: { x: -6.4, y: 3.9, z: 7.4 } },
|
||||
{ id: 'p2', type: 'post', pos: { x: 5.3, y: 3.9, z: 8 } },
|
||||
].map((a) => ({ ...a, sway: () => a.pos }));
|
||||
|
||||
const session = () => new RiggingSession({ anchors: ANCHORS });
|
||||
|
||||
const TESTS = [];
|
||||
const test = (name, fn) => TESTS.push([name, fn]);
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
|
||||
test('rigging four corners charges the cheapest hardware each', () => {
|
||||
const s = session();
|
||||
for (const id of ['h1', 'h3', 'p1', 'p2']) assert(s.rig(id).ok, `rig ${id} failed`);
|
||||
assert(s.budget === START_BUDGET - 4 * CARABINER.cost, `budget $${s.budget}`);
|
||||
assert(s.canStart, 'four corners should be startable');
|
||||
return `$${START_BUDGET} -> $${s.budget} after four carabiners`;
|
||||
});
|
||||
|
||||
test('a sail has four corners, not five', () => {
|
||||
const s = session();
|
||||
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
|
||||
const r = s.rig('t1');
|
||||
assert(!r.ok && r.reason === 'a sail has four corners', `fifth corner allowed: ${JSON.stringify(r)}`);
|
||||
assert(s.budget === START_BUDGET - 4 * CARABINER.cost, 'refused corner should not be charged');
|
||||
return 'fifth pick refused and not charged';
|
||||
});
|
||||
|
||||
test('hardware cycles up, charging only the difference', () => {
|
||||
const s = session();
|
||||
s.rig('h1');
|
||||
assert(s.cycleHardware('h1').ok, 'cycle to shackle failed');
|
||||
assert(s.pickOf('h1').hw === SHACKLE, 'expected shackle');
|
||||
assert(s.budget === START_BUDGET - SHACKLE.cost, `budget $${s.budget} should be $${START_BUDGET - SHACKLE.cost}`);
|
||||
s.cycleHardware('h1');
|
||||
assert(s.pickOf('h1').hw === RATED, 'expected rated shackle');
|
||||
assert(s.budget === START_BUDGET - RATED.cost, `budget $${s.budget}`);
|
||||
return `carabiner -> shackle -> rated, paid $${RATED.cost} total`;
|
||||
});
|
||||
|
||||
test('cycling past the top tier wraps and refunds', () => {
|
||||
const s = session();
|
||||
s.rig('h1');
|
||||
s.cycleHardware('h1'); s.cycleHardware('h1'); // -> rated
|
||||
s.cycleHardware('h1'); // -> wraps to carabiner
|
||||
assert(s.pickOf('h1').hw === CARABINER, 'expected wrap back to carabiner');
|
||||
assert(s.budget === START_BUDGET - CARABINER.cost, `budget $${s.budget} — wrap should refund the difference`);
|
||||
return `wrapped and refunded back to $${s.budget}`;
|
||||
});
|
||||
|
||||
test('unrig refunds exactly what the corner cost', () => {
|
||||
const s = session();
|
||||
s.rig('h1');
|
||||
s.cycleHardware('h1'); s.cycleHardware('h1'); // rated, $30
|
||||
assert(s.unrig('h1').ok, 'unrig failed');
|
||||
assert(s.budget === START_BUDGET, `budget $${s.budget} should be back to $${START_BUDGET}`);
|
||||
assert(!s.isRigged('h1'), 'h1 should be free again');
|
||||
return 'full refund, no leak';
|
||||
});
|
||||
|
||||
test('spares cost real money and refund', () => {
|
||||
const s = session();
|
||||
assert(s.setSpares(1).ok, 'buying a spare failed');
|
||||
assert(s.budget === START_BUDGET - SPARE_COST, `budget $${s.budget}`);
|
||||
s.setSpares(0);
|
||||
assert(s.budget === START_BUDGET && s.spares === 0, 'selling the spare back should restore budget');
|
||||
return `spare costs $${SPARE_COST}, refunds clean`;
|
||||
});
|
||||
|
||||
test('budget is a real wall', () => {
|
||||
const s = session();
|
||||
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); // $20, $60 left
|
||||
s.cycleHardware('h1'); s.cycleHardware('h1'); // -> rated, $25 more, $35 left
|
||||
s.cycleHardware('h3'); s.cycleHardware('h3'); // -> rated, $25 more, $10 left
|
||||
s.cycleHardware('p1'); // -> shackle, $10, $0 left
|
||||
const broke = s.cycleHardware('p2');
|
||||
assert(!broke.ok && broke.reason === 'not enough budget', `overspend allowed: ${JSON.stringify(broke)}`);
|
||||
assert(s.budget === 0, `budget $${s.budget}`);
|
||||
assert(s.pickOf('p2').hw === CARABINER, 'refused upgrade should not have applied');
|
||||
return 'refused the upgrade that would have gone negative';
|
||||
});
|
||||
|
||||
// DESIGN.md: "good hardware everywhere is unaffordable. You *will* field one
|
||||
// dodgy corner — the game is choosing which one." If this ever passes, the
|
||||
// central economic tension of the game is gone and the budget is decoration.
|
||||
// contracts.js's HARDWARE comment names this as the shape retuning had to keep.
|
||||
test('you cannot afford good hardware on all four corners', () => {
|
||||
const s = session();
|
||||
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
|
||||
let upgraded = 0;
|
||||
for (const id of ['h1', 'h3', 'p1', 'p2']) if (s.setHardware(id, RATED).ok) upgraded++;
|
||||
assert(upgraded < 4, `all four corners got rated shackles with $${START_BUDGET} — no compromise left to make`);
|
||||
assert(upgraded >= 2, `only ${upgraded} rated corners affordable — budget may be too tight to be interesting`);
|
||||
return `$${START_BUDGET} buys ${upgraded}/4 rated corners, then you are choosing your weak link`;
|
||||
});
|
||||
|
||||
test('picks come back ring-ordered however you click them', () => {
|
||||
const s = session();
|
||||
// deliberately crossing order: two diagonals first
|
||||
for (const id of ['h1', 'p2', 'h3', 'p1']) s.rig(id);
|
||||
const ids = s.picks.map((p) => p.anchorId);
|
||||
// a valid ring puts h1 opposite p2 (they are diagonal across the yard)
|
||||
const opposite = ids[(ids.indexOf('h1') + 2) % 4];
|
||||
assert(opposite === 'p2', `h1 should sit opposite p2 in the ring, got ${ids.join(',')}`);
|
||||
return `clicked h1,p2,h3,p1 -> ring ${ids.join(' -> ')}`;
|
||||
});
|
||||
|
||||
test('tension clamps to the rigging range', () => {
|
||||
const s = session();
|
||||
assert(s.setTension(99) === TENSION_MAX, 'over-tight should clamp');
|
||||
assert(s.setTension(0) === TENSION_MIN, 'over-loose should clamp');
|
||||
s.setTension(1.15);
|
||||
assert(s.tension === 1.15, 'in-range tension should pass through');
|
||||
return `clamped to ${TENSION_MIN}..${TENSION_MAX}`;
|
||||
});
|
||||
|
||||
test('commit hands a working rig to the sim', () => {
|
||||
const s = session();
|
||||
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
|
||||
s.setHardware('h1', RATED);
|
||||
s.setTension(1.1);
|
||||
const rig = s.commit(new SailRig({ anchors: ANCHORS }));
|
||||
assert(rig.rigged, 'rig should be rigged');
|
||||
assert(rig.corners.length === 4, 'rig should have four corners');
|
||||
assert(rig.tension === 1.1, `rig tension ${rig.tension}`);
|
||||
assert(rig.corners.find((c) => c.anchorId === 'h1').hw === RATED, 'h1 should have carried its rated shackle into the sim');
|
||||
const wind = { sample: () => ({ x: 0, y: 0, z: 12 }) };
|
||||
for (let i = 0; i < 240; i++) rig.step(1 / 60, wind, i / 60);
|
||||
assert(rig.corners.every((c) => Number.isFinite(c.load)), 'committed rig went NaN');
|
||||
return `committed and stepped 4 s clean over the real yard, max load ${(rig.maxLoad() / 1000).toFixed(2)} kN`;
|
||||
});
|
||||
|
||||
test('commit refuses an unfinished rig', () => {
|
||||
const s = session();
|
||||
s.rig('h1'); s.rig('h3');
|
||||
let threw = false;
|
||||
try { s.commit(new SailRig({ anchors: ANCHORS })); } catch { threw = true; }
|
||||
assert(threw, 'committing two corners should throw');
|
||||
return 'two corners refused';
|
||||
});
|
||||
|
||||
test('summary names the weak link for the HUD', () => {
|
||||
const s = session();
|
||||
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
|
||||
s.setHardware('h1', RATED); s.setHardware('h3', SHACKLE); s.setHardware('p1', SHACKLE);
|
||||
const sum = s.summary;
|
||||
assert(sum.weakest === 'p2', `weakest should be the lone carabiner p2, got ${sum.weakest}`);
|
||||
assert(sum.corners.length === 4, 'summary should list four corners');
|
||||
return `weak link flagged: ${sum.weakest}, $${sum.budget} left`;
|
||||
});
|
||||
|
||||
export const RIGGING_TESTS = TESTS;
|
||||
|
||||
export function runRiggingSelftest() {
|
||||
const results = TESTS.map(([name, fn]) => {
|
||||
try { return { name, pass: true, detail: fn() || '' }; }
|
||||
catch (e) { return { name, pass: false, detail: e.message }; }
|
||||
});
|
||||
return { pass: results.every((r) => r.pass), results };
|
||||
}
|
||||
|
||||
function report(out) {
|
||||
const lines = out.results.map(
|
||||
(r) => `${r.pass ? 'PASS' : 'FAIL'} ${r.name}${r.detail ? `\n ${r.detail}` : ''}`
|
||||
);
|
||||
return `${lines.join('\n')}\n\n${out.pass ? 'ALL GREEN' : 'FAILURES'} — ${out.results.filter((r) => r.pass).length}/${out.results.length}`;
|
||||
}
|
||||
|
||||
if (typeof process !== 'undefined' && process.versions?.node && import.meta.filename === process.argv[1]) {
|
||||
const out = runRiggingSelftest();
|
||||
console.log(report(out));
|
||||
process.exit(out.pass ? 0 : 1);
|
||||
}
|
||||
|
||||
export { report };
|
||||
635
web/world/js/sail.js
Normal file
635
web/world/js/sail.js
Normal file
@ -0,0 +1,635 @@
|
||||
/**
|
||||
* sail.js — shade sail cloth simulation, corner loads, hardware failure. [Lane B]
|
||||
*
|
||||
* A 3D verlet cloth on a bilinear patch between 4 anchors. Wind pressure is
|
||||
* applied per FACE, not per node, which is the whole point: a twisted (hypar)
|
||||
* sail turns most of its faces edge-on to the wind and sheds load, while a flat
|
||||
* one presents every face square-on and catches everything. That difference is
|
||||
* the game's thesis and it is asserted in sail.selftest.js.
|
||||
*
|
||||
* Units are SI throughout: metres, kilograms, seconds, newtons. Corner loads
|
||||
* come out in real newtons and hardware ratings are real working load limits,
|
||||
* so a 5x5 m sail in a 34 m/s storm genuinely puts ~1-4 kN on a corner — which
|
||||
* is genuinely why real shade sails use 3 kN+ shackles.
|
||||
*
|
||||
* The sim core holds no THREE types: nodes are plain Float64Arrays, so the hot
|
||||
* loop allocates nothing, replays bit-for-bit, and runs headless under node
|
||||
* (see sail.selftest.js) as well as in Lane A's selftest.html. three.js only
|
||||
* appears in createSailView(), which is imported lazily.
|
||||
*/
|
||||
|
||||
import { Emitter, FIXED_DT, HARDWARE } from './contracts.js';
|
||||
|
||||
export { HARDWARE };
|
||||
|
||||
// ---------- sim tunables ----------
|
||||
const SIM_DT = FIXED_DT; // sim always steps at a fixed rate; step() accumulates
|
||||
const MAX_SUBSTEPS = 5; // spiral-of-death guard when the frame hitches
|
||||
const RELAX_ITERS = 5; // FABRIC_K is calibrated against this; changing it rescales loads
|
||||
const GRAVITY = -9.81;
|
||||
|
||||
// ---------- aerodynamics ----------
|
||||
// 0.5 * air density (1.225) * flat-plate drag coefficient (~1.4).
|
||||
// Newtons per m^2 of face area per (m/s)^2 of normal-on airflow.
|
||||
const PRESSURE_COEFF = 0.86;
|
||||
const TANGENT_COEFF = 0.02; // skin friction dragging along the face
|
||||
const MAX_NORMAL_SPEED = 45; // clamp on the normal-on component, m/s — stability in extreme gusts
|
||||
|
||||
// ---------- fabric ----------
|
||||
const FABRIC_DENSITY = 0.32; // kg/m^2, typical knitted shade cloth
|
||||
// Axial stiffness of one grid spring, N/m — roughly E*t*width/length for
|
||||
// knitted HDPE mesh. Fed to the solver as a compliance (1/k), not used to
|
||||
// convert stretch into force: see _measureLoads for why that distinction is
|
||||
// the whole ballgame.
|
||||
const FABRIC_K = 100000;
|
||||
const K_COMPRESS = 0.08; // cloth resists stretch hard, compression barely (from prototype)
|
||||
const K_BEND = 0.04;
|
||||
const COMP_STRETCH = 1 / FABRIC_K;
|
||||
const COMP_COMPRESS = 1 / (FABRIC_K * K_COMPRESS);
|
||||
const COMP_BEND = 1 / (FABRIC_K * K_BEND);
|
||||
const VEL_DAMP = 0.995; // light; relative-wind drag supplies the real damping
|
||||
|
||||
// ---------- failure ----------
|
||||
const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
|
||||
const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
|
||||
const LOAD_TAU = 0.11; // load meter smoothing time constant, s
|
||||
|
||||
export const TENSION_MIN = 0.6;
|
||||
export const TENSION_MAX = 1.4;
|
||||
|
||||
/**
|
||||
* How much pre-strain the tension dial actually commands, per unit of dial.
|
||||
* Dial 1.0 is neutral (rest length = as-cut), 1.4 is drum tight, 0.6 is loose.
|
||||
*
|
||||
* The prototype used `rest = rest / tension`, which on its 2D arbitrary scale
|
||||
* was harmless. In real newtons it is not: it asks for 17% pre-strain at dial
|
||||
* 1.2 and 29% at 1.4 — i.e. stretching an 18 m sail by three metres — and it
|
||||
* put 68 kN on a corner of the real yard's biggest quad before any wind blew.
|
||||
*
|
||||
* 0.10 puts dial 1.4 at 4% pre-strain. Measured: it swings a 5x5 m rig's peak
|
||||
* load 2.1x from loose to tight, so the dial is a real decision; and it redlines
|
||||
* the yard's 192 m2 quad at 8.3 kN drum-tight, which blows even a rated shackle
|
||||
* — correctly, because you cannot drum-tighten 192 m2 of cloth on $30 of
|
||||
* hardware. The load bars show that during prep, which is where it should be
|
||||
* learned.
|
||||
*/
|
||||
const PRE_STRAIN = 0.10;
|
||||
const TRIM_MIN = 0.85;
|
||||
const TRIM_MAX = 1.15;
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
/**
|
||||
* Order 4 anchors into a non-self-intersecting ring by angle around their
|
||||
* centroid, projected onto the ground plane. Ported from the prototype's
|
||||
* orderRing; without it, picking corners in a silly order knots the sail.
|
||||
*/
|
||||
export function orderRing(anchors) {
|
||||
const n = anchors.length;
|
||||
let cx = 0, cz = 0;
|
||||
for (const a of anchors) { cx += a.pos.x; cz += a.pos.z; }
|
||||
cx /= n; cz /= n;
|
||||
return [...anchors].sort(
|
||||
(a, b) => Math.atan2(a.pos.z - cz, a.pos.x - cx) - Math.atan2(b.pos.z - cz, b.pos.x - cx)
|
||||
);
|
||||
}
|
||||
|
||||
export class SailRig {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {Array} opts.anchors world.anchors — see contracts.js Anchor
|
||||
* @param {number} opts.gridN nodes per side (default 10)
|
||||
* @param {number} opts.porosity 0 = solid membrane, ~0.3 = knitted shade cloth (blows through, less load)
|
||||
*/
|
||||
constructor({ anchors = [], gridN = 10, porosity = 0 } = {}) {
|
||||
this.anchors = anchors;
|
||||
this.N = gridN;
|
||||
this.porosity = porosity;
|
||||
this.corners = [];
|
||||
/** Emits 'break' and 'repair' as {type, corner} — contracts.js SailRig. */
|
||||
this.events = new Emitter();
|
||||
this.tension = 1.0;
|
||||
this.t = 0;
|
||||
this.rigged = false;
|
||||
this._acc = 0;
|
||||
// scratch, reused every face to keep the hot loop allocation-free
|
||||
this._probe = { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rig the sail across 4 anchors.
|
||||
* @param {string[]} anchorIds 4 anchor ids; reordered into a ring internally
|
||||
* @param {object[]} hwChoices hardware per anchor id, same order as anchorIds
|
||||
* @param {number} tension 0.6 (loose, flogs) .. 1.4 (drum tight, shock-loads)
|
||||
*/
|
||||
attach(anchorIds, hwChoices, tension = 1.0) {
|
||||
if (anchorIds.length !== 4) throw new Error(`sail needs exactly 4 corners, got ${anchorIds.length}`);
|
||||
|
||||
const picked = anchorIds.map((id) => {
|
||||
const a = this.anchors.find((x) => x.id === id);
|
||||
if (!a) throw new Error(`unknown anchor "${id}"`);
|
||||
return a;
|
||||
});
|
||||
const hwById = new Map(anchorIds.map((id, i) => [id, hwChoices[i] || HARDWARE[0]]));
|
||||
|
||||
const ring = orderRing(picked);
|
||||
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
|
||||
this.corners = ring.map((a) => ({
|
||||
anchorId: a.id,
|
||||
anchor: a,
|
||||
hw: hwById.get(a.id),
|
||||
load: 0,
|
||||
peakLoad: 0,
|
||||
overload: 0,
|
||||
broken: false,
|
||||
trim: 1.0,
|
||||
loadVec: { x: 0, y: 0, z: 0 }, // reaction direction, not just magnitude — see _measureLoads
|
||||
}));
|
||||
|
||||
this._build(ring);
|
||||
this.rigged = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
_build(ring) {
|
||||
const N = this.N;
|
||||
const nodeCount = N * N;
|
||||
this.pos = new Float64Array(nodeCount * 3);
|
||||
this.prev = new Float64Array(nodeCount * 3);
|
||||
this.force = new Float64Array(nodeCount * 3);
|
||||
this.invMass = new Float64Array(nodeCount);
|
||||
|
||||
// Bilinear patch across the 4 corners. Because the anchors sit at different
|
||||
// heights, this initial surface is already a hypar — the sim just relaxes it.
|
||||
const [c0, c1, c2, c3] = ring.map((a) => a.pos);
|
||||
for (let v = 0; v < N; v++) {
|
||||
for (let u = 0; u < N; u++) {
|
||||
const fu = u / (N - 1), fv = v / (N - 1);
|
||||
const i = (v * N + u) * 3;
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const ax = ['x', 'y', 'z'][k];
|
||||
const top = (1 - fu) * c0[ax] + fu * c1[ax];
|
||||
const bot = (1 - fu) * c3[ax] + fu * c2[ax];
|
||||
this.pos[i + k] = this.prev[i + k] = (1 - fv) * top + fv * bot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const idx = (u, v) => v * N + u;
|
||||
this.cornerIdx = [idx(0, 0), idx(N - 1, 0), idx(N - 1, N - 1), idx(0, N - 1)];
|
||||
|
||||
// springs: structural + shear carry load; bend only resists folding
|
||||
this.springs = [];
|
||||
const link = (a, b, kind) => {
|
||||
const ax = a * 3, bx = b * 3;
|
||||
const dx = this.pos[bx] - this.pos[ax];
|
||||
const dy = this.pos[bx + 1] - this.pos[ax + 1];
|
||||
const dz = this.pos[bx + 2] - this.pos[ax + 2];
|
||||
this.springs.push({ a, b, restBase: Math.hypot(dx, dy, dz), rest: 0, kind });
|
||||
};
|
||||
for (let v = 0; v < N; v++) {
|
||||
for (let u = 0; u < N; u++) {
|
||||
if (u < N - 1) link(idx(u, v), idx(u + 1, v), 'struct');
|
||||
if (v < N - 1) link(idx(u, v), idx(u, v + 1), 'struct');
|
||||
if (u < N - 1 && v < N - 1) {
|
||||
link(idx(u, v), idx(u + 1, v + 1), 'shear');
|
||||
link(idx(u + 1, v), idx(u, v + 1), 'shear');
|
||||
}
|
||||
if (u < N - 2) link(idx(u, v), idx(u + 2, v), 'bend');
|
||||
if (v < N - 2) link(idx(u, v), idx(u, v + 2), 'bend');
|
||||
}
|
||||
}
|
||||
|
||||
// XPBD Lagrange multipliers, one per spring, reset every substep
|
||||
this.lambda = new Float64Array(this.springs.length);
|
||||
|
||||
// Springs meeting each corner, kept as {spring, index} so the load meter can
|
||||
// look up each one's multiplier. Bend springs are included: the hardware
|
||||
// physically carries every element that touches it, and leaving them out
|
||||
// under-reports the reaction and breaks the statics balance.
|
||||
this.cornerSprings = this.cornerIdx.map((ci) =>
|
||||
this.springs
|
||||
.map((s, si) => ({ s, si }))
|
||||
.filter(({ s }) => s.a === ci || s.b === ci)
|
||||
);
|
||||
|
||||
// triangles: wind acts per face, and coverage raycasts against these
|
||||
this.tris = new Uint16Array((N - 1) * (N - 1) * 6);
|
||||
let ti = 0;
|
||||
for (let v = 0; v < N - 1; v++) {
|
||||
for (let u = 0; u < N - 1; u++) {
|
||||
const a = idx(u, v), b = idx(u + 1, v), c = idx(u + 1, v + 1), d = idx(u, v + 1);
|
||||
this.tris[ti++] = a; this.tris[ti++] = b; this.tris[ti++] = c;
|
||||
this.tris[ti++] = a; this.tris[ti++] = c; this.tris[ti++] = d;
|
||||
}
|
||||
}
|
||||
|
||||
// grid-space proximity of every node to each corner, for per-corner trim
|
||||
this._cornerWeight = [];
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const cu = [0, N - 1, N - 1, 0][k], cv = [0, 0, N - 1, N - 1][k];
|
||||
const w = new Float64Array(nodeCount);
|
||||
for (let v = 0; v < N; v++) {
|
||||
for (let u = 0; u < N; u++) {
|
||||
const dist = Math.hypot(u - cu, v - cv) / (N - 1);
|
||||
w[idx(u, v)] = Math.max(0, 1 - dist);
|
||||
}
|
||||
}
|
||||
this._cornerWeight.push(w);
|
||||
}
|
||||
|
||||
this.area = this._surfaceArea();
|
||||
const mass = (FABRIC_DENSITY * this.area) / nodeCount;
|
||||
this.nodeMass = mass;
|
||||
this.invMass.fill(1 / mass);
|
||||
|
||||
this._applyRestLengths();
|
||||
this._repin(0);
|
||||
}
|
||||
|
||||
/** Rest lengths shrink as the tension dial rises, modulated per corner by trim. */
|
||||
_applyRestLengths() {
|
||||
for (const s of this.springs) {
|
||||
let wsum = 0, tsum = 0;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const w = this._cornerWeight[k][s.a] + this._cornerWeight[k][s.b];
|
||||
wsum += w;
|
||||
tsum += w * this.corners[k].trim;
|
||||
}
|
||||
const trim = wsum > 1e-9 ? tsum / wsum : 1;
|
||||
s.rest = s.restBase * (1 - PRE_STRAIN * (this.tension * trim - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/** Pinned corners are infinite-mass so springs stretch honestly against them. */
|
||||
_repin(t) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
const ci = this.cornerIdx[k];
|
||||
if (c.broken) {
|
||||
this.invMass[ci] = 1 / this.nodeMass; // freed node — flogging falls out of this
|
||||
continue;
|
||||
}
|
||||
this.invMass[ci] = 0;
|
||||
const p = this._anchorPos(c.anchor, t);
|
||||
this.pos[ci * 3] = p.x; this.pos[ci * 3 + 1] = p.y; this.pos[ci * 3 + 2] = p.z;
|
||||
this.prev[ci * 3] = p.x; this.prev[ci * 3 + 1] = p.y; this.prev[ci * 3 + 2] = p.z;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a corner is pinned right now. `sway(t)` is the ABSOLUTE world
|
||||
* position, not an offset from `pos` (contracts.js Anchor; Lane A called this
|
||||
* out in THREADS). House and post anchors return a constant; tree anchors
|
||||
* wander, and that wander is dynamic load — the reason a tree is the scary
|
||||
* anchor. The returned vector is shared and reused between calls, so read it
|
||||
* immediately and never store it.
|
||||
*/
|
||||
_anchorPos(a, t) {
|
||||
return a.sway ? a.sway(t) : a.pos;
|
||||
}
|
||||
|
||||
_surfaceArea() {
|
||||
let total = 0;
|
||||
for (let i = 0; i < this.tris.length; i += 3) {
|
||||
const a = this.tris[i] * 3, b = this.tris[i + 1] * 3, c = this.tris[i + 2] * 3;
|
||||
const e1x = this.pos[b] - this.pos[a], e1y = this.pos[b + 1] - this.pos[a + 1], e1z = this.pos[b + 2] - this.pos[a + 2];
|
||||
const e2x = this.pos[c] - this.pos[a], e2y = this.pos[c + 1] - this.pos[a + 1], e2z = this.pos[c + 2] - this.pos[a + 2];
|
||||
const nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
|
||||
total += Math.hypot(nx, ny, nz) * 0.5;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the sim. Accumulates real time and burns it in fixed SIM_DT chunks,
|
||||
* so a variable-rate render loop and a fast-forwarded selftest produce
|
||||
* identical traces. Never reads a clock.
|
||||
*
|
||||
* @param {number} dt seconds elapsed since last call
|
||||
* @param {object} wind { sample(pos, t) -> {x,y,z} }
|
||||
* @param {number} t world time, seconds
|
||||
*/
|
||||
step(dt, wind, t) {
|
||||
if (!this.rigged) return;
|
||||
this._acc += dt;
|
||||
let n = 0;
|
||||
while (this._acc >= SIM_DT && n < MAX_SUBSTEPS) {
|
||||
this._substep(SIM_DT, wind, this.t);
|
||||
this._acc -= SIM_DT;
|
||||
this.t += SIM_DT;
|
||||
n++;
|
||||
}
|
||||
if (n === MAX_SUBSTEPS) this._acc = 0; // dropped frames: don't try to catch up
|
||||
}
|
||||
|
||||
_substep(dt, wind, t) {
|
||||
this._accumulateWind(wind, t, dt);
|
||||
this._integrate(dt);
|
||||
this.lambda.fill(0); // XPBD multipliers are per-substep
|
||||
for (let i = 0; i < RELAX_ITERS; i++) this._relax(dt * dt);
|
||||
this._pinCorners(t);
|
||||
this._measureLoads(dt);
|
||||
this._checkFailure(dt);
|
||||
}
|
||||
|
||||
/** Wind force per FACE — the hypar mechanic lives here. */
|
||||
_accumulateWind(wind, t, dt) {
|
||||
const pos = this.pos, prev = this.prev, F = this.force;
|
||||
F.fill(0);
|
||||
const coeff = PRESSURE_COEFF * (1 - this.porosity);
|
||||
const tanCoeff = TANGENT_COEFF * (1 - this.porosity);
|
||||
const invDt = 1 / dt;
|
||||
const probe = this._probe;
|
||||
|
||||
for (let i = 0; i < this.tris.length; i += 3) {
|
||||
const ia = this.tris[i] * 3, ib = this.tris[i + 1] * 3, ic = this.tris[i + 2] * 3;
|
||||
|
||||
const e1x = pos[ib] - pos[ia], e1y = pos[ib + 1] - pos[ia + 1], e1z = pos[ib + 2] - pos[ia + 2];
|
||||
const e2x = pos[ic] - pos[ia], e2y = pos[ic + 1] - pos[ia + 1], e2z = pos[ic + 2] - pos[ia + 2];
|
||||
// |cross| is twice the area and its direction is the face normal
|
||||
let nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
|
||||
const len = Math.hypot(nx, ny, nz);
|
||||
if (len < 1e-9) continue;
|
||||
const area = len * 0.5;
|
||||
nx /= len; ny /= len; nz /= len;
|
||||
|
||||
probe.x = (pos[ia] + pos[ib] + pos[ic]) / 3;
|
||||
probe.y = (pos[ia + 1] + pos[ib + 1] + pos[ic + 1]) / 3;
|
||||
probe.z = (pos[ia + 2] + pos[ib + 2] + pos[ic + 2]) / 3;
|
||||
const w = wind.sample(probe, t);
|
||||
|
||||
// Relative wind, not absolute: as the cloth accelerates downwind the load
|
||||
// bleeds off by itself. This is what stops flogging from exploding.
|
||||
const vx = (pos[ia] - prev[ia] + pos[ib] - prev[ib] + pos[ic] - prev[ic]) / 3 * invDt;
|
||||
const vy = (pos[ia + 1] - prev[ia + 1] + pos[ib + 1] - prev[ib + 1] + pos[ic + 1] - prev[ic + 1]) / 3 * invDt;
|
||||
const vz = (pos[ia + 2] - prev[ia + 2] + pos[ib + 2] - prev[ib + 2] + pos[ic + 2] - prev[ic + 2]) / 3 * invDt;
|
||||
const rx = w.x - vx, ry = w.y - vy, rz = w.z - vz;
|
||||
|
||||
const d = clamp(rx * nx + ry * ny + rz * nz, -MAX_NORMAL_SPEED, MAX_NORMAL_SPEED);
|
||||
// d*|d| rather than d^2: keeps the v^2 magnitude but points the force the
|
||||
// way the wind is actually blowing. A sail is double-sided.
|
||||
const p = coeff * area * d * Math.abs(d);
|
||||
|
||||
const tx = (rx - nx * d) * tanCoeff * area;
|
||||
const ty = (ry - ny * d) * tanCoeff * area;
|
||||
const tz = (rz - nz * d) * tanCoeff * area;
|
||||
|
||||
const fx = (nx * p + tx) / 3, fy = (ny * p + ty) / 3, fz = (nz * p + tz) / 3;
|
||||
F[ia] += fx; F[ia + 1] += fy; F[ia + 2] += fz;
|
||||
F[ib] += fx; F[ib + 1] += fy; F[ib + 2] += fz;
|
||||
F[ic] += fx; F[ic + 1] += fy; F[ic + 2] += fz;
|
||||
}
|
||||
}
|
||||
|
||||
_integrate(dt) {
|
||||
const pos = this.pos, prev = this.prev, F = this.force, im = this.invMass;
|
||||
const dt2 = dt * dt;
|
||||
for (let n = 0; n < im.length; n++) {
|
||||
if (im[n] === 0) continue; // pinned
|
||||
const i = n * 3;
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const a = F[i + k] * im[n] + (k === 1 ? GRAVITY : 0);
|
||||
const x = pos[i + k];
|
||||
const nx = x + (x - prev[i + k]) * VEL_DAMP + a * dt2;
|
||||
prev[i + k] = x;
|
||||
pos[i + k] = nx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* XPBD constraint solve. The plain-PBD version of this is simpler, but its
|
||||
* position corrections carry no force information — the leftover stretch
|
||||
* after a fixed iteration count is solver error, not fabric strain, so
|
||||
* reading load off it measures the solver. XPBD's Lagrange multiplier lambda
|
||||
* is the real constraint impulse, so lambda/dt^2 is a genuine newton value
|
||||
* and the corner reactions balance the applied wind by construction.
|
||||
*/
|
||||
_relax(dt2) {
|
||||
const pos = this.pos, im = this.invMass, lam = this.lambda;
|
||||
for (let si = 0; si < this.springs.length; si++) {
|
||||
const s = this.springs[si];
|
||||
const wa = im[s.a], wb = im[s.b];
|
||||
const w = wa + wb;
|
||||
if (w === 0) continue; // both ends pinned
|
||||
const ia = s.a * 3, ib = s.b * 3;
|
||||
const dx = pos[ib] - pos[ia], dy = pos[ib + 1] - pos[ia + 1], dz = pos[ib + 2] - pos[ia + 2];
|
||||
const d = Math.hypot(dx, dy, dz);
|
||||
if (d < 1e-9) continue;
|
||||
const C = d - s.rest;
|
||||
const compliance = s.kind === 'bend' ? COMP_BEND : C > 0 ? COMP_STRETCH : COMP_COMPRESS;
|
||||
const at = compliance / dt2;
|
||||
const dLambda = (-C - at * lam[si]) / (w + at);
|
||||
lam[si] += dLambda;
|
||||
// grad C is -n for node a and +n for node b, with n = (b - a)/d
|
||||
const nx = dx / d, ny = dy / d, nz = dz / d;
|
||||
pos[ia] -= nx * dLambda * wa; pos[ia + 1] -= ny * dLambda * wa; pos[ia + 2] -= nz * dLambda * wa;
|
||||
pos[ib] += nx * dLambda * wb; pos[ib + 1] += ny * dLambda * wb; pos[ib + 2] += nz * dLambda * wb;
|
||||
}
|
||||
}
|
||||
|
||||
_pinCorners(t) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) continue;
|
||||
const ci = this.cornerIdx[k] * 3;
|
||||
const p = this._anchorPos(c.anchor, t);
|
||||
this.pos[ci] = p.x; this.pos[ci + 1] = p.y; this.pos[ci + 2] = p.z;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Corner load = magnitude of the VECTOR sum of the tensions in the springs
|
||||
* meeting that corner, each read off its XPBD multiplier as |lambda|/dt^2.
|
||||
*
|
||||
* Reading tension as FABRIC_K * leftover-stretch instead looks equivalent and
|
||||
* is not: after a fixed 5 iterations the leftover stretch is solver error, so
|
||||
* that number measures the solver rather than the fabric and comes out ~50x
|
||||
* hot. The multiplier is the actual constraint impulse, which is why the
|
||||
* statics assert balances.
|
||||
*
|
||||
* The vector sum (rather than a scalar total) is what
|
||||
* makes DESIGN.md's anchor-angle mechanic fall out for free: edges pulling in
|
||||
* nearly the same direction add up, edges pulling apart partly cancel — so a
|
||||
* pinched corner really does multiply its own load.
|
||||
*/
|
||||
_measureLoads(dt) {
|
||||
const pos = this.pos, lam = this.lambda;
|
||||
const invDt2 = 1 / (dt * dt);
|
||||
const alpha = 1 - Math.exp(-dt / LOAD_TAU);
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) { c.load = 0; c.loadVec.x = c.loadVec.y = c.loadVec.z = 0; continue; }
|
||||
const ci = this.cornerIdx[k], cix = ci * 3;
|
||||
let sx = 0, sy = 0, sz = 0;
|
||||
for (const { s, si } of this.cornerSprings[k]) {
|
||||
if (lam[si] >= 0) continue; // slack or compressed fabric pulls on nothing
|
||||
const tension = -lam[si] * invDt2; // the multiplier IS the impulse; /dt^2 makes it newtons
|
||||
const o = (s.a === ci ? s.b : s.a) * 3;
|
||||
const dx = pos[o] - pos[cix], dy = pos[o + 1] - pos[cix + 1], dz = pos[o + 2] - pos[cix + 2];
|
||||
const d = Math.hypot(dx, dy, dz);
|
||||
if (d < 1e-9) continue;
|
||||
sx += (dx / d) * tension; sy += (dy / d) * tension; sz += (dz / d) * tension;
|
||||
}
|
||||
c.loadVec.x += (sx - c.loadVec.x) * alpha;
|
||||
c.loadVec.y += (sy - c.loadVec.y) * alpha;
|
||||
c.loadVec.z += (sz - c.loadVec.z) * alpha;
|
||||
const raw = Math.hypot(sx, sy, sz);
|
||||
c.load += (raw - c.load) * alpha;
|
||||
if (c.load > c.peakLoad) c.peakLoad = c.load;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
|
||||
_checkFailure(dt) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) continue;
|
||||
if (c.load > c.hw.rating) c.overload += dt;
|
||||
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
|
||||
if (c.overload > OVERLOAD_SECS) {
|
||||
c.broken = true;
|
||||
c.overload = 0;
|
||||
c.load = 0;
|
||||
// Hand the node its mass back. Everything good about a failure comes
|
||||
// from this one line: the freed corner stops being pinned, so it flies
|
||||
// on the wind and the flogging is emergent rather than animated.
|
||||
// Without it a "blown" corner stays welded in mid-air.
|
||||
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
|
||||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
|
||||
}
|
||||
}
|
||||
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
|
||||
}
|
||||
|
||||
/** Re-rig a blown corner with fresh hardware. Lane D's hold-E repair calls this. */
|
||||
repairCorner(index, hw = HARDWARE[1]) {
|
||||
const c = this.corners[index];
|
||||
if (!c || !c.broken) return false;
|
||||
c.broken = false;
|
||||
c.hw = hw;
|
||||
c.load = 0;
|
||||
c.overload = 0;
|
||||
this._repin(this.t);
|
||||
this.events.emit('repair', { type: 'repair', corner: c, anchorId: c.anchorId, hw: hw.name, t: this.t });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turnbuckle trim at ONE corner (Lane D, 1.2 s hold). Tightens/eases locally. */
|
||||
trimCorner(index, delta) {
|
||||
const c = this.corners[index];
|
||||
if (!c) return false;
|
||||
c.trim = clamp(c.trim + delta, TRIM_MIN, TRIM_MAX);
|
||||
this._dirtyRest = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
setTension(tension) {
|
||||
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
|
||||
if (this.rigged) this._applyRestLengths();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground-projected shade over a rect: the fraction of sample points on the
|
||||
* rect that the sail blocks from the sun. This IS the shade mechanic, so it
|
||||
* raycasts toward the real sun rather than projecting straight down — which
|
||||
* is what lets DESIGN.md's moving and seasonal sun change the answer.
|
||||
*
|
||||
* @param {object} rect world.gardenBed shape: CENTRE (x,z), size (w,d), metres
|
||||
* @param {object} sunDir world.sunDir — unit vector from the ground TOWARD
|
||||
* the sun. A hit means shaded. Defaults to overhead.
|
||||
*/
|
||||
coverageOver(rect, sunDir = { x: 0, y: 1, z: 0 }) {
|
||||
if (!this.rigged) return 0;
|
||||
const len = Math.hypot(sunDir.x, sunDir.y, sunDir.z) || 1;
|
||||
const dx = sunDir.x / len, dy = sunDir.y / len, dz = sunDir.z / len;
|
||||
if (dy <= 0.01) return 0; // sun at or below the horizon casts no useful shade
|
||||
|
||||
const COLS = 6, ROWS = 4; // prototype sampled 6x4 over the garden
|
||||
let hit = 0;
|
||||
for (let i = 0; i < COLS; i++) {
|
||||
for (let j = 0; j < ROWS; j++) {
|
||||
// rect is centre-and-size, so samples straddle (rect.x, rect.z)
|
||||
const ox = rect.x + ((i + 0.5) / COLS - 0.5) * rect.w;
|
||||
const oz = rect.z + ((j + 0.5) / ROWS - 0.5) * rect.d;
|
||||
if (this._rayHitsSail(ox, 0, oz, dx, dy, dz)) hit++;
|
||||
}
|
||||
}
|
||||
return hit / (COLS * ROWS);
|
||||
}
|
||||
|
||||
/** Moller-Trumbore against every face; 162 tris, cheap enough to not bother accelerating. */
|
||||
_rayHitsSail(ox, oy, oz, dx, dy, dz) {
|
||||
const pos = this.pos;
|
||||
for (let i = 0; i < this.tris.length; i += 3) {
|
||||
const a = this.tris[i] * 3, b = this.tris[i + 1] * 3, c = this.tris[i + 2] * 3;
|
||||
const e1x = pos[b] - pos[a], e1y = pos[b + 1] - pos[a + 1], e1z = pos[b + 2] - pos[a + 2];
|
||||
const e2x = pos[c] - pos[a], e2y = pos[c + 1] - pos[a + 1], e2z = pos[c + 2] - pos[a + 2];
|
||||
const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x;
|
||||
const det = e1x * px + e1y * py + e1z * pz;
|
||||
if (Math.abs(det) < 1e-9) continue; // ray parallel to the face
|
||||
const inv = 1 / det;
|
||||
const tx = ox - pos[a], ty = oy - pos[a + 1], tz = oz - pos[a + 2];
|
||||
const u = (tx * px + ty * py + tz * pz) * inv;
|
||||
if (u < 0 || u > 1) continue;
|
||||
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
|
||||
const v = (dx * qx + dy * qy + dz * qz) * inv;
|
||||
if (v < 0 || u + v > 1) continue;
|
||||
const hitT = (e2x * qx + e2y * qy + e2z * qz) * inv;
|
||||
if (hitT > 1e-6) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Sum of the aerodynamic + weight force on the whole sail, N. Used by the statics assert. */
|
||||
netAppliedForce(wind, t) {
|
||||
this._accumulateWind(wind, t, SIM_DT);
|
||||
let fx = 0, fy = 0, fz = 0;
|
||||
for (let n = 0; n < this.invMass.length; n++) {
|
||||
fx += this.force[n * 3];
|
||||
fy += this.force[n * 3 + 1] + GRAVITY * this.nodeMass;
|
||||
fz += this.force[n * 3 + 2];
|
||||
}
|
||||
return { x: fx, y: fy, z: fz };
|
||||
}
|
||||
|
||||
maxLoad() {
|
||||
let m = 0;
|
||||
for (const c of this.corners) if (c.load > m) m = c.load;
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* three.js view over a rig. Imported lazily so the sim core above stays
|
||||
* headless-runnable; call this only from the browser, after Lane A's vendor/
|
||||
* exists. Returns a THREE.Group to add to the scene, with update() per frame.
|
||||
*/
|
||||
export async function createSailView(rig, { color = 0xd8c48a } = {}) {
|
||||
const THREE = await import('../vendor/three.module.js');
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const verts = new Float32Array(rig.pos.length);
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
|
||||
geo.setIndex(new THREE.BufferAttribute(new Uint16Array(rig.tris), 1));
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color, side: THREE.DoubleSide, roughness: 0.92, metalness: 0.0,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.castShadow = true; // the shadow IS the product
|
||||
mesh.receiveShadow = true;
|
||||
mesh.frustumCulled = false; // it flogs well outside its initial bounds
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.add(mesh);
|
||||
group.update = () => {
|
||||
verts.set(rig.pos);
|
||||
geo.attributes.position.needsUpdate = true;
|
||||
geo.computeVertexNormals();
|
||||
geo.computeBoundingSphere();
|
||||
};
|
||||
group.update();
|
||||
return group;
|
||||
}
|
||||
370
web/world/js/sail.selftest.js
Normal file
370
web/world/js/sail.selftest.js
Normal file
@ -0,0 +1,370 @@
|
||||
/**
|
||||
* sail.selftest.js — assert suite for the sail sim. [Lane B]
|
||||
*
|
||||
* Exports SAIL_TESTS as plain [name, fn] pairs so ONE set of asserts runs in
|
||||
* two harnesses: Lane A's selftest.html (via js/tests/b.test.js) and node
|
||||
* (`node web/world/js/sail.selftest.js`) for fast iteration without a browser.
|
||||
* Drives time with fixed-dt loops only — never rAF, never a clock.
|
||||
*
|
||||
* The headline assert is `hypar sheds load vs flat`: it is the game's thesis
|
||||
* stated as a test. If it ever goes red, the sail has stopped being a sail.
|
||||
*/
|
||||
|
||||
import { SailRig } from './sail.js';
|
||||
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
|
||||
|
||||
const SIM_DT = FIXED_DT;
|
||||
|
||||
// ---------- deterministic stub wind ----------
|
||||
// contracts.js ships createStubWind(), and the integration test below uses it.
|
||||
// This local one exists only because the thesis needs the wind DIRECTION swept,
|
||||
// which the shared stub does not expose. Lane C's weather.js replaces both.
|
||||
|
||||
function makeStubWind({ seed = 7, stormLen = 90, dir = { x: 0, y: 0, z: 1 }, calm = false } = {}) {
|
||||
const rand = rng(seed);
|
||||
const gusts = [];
|
||||
for (let t = 3; t < stormLen; t += 5 + rand() * 7) {
|
||||
gusts.push({ start: t, pow: 12 + rand() * 16 + 10 * (t / stormLen) });
|
||||
}
|
||||
const len = Math.hypot(dir.x, dir.y, dir.z) || 1;
|
||||
const dx = dir.x / len, dy = dir.y / len, dz = dir.z / len;
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
|
||||
return {
|
||||
speedAt(t) {
|
||||
if (calm) return 0;
|
||||
let speed = 8 + 26 * Math.min(1, (t / stormLen) * 1.6);
|
||||
for (const g of gusts) {
|
||||
const gt = t - g.start;
|
||||
if (gt < 0 || gt >= 5) continue;
|
||||
if (gt < 1.5) continue; // telegraph: seen, not felt
|
||||
else if (gt < 2.3) speed += g.pow * (gt - 1.5) / 0.8; // ramp
|
||||
else if (gt < 4.0) speed += g.pow; // hold
|
||||
else speed += g.pow * (5.0 - gt); // fade
|
||||
}
|
||||
return speed;
|
||||
},
|
||||
sample(pos, t) {
|
||||
const s = this.speedAt(t);
|
||||
out.x = dx * s; out.y = dy * s; out.z = dz * s;
|
||||
return out;
|
||||
},
|
||||
gustTelegraph: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
const constantWind = (v) => ({ sample: () => v, speedAt: () => Math.hypot(v.x, v.y, v.z), gustTelegraph: () => null });
|
||||
|
||||
// ---------- test rigs ----------
|
||||
// Same 5x5 m footprint, same multiset of corner heights {4.0, 4.0, 2.5, 2.5}.
|
||||
// Only the ARRANGEMENT differs: coplanar (flat, pitched) vs permuted (twisted
|
||||
// hypar). Any load difference is therefore purely geometry, nothing else.
|
||||
|
||||
const FOOT = [
|
||||
{ x: -2.5, z: -2.5 }, { x: 2.5, z: -2.5 }, { x: 2.5, z: 2.5 }, { x: -2.5, z: 2.5 },
|
||||
];
|
||||
export const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5]; // y linear in z -> one plane
|
||||
export const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle
|
||||
|
||||
/** Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position. */
|
||||
export const makeAnchors = (heights) =>
|
||||
FOOT.map((f, i) => {
|
||||
const pos = { x: f.x, y: heights[i], z: f.z };
|
||||
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
|
||||
});
|
||||
|
||||
const ALL_IDS = ['a0', 'a1', 'a2', 'a3'];
|
||||
const UNBREAKABLE = { name: 'test rig', cost: 0, rating: Infinity };
|
||||
|
||||
function rig(heights, { hw = UNBREAKABLE, tension = 1.0, porosity = 0 } = {}) {
|
||||
return new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity })
|
||||
.attach(ALL_IDS, [hw, hw, hw, hw], tension);
|
||||
}
|
||||
|
||||
/** Fixed-dt fast-forward. Returns the peak corner load over the whole run, N. */
|
||||
function runStorm(r, wind, secs, onStep) {
|
||||
const steps = Math.round(secs / SIM_DT);
|
||||
let peak = 0;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
r.step(SIM_DT, wind, i * SIM_DT);
|
||||
const m = r.maxLoad();
|
||||
if (m > peak) peak = m;
|
||||
if (onStep) onStep(r, i);
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
const TESTS = [];
|
||||
const test = (name, fn) => TESTS.push([name, fn]);
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
const kN = (n) => `${(n / 1000).toFixed(2)} kN`;
|
||||
|
||||
// ---------- the suite ----------
|
||||
|
||||
test('sim stays finite through a full storm', () => {
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
runStorm(r, makeStubWind({ stormLen: 90 }), 90);
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'node position went NaN/Infinity');
|
||||
for (const c of r.corners) assert(Number.isFinite(c.load), 'corner load went NaN');
|
||||
return `peak ${kN(r.corners.reduce((m, c) => Math.max(m, c.peakLoad), 0))}`;
|
||||
});
|
||||
|
||||
test('sail sags under gravity when calm', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 6);
|
||||
const N = r.N, mid = (Math.floor(N / 2) * N + Math.floor(N / 2)) * 3;
|
||||
const midY = r.pos[mid + 1];
|
||||
const cornerMeanY = HEIGHTS_FLAT.reduce((a, b) => a + b) / 4;
|
||||
assert(midY < cornerMeanY, `belly (${midY.toFixed(2)}m) should hang below corner mean (${cornerMeanY}m)`);
|
||||
return `belly sags ${(cornerMeanY - midY).toFixed(2)} m below corner plane`;
|
||||
});
|
||||
|
||||
// Newton's third law. This is what pins FABRIC_K to real newtons: if the corner
|
||||
// reactions don't sum to the actual aerodynamic + weight force on the fabric,
|
||||
// the load meter is lying and every kN rating on it is meaningless.
|
||||
test('statics: corner reactions balance the applied force', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 18 });
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, w, 12); // settle
|
||||
// A membrane in steady wind never fully stops moving, so compare the
|
||||
// TIME-AVERAGED reaction against the time-averaged applied force. That is the
|
||||
// momentum balance that must hold; instant by instant it need not.
|
||||
let n = 0, ax = 0, ay = 0, az = 0, rx = 0, ry = 0, rz = 0;
|
||||
for (let i = 0; i < Math.round(4 / SIM_DT); i++) {
|
||||
const t = 12 + i * SIM_DT;
|
||||
r.step(SIM_DT, w, t);
|
||||
const f = r.netAppliedForce(w, t);
|
||||
ax += f.x; ay += f.y; az += f.z;
|
||||
for (const c of r.corners) { rx += c.loadVec.x; ry += c.loadVec.y; rz += c.loadVec.z; }
|
||||
n++;
|
||||
}
|
||||
ax /= n; ay /= n; az /= n; rx /= n; ry /= n; rz /= n;
|
||||
const appliedMag = Math.hypot(ax, ay, az);
|
||||
const err = Math.hypot(rx - ax, ry - ay, rz - az) / appliedMag;
|
||||
assert(err < 0.2, `reactions ${kN(Math.hypot(rx, ry, rz))} vs applied ${kN(appliedMag)} — ${(err * 100).toFixed(0)}% out of balance`);
|
||||
return `applied ${kN(appliedMag)}, reactions ${kN(Math.hypot(rx, ry, rz))}, residual ${(err * 100).toFixed(1)}%`;
|
||||
});
|
||||
|
||||
// THE THESIS. A twisted sail resists bellying into one coherent pocket, so its
|
||||
// worst moment is gentler than a flat sail's worst moment.
|
||||
//
|
||||
// Scored on WORST CASE over wind direction, not per-direction. Lane C's storms
|
||||
// veer, so the player never gets to choose the wind, and worst-case is what the
|
||||
// hardware actually has to survive. Per-direction would be a false assert: a
|
||||
// flat sail sitting edge-on to the wind genuinely does have low drag, and from
|
||||
// that one angle it beats the hypar. Demanding otherwise would mean tuning the
|
||||
// sim into a lie.
|
||||
test('hypar sheds load vs flat, worst case over wind direction (the thesis)', () => {
|
||||
const DIRS = [
|
||||
{ name: 'N', x: 0, z: 1 }, { name: 'NE', x: 0.707, z: 0.707 },
|
||||
{ name: 'E', x: 1, z: 0 }, { name: 'SE', x: 0.707, z: -0.707 },
|
||||
{ name: 'S', x: 0, z: -1 }, { name: 'SW', x: -0.707, z: -0.707 },
|
||||
{ name: 'W', x: -1, z: 0 }, { name: 'NW', x: -0.707, z: 0.707 },
|
||||
];
|
||||
const sweep = (heights) => {
|
||||
let worst = 0, at = '';
|
||||
for (const d of DIRS) {
|
||||
const storm = makeStubWind({ seed: 7, stormLen: 45, dir: { x: d.x, y: 0, z: d.z } });
|
||||
const p = runStorm(rig(heights), storm, 45);
|
||||
if (p > worst) { worst = p; at = d.name; }
|
||||
}
|
||||
return { worst, at };
|
||||
};
|
||||
const flat = sweep(HEIGHTS_FLAT);
|
||||
const hypar = sweep(HEIGHTS_HYPAR);
|
||||
assert(
|
||||
hypar.worst < flat.worst * 0.8,
|
||||
`hypar worst ${kN(hypar.worst)} (${hypar.at}) should be well under flat worst ${kN(flat.worst)} (${flat.at})`
|
||||
);
|
||||
return `flat worst ${kN(flat.worst)} from ${flat.at} -> hypar worst ${kN(hypar.worst)} from ${hypar.at} (sheds ${((1 - hypar.worst / flat.worst) * 100).toFixed(0)}%)`;
|
||||
});
|
||||
|
||||
test('cascade: losing a corner spikes its neighbours', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 22 });
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
runStorm(r, w, 6); // settle
|
||||
const before = Math.max(r.corners[1].load, r.corners[3].load);
|
||||
r.corners[0].broken = true;
|
||||
r._repin(r.t);
|
||||
runStorm(r, w, 2.5); // let the load redistribute
|
||||
const after = Math.max(r.corners[1].load, r.corners[3].load);
|
||||
assert(after >= before * 2, `neighbour went ${kN(before)} -> ${kN(after)}, wanted >= 2x`);
|
||||
return `neighbour ${kN(before)} -> ${kN(after)} (${(after / before).toFixed(1)}x)`;
|
||||
});
|
||||
|
||||
test('determinism: identical inputs give byte-equal load traces', () => {
|
||||
const trace = () => {
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
const w = makeStubWind({ seed: 3, stormLen: 30 });
|
||||
const out = [];
|
||||
runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); });
|
||||
return out;
|
||||
};
|
||||
const a = trace(), b = trace();
|
||||
assert(a.length === b.length, 'traces differ in length');
|
||||
for (let i = 0; i < a.length; i++) assert(a[i] === b[i], `sample ${i} diverged: ${a[i]} vs ${b[i]}`);
|
||||
return `${a.length} load samples identical`;
|
||||
});
|
||||
|
||||
test('determinism: variable frame dt matches fixed dt', () => {
|
||||
// Lane A's render loop delivers ragged dt. The internal accumulator has to
|
||||
// absorb that, or nothing the selftest proves applies to the real game.
|
||||
const w1 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const fixed = rig(HEIGHTS_HYPAR);
|
||||
for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT);
|
||||
|
||||
const w2 = makeStubWind({ seed: 5, stormLen: 20 });
|
||||
const ragged = rig(HEIGHTS_HYPAR);
|
||||
const rand = rng(99);
|
||||
let acc = 0;
|
||||
while (acc < 20) {
|
||||
const dt = 0.004 + rand() * 0.02; // 4-24 ms frames
|
||||
ragged.step(dt, w2, acc);
|
||||
acc += dt;
|
||||
}
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load);
|
||||
assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N between fixed and ragged dt`);
|
||||
}
|
||||
return 'ragged frame times converge on the fixed-dt trace';
|
||||
});
|
||||
|
||||
test('tension dial changes load (drum tight shock-loads)', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const loosePeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 0.7 }), w, 8);
|
||||
const tightPeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 1.35 }), w, 8);
|
||||
assert(tightPeak > loosePeak, `tight ${kN(tightPeak)} should exceed loose ${kN(loosePeak)}`);
|
||||
return `loose ${kN(loosePeak)} vs tight ${kN(tightPeak)}`;
|
||||
});
|
||||
|
||||
test('porous shade cloth carries less load than solid membrane', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const solid = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0 }), w, 8);
|
||||
const porous = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0.35 }), w, 8);
|
||||
assert(porous < solid, `porous ${kN(porous)} should be under solid ${kN(solid)}`);
|
||||
return `solid ${kN(solid)} vs porous ${kN(porous)}`;
|
||||
});
|
||||
|
||||
test('coverage: sail shades the ground under it, not beside it', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 4);
|
||||
// world.gardenBed rects are CENTRE + size, so this bed straddles the origin.
|
||||
const under = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 });
|
||||
const beside = r.coverageOver({ x: 14, z: 14, w: 4, d: 4 });
|
||||
assert(under > 0.9, `ground under the sail only ${(under * 100).toFixed(0)}% shaded`);
|
||||
assert(beside === 0, `ground 14 m away reported ${(beside * 100).toFixed(0)}% shaded`);
|
||||
return `under sail ${(under * 100).toFixed(0)}%, off to the side ${(beside * 100).toFixed(0)}%`;
|
||||
});
|
||||
|
||||
test('coverage tracks a low sun off to the side', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 4);
|
||||
const noon = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0, y: 1, z: 0 });
|
||||
const lowSun = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0.9, y: 0.25, z: 0 });
|
||||
assert(noon > lowSun, `shadow should slide off the bed as the sun drops (noon ${noon}, low ${lowSun})`);
|
||||
return `noon ${(noon * 100).toFixed(0)}% -> low sun ${(lowSun * 100).toFixed(0)}%`;
|
||||
});
|
||||
|
||||
// PLAN3D §7 definition of done, in miniature.
|
||||
test('cheap flat rig cascades; twisted mixed rig survives', () => {
|
||||
const storm = () => makeStubWind({ seed: 11, stormLen: 90 });
|
||||
const cheap = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.35 });
|
||||
runStorm(cheap, storm(), 90);
|
||||
const cheapBroken = cheap.corners.filter((c) => c.broken).length;
|
||||
|
||||
const good = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2], tension: 0.95 });
|
||||
runStorm(good, storm(), 90);
|
||||
const goodBroken = good.corners.filter((c) => c.broken).length;
|
||||
|
||||
assert(cheapBroken >= 2, `flat drum-tight carabiner rig only lost ${cheapBroken} corners — should cascade`);
|
||||
assert(goodBroken === 0, `twisted rated-shackle rig lost ${goodBroken} corners — should survive`);
|
||||
return `cheap flat lost ${cheapBroken}/4, good hypar lost ${goodBroken}/4`;
|
||||
});
|
||||
|
||||
// PLAN3D §5-B: "broken corner frees the node -> flogging is emergent". This
|
||||
// drives a REAL overload failure rather than setting broken by hand, because
|
||||
// hand-setting it was exactly what hid the bug where _checkFailure marked a
|
||||
// corner broken but never gave its node its mass back — so a blown corner
|
||||
// stayed welded in mid-air and the sail never flogged.
|
||||
test('a blown corner is freed and flies (flogging is emergent)', () => {
|
||||
const w = makeStubWind({ seed: 11, stormLen: 90 });
|
||||
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // cheap and tight: this one lets go
|
||||
const broke = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
|
||||
// step until the first corner lets go
|
||||
let i = 0;
|
||||
for (const end = Math.round(90 / SIM_DT); i < end && !broke.length; i++) r.step(SIM_DT, w, i * SIM_DT);
|
||||
assert(broke.length > 0, 'a carabiner rig should have blown a corner somewhere in a 90 s storm');
|
||||
|
||||
const k = r.corners.indexOf(broke[0].corner);
|
||||
const node = r.cornerIdx[k], ci = node * 3;
|
||||
const anchor = r.corners[k].anchor.pos;
|
||||
const before = [r.pos[ci], r.pos[ci + 1], r.pos[ci + 2]];
|
||||
for (let j = 0; j < Math.round(3 / SIM_DT); j++) r.step(SIM_DT, w, (i + j) * SIM_DT);
|
||||
|
||||
const moved = Math.hypot(r.pos[ci] - before[0], r.pos[ci + 1] - before[1], r.pos[ci + 2] - before[2]);
|
||||
const fromAnchor = Math.hypot(r.pos[ci] - anchor.x, r.pos[ci + 1] - anchor.y, r.pos[ci + 2] - anchor.z);
|
||||
|
||||
assert(r.invMass[node] > 0, 'blown corner still has infinite mass — it is welded in mid-air, not flogging');
|
||||
assert(moved > 0.05, `blown corner only drifted ${moved.toFixed(3)} m in 3 s — it is not flogging`);
|
||||
assert(fromAnchor > 0.2, `blown corner is still ${fromAnchor.toFixed(2)} m from its anchor — it never let go`);
|
||||
return `corner ${broke[0].anchorId} blew at t=${broke[0].t.toFixed(1)}s, tore ${fromAnchor.toFixed(2)} m off its anchor and is flying`;
|
||||
});
|
||||
|
||||
test('break and repair emit on the events Emitter', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const r = rig(HEIGHTS_HYPAR, { hw: UNBREAKABLE });
|
||||
const seen = [];
|
||||
r.events.on('break', (e) => seen.push(e));
|
||||
r.events.on('repair', (e) => seen.push(e));
|
||||
|
||||
runStorm(r, w, 4);
|
||||
r.corners[0].broken = true;
|
||||
r._repin(r.t);
|
||||
runStorm(r, w, 1);
|
||||
assert(r.corners[0].load === 0, 'broken corner should carry no load');
|
||||
|
||||
assert(r.repairCorner(0, UNBREAKABLE), 'repairCorner should report success');
|
||||
runStorm(r, w, 3);
|
||||
assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
|
||||
assert(seen.some((e) => e.type === 'repair' && e.corner === r.corners[0]), 'no repair event with {type, corner}');
|
||||
return `repaired corner back to ${kN(r.corners[0].load)}, ${seen.length} event(s) emitted`;
|
||||
});
|
||||
|
||||
test('runs against the shared contracts.js stub wind', () => {
|
||||
// Proves the rig eats the sanctioned Wind implementation, not just my local
|
||||
// stub — so nothing surprises us when Lane C's weather.js drops in.
|
||||
const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[1] });
|
||||
const wind = createStubWind({ seed: 1, stormLen: 90 });
|
||||
const peak = runStorm(r, wind, 90);
|
||||
for (const v of r.pos) assert(Number.isFinite(v), 'went NaN on the shared stub wind');
|
||||
assert(peak > 0, 'shared stub wind produced no load at all');
|
||||
return `90 s on contracts.js stub wind, peak ${kN(peak)}, ${r.corners.filter((c) => c.broken).length}/4 corners lost`;
|
||||
});
|
||||
|
||||
export const SAIL_TESTS = TESTS;
|
||||
|
||||
export function runSailSelftest() {
|
||||
const results = TESTS.map(([name, fn]) => {
|
||||
try { return { name, pass: true, detail: fn() || '' }; }
|
||||
catch (e) { return { name, pass: false, detail: e.message }; }
|
||||
});
|
||||
return { pass: results.every((r) => r.pass), results };
|
||||
}
|
||||
|
||||
export function report(out) {
|
||||
const lines = out.results.map(
|
||||
(r) => `${r.pass ? 'PASS' : 'FAIL'} ${r.name}${r.detail ? `\n ${r.detail}` : ''}`
|
||||
);
|
||||
return `${lines.join('\n')}\n\n${out.pass ? 'ALL GREEN' : 'FAILURES'} — ${out.results.filter((r) => r.pass).length}/${out.results.length}`;
|
||||
}
|
||||
|
||||
// Run only when invoked directly; importing this module must not run the suite.
|
||||
if (typeof process !== 'undefined' && process.versions?.node && import.meta.filename === process.argv[1]) {
|
||||
const out = runSailSelftest();
|
||||
console.log(report(out));
|
||||
process.exit(out.pass ? 0 : 1);
|
||||
}
|
||||
|
||||
export { makeStubWind };
|
||||
470
web/world/js/skyfx.js
Normal file
470
web/world/js/skyfx.js
Normal file
@ -0,0 +1,470 @@
|
||||
'use strict';
|
||||
// SHADES — Lane C — skyfx: rain, storm sky, lightning, and the noise of it all.
|
||||
//
|
||||
// PLAN3D §5-C.2/§5-C.3. Lane A's world.js owns the calm sky and the base lights;
|
||||
// this MODULATES them as the storm builds and hands them back on dispose(), so
|
||||
// two lanes never fight over one scene.
|
||||
//
|
||||
// Everything is duck-typed and optional — no sun light, no audio, no sail? Then
|
||||
// those layers just don't run. Lane A can wire the pieces as they land.
|
||||
//
|
||||
// Audio is synthesized, not sampled: web/world/audio/ is empty, we ship no CDN
|
||||
// and no deps, and a filtered-noise bed tracks wind speed better than a loop.
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { rng } from './contracts.js';
|
||||
import { valueNoise2 } from './weather.core.js';
|
||||
|
||||
const lerp = (a, b, k) => a + (b - a) * k;
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
|
||||
const CALM_SKY = new THREE.Color(0x9fc4e8);
|
||||
const STORM_SKY = new THREE.Color(0x2a2f3a);
|
||||
const NIGHT_SKY = new THREE.Color(0x11141c);
|
||||
|
||||
// ---------------------------------------------------------------- rain
|
||||
function createRain(opts) {
|
||||
const max = opts.maxDrops ?? 3000;
|
||||
const half = opts.half ?? 18; // box half-extent around the camera
|
||||
const height = opts.height ?? 24;
|
||||
const groundY = opts.groundY ?? 0;
|
||||
const rand = rng(0xd309);
|
||||
|
||||
// one thin quadish streak, instanced — cheap and reads as rain in motion
|
||||
const geo = new THREE.BoxGeometry(0.015, 1, 0.015);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0xb4d2ff, transparent: true, opacity: 0.34,
|
||||
depthWrite: false, fog: false,
|
||||
});
|
||||
const mesh = new THREE.InstancedMesh(geo, mat, max);
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
mesh.frustumCulled = false;
|
||||
mesh.renderOrder = 2;
|
||||
mesh.count = 0;
|
||||
|
||||
const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max);
|
||||
const jitter = new Float32Array(max);
|
||||
for (let i = 0; i < max; i++) {
|
||||
px[i] = (rand() * 2 - 1) * half;
|
||||
py[i] = groundY + rand() * height;
|
||||
pz[i] = (rand() * 2 - 1) * half;
|
||||
jitter[i] = 0.75 + rand() * 0.5; // not every drop is the same drop
|
||||
}
|
||||
|
||||
const m = new THREE.Matrix4();
|
||||
const q = new THREE.Quaternion();
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
const vel = new THREE.Vector3();
|
||||
const scale = new THREE.Vector3(1, 1, 1);
|
||||
const zero = new THREE.Vector3();
|
||||
|
||||
return {
|
||||
mesh,
|
||||
/** @param {THREE.Vector3} camPos @param {THREE.Vector3} w local wind */
|
||||
step(dt, camPos, w, intensity) {
|
||||
const n = Math.floor(max * clamp01(intensity));
|
||||
mesh.count = n;
|
||||
if (n === 0) return;
|
||||
|
||||
const fall = 9 + intensity * 4;
|
||||
// rain leans into the wind; that lean IS the readout of how hard it's blowing
|
||||
vel.set(w.x * 0.55, -fall, w.z * 0.55);
|
||||
const speed = vel.length() || 1;
|
||||
q.setFromUnitVectors(up, vel.clone().divideScalar(speed));
|
||||
// streak stretches with speed — drizzle is dots, a squall is lines
|
||||
scale.set(1, Math.min(2.6, 0.35 + speed * 0.055), 1);
|
||||
m.compose(zero, q, scale);
|
||||
|
||||
const top = groundY + height;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = jitter[i];
|
||||
px[i] += w.x * 0.55 * j * dt;
|
||||
py[i] -= fall * j * dt;
|
||||
pz[i] += w.z * 0.55 * j * dt;
|
||||
|
||||
// wrap the box around the camera instead of respawning — no bookkeeping,
|
||||
// and the rain is always exactly where the player is looking
|
||||
let d = px[i] - camPos.x;
|
||||
if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2;
|
||||
d = pz[i] - camPos.z;
|
||||
if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2;
|
||||
if (py[i] < groundY) py[i] += height;
|
||||
else if (py[i] > top) py[i] -= height;
|
||||
|
||||
m.elements[12] = px[i];
|
||||
m.elements[13] = py[i];
|
||||
m.elements[14] = pz[i];
|
||||
mesh.setMatrixAt(i, m);
|
||||
}
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
},
|
||||
dispose() { geo.dispose(); mat.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ cloud dome
|
||||
function cloudTexture(size = 256, seed = 7) {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = size;
|
||||
const ctx = cv.getContext('2d');
|
||||
const img = ctx.createImageData(size, size);
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
// fbm at integer frequencies, each octave wrapped at its own period, so
|
||||
// the texture tiles: it's set to repeat(3,2) and it scrolls forever, and
|
||||
// an unwrapped octave puts a dead straight seam across the sky.
|
||||
let n = 0, amp = 0.5, f = 4;
|
||||
for (let o = 0; o < 4; o++) {
|
||||
n += amp * valueNoise2((x / size) * f, (y / size) * f, seed + o * 977, f);
|
||||
amp *= 0.5; f *= 2;
|
||||
}
|
||||
const v = clamp01((n - 0.28) * 2.2);
|
||||
const i = (y * size + x) * 4;
|
||||
const shade = 150 + v * 70;
|
||||
img.data[i] = shade; img.data[i + 1] = shade; img.data[i + 2] = shade + 12;
|
||||
img.data[i + 3] = v * 235;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(3, 2);
|
||||
return tex;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- audio
|
||||
// Synthesized layers. WebAudio won't start until a gesture (browser rule), so
|
||||
// everything is built lazily on unlock() and silently absent before it.
|
||||
function createAudio(seed = 1) {
|
||||
let ctx = null, master = null;
|
||||
let windGain, windFilter, windHowl, howlGain;
|
||||
let rainGain, rainFilter;
|
||||
let gustGain, gustFilter;
|
||||
let noiseBuf = null;
|
||||
let creakNext = 0, flogNext = 0;
|
||||
let started = false;
|
||||
|
||||
function noiseBuffer(c) {
|
||||
const len = c.sampleRate * 4;
|
||||
const buf = c.createBuffer(1, len, c.sampleRate);
|
||||
const d = buf.getChannelData(0);
|
||||
const rand = rng(seed ^ 0x0157);
|
||||
let last = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const white = rand() * 2 - 1;
|
||||
last = (last + 0.02 * white) / 1.02; // brown-ish: weight to the low end
|
||||
d[i] = last * 3.5;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
function loop(buf, dest, filter) {
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf; src.loop = true;
|
||||
src.connect(filter); filter.connect(dest);
|
||||
src.start();
|
||||
return src;
|
||||
}
|
||||
|
||||
return {
|
||||
get ready() { return started; },
|
||||
/** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph
|
||||
* got built — a suspended context is still silent, so the HUD reports this. */
|
||||
get state() { return ctx ? ctx.state : 'none'; },
|
||||
/** Current layer gains — for the HUD and for asserting the bed tracks wind. */
|
||||
levels() {
|
||||
if (!started) return null;
|
||||
return {
|
||||
wind: +windGain.gain.value.toFixed(4),
|
||||
howl: +howlGain.gain.value.toFixed(4),
|
||||
rain: +rainGain.gain.value.toFixed(4),
|
||||
cutoff: Math.round(windFilter.frequency.value),
|
||||
};
|
||||
},
|
||||
|
||||
/** Call from the first click/keydown. Safe to call repeatedly. */
|
||||
unlock() {
|
||||
if (started) return;
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return;
|
||||
ctx = new AC();
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
master = ctx.createGain();
|
||||
master.gain.value = 0.55;
|
||||
master.connect(ctx.destination);
|
||||
noiseBuf = noiseBuffer(ctx);
|
||||
|
||||
// wind bed: brown noise through a lowpass that opens as it blows harder
|
||||
windGain = ctx.createGain(); windGain.gain.value = 0;
|
||||
windFilter = ctx.createBiquadFilter();
|
||||
windFilter.type = 'lowpass'; windFilter.frequency.value = 400;
|
||||
windGain.connect(master);
|
||||
loop(noiseBuf, windGain, windFilter);
|
||||
|
||||
// howl: a resonant band on top — this is the bit that sounds like a gale
|
||||
howlGain = ctx.createGain(); howlGain.gain.value = 0;
|
||||
windHowl = ctx.createBiquadFilter();
|
||||
windHowl.type = 'bandpass'; windHowl.frequency.value = 500; windHowl.Q.value = 6;
|
||||
howlGain.connect(master);
|
||||
loop(noiseBuf, howlGain, windHowl);
|
||||
|
||||
rainGain = ctx.createGain(); rainGain.gain.value = 0;
|
||||
rainFilter = ctx.createBiquadFilter();
|
||||
rainFilter.type = 'highpass'; rainFilter.frequency.value = 1800;
|
||||
rainGain.connect(master);
|
||||
loop(noiseBuf, rainGain, rainFilter);
|
||||
|
||||
gustGain = ctx.createGain(); gustGain.gain.value = 0;
|
||||
gustFilter = ctx.createBiquadFilter();
|
||||
gustFilter.type = 'bandpass'; gustFilter.frequency.value = 300; gustFilter.Q.value = 2.5;
|
||||
gustGain.connect(master);
|
||||
loop(noiseBuf, gustGain, gustFilter);
|
||||
|
||||
started = true;
|
||||
},
|
||||
|
||||
/** One-shot filtered noise burst — the workhorse for creak/flog/thunder. */
|
||||
burst({ freq, q, gain, attack, decay, type = 'bandpass' }) {
|
||||
if (!started) return;
|
||||
const now = ctx.currentTime;
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = noiseBuf;
|
||||
src.loop = true;
|
||||
const f = ctx.createBiquadFilter();
|
||||
f.type = type; f.frequency.value = freq; f.Q.value = q ?? 4;
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, now);
|
||||
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), now + attack);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + attack + decay);
|
||||
src.connect(f); f.connect(g); g.connect(master);
|
||||
src.start(now);
|
||||
src.stop(now + attack + decay + 0.05);
|
||||
},
|
||||
|
||||
/** @param {number} speed m/s @param {number} rain 0..1 */
|
||||
setLevels(speed, rain) {
|
||||
if (!started) return;
|
||||
const now = ctx.currentTime;
|
||||
const s = clamp01(speed / 32);
|
||||
// gain and brightness both climb — a 30 m/s wind isn't just a louder 5 m/s one
|
||||
windGain.gain.setTargetAtTime(0.05 + s * 0.5, now, 0.15);
|
||||
windFilter.frequency.setTargetAtTime(220 + s * 900, now, 0.2);
|
||||
howlGain.gain.setTargetAtTime(s * s * 0.28, now, 0.2);
|
||||
windHowl.frequency.setTargetAtTime(320 + s * 700, now, 0.25);
|
||||
rainGain.gain.setTargetAtTime(rain * 0.34, now, 0.3);
|
||||
rainFilter.frequency.setTargetAtTime(1500 + rain * 900, now, 0.3);
|
||||
},
|
||||
|
||||
/** Telegraph cue: you hear it coming before you feel it. */
|
||||
whoosh(power, eta) {
|
||||
if (!started) return;
|
||||
const now = ctx.currentTime;
|
||||
const p = clamp01(power / 18);
|
||||
gustGain.gain.cancelScheduledValues(now);
|
||||
gustGain.gain.setValueAtTime(gustGain.gain.value, now);
|
||||
gustGain.gain.linearRampToValueAtTime(0.05 + p * 0.3, now + Math.max(0.05, eta));
|
||||
gustGain.gain.linearRampToValueAtTime(0.0001, now + Math.max(0.05, eta) + 2.2);
|
||||
gustFilter.frequency.cancelScheduledValues(now);
|
||||
gustFilter.frequency.setValueAtTime(220, now);
|
||||
gustFilter.frequency.linearRampToValueAtTime(240 + p * 700, now + Math.max(0.05, eta) + 0.8);
|
||||
},
|
||||
|
||||
/** Rope creak — rate and pitch both ride the worst corner. */
|
||||
creak(dt, loadFrac) {
|
||||
if (!started || loadFrac < 0.35) return;
|
||||
creakNext -= dt;
|
||||
if (creakNext > 0) return;
|
||||
creakNext = lerp(1.1, 0.16, clamp01((loadFrac - 0.35) / 0.65));
|
||||
this.burst({
|
||||
freq: 180 + loadFrac * 420, q: 9,
|
||||
gain: 0.05 + loadFrac * 0.22, attack: 0.012, decay: 0.16,
|
||||
});
|
||||
},
|
||||
|
||||
/** Freed corner: canvas cracking itself to pieces. */
|
||||
flog(dt, speed) {
|
||||
if (!started) return;
|
||||
flogNext -= dt;
|
||||
if (flogNext > 0) return;
|
||||
flogNext = Math.max(0.09, 0.5 - speed * 0.011);
|
||||
this.burst({ freq: 900 + speed * 26, q: 1.2, gain: 0.1 + clamp01(speed / 30) * 0.3, attack: 0.005, decay: 0.1 });
|
||||
},
|
||||
|
||||
thunder(power) {
|
||||
if (!started) return;
|
||||
this.burst({ type: 'lowpass', freq: 90 + power * 60, q: 0.7, gain: 0.25 + power * 0.5, attack: 0.06, decay: 2.6 + power * 1.6 });
|
||||
},
|
||||
|
||||
dispose() { if (ctx) ctx.close(); started = false; },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- skyfx
|
||||
/**
|
||||
* @param {object} o
|
||||
* @param {THREE.Scene} o.scene
|
||||
* @param {THREE.Camera} o.camera
|
||||
* @param {object} o.wind from weather.js
|
||||
* @param {THREE.Light} [o.sun] Lane A's directional light — we dim it
|
||||
* @param {THREE.Light} [o.hemi] Lane A's hemisphere light
|
||||
* @param {boolean} [o.night] storm_02 is a wild NIGHT
|
||||
* @param {function} [o.onEvent] (text) HUD ticker
|
||||
*/
|
||||
export function createSkyFx(o = {}) {
|
||||
const { scene, camera, wind } = o;
|
||||
const sun = o.sun || null;
|
||||
const hemi = o.hemi || null;
|
||||
const def = (wind && wind.def) || {};
|
||||
const skyDef = def.sky || {};
|
||||
const darkness = skyDef.darkness ?? 0.7;
|
||||
const scroll = skyDef.cloudScroll ?? 0.06;
|
||||
const target = (o.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
|
||||
|
||||
const rain = createRain({ groundY: o.groundY ?? 0 });
|
||||
if (scene) scene.add(rain.mesh);
|
||||
|
||||
const audio = createAudio((wind && wind.seed) || 1);
|
||||
|
||||
// cloud dome rides the camera so it can't clip the far plane whatever Lane A set
|
||||
const domeTex = cloudTexture(256, ((wind && wind.seed) || 7) & 0xffff);
|
||||
const dome = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(180, 24, 16),
|
||||
new THREE.MeshBasicMaterial({
|
||||
map: domeTex, side: THREE.BackSide, transparent: true,
|
||||
depthWrite: false, fog: false, opacity: 0,
|
||||
}),
|
||||
);
|
||||
dome.renderOrder = -1;
|
||||
if (scene) scene.add(dome);
|
||||
|
||||
// remember what world.js handed us, so dispose() puts it back exactly
|
||||
const original = {
|
||||
background: scene ? scene.background : null,
|
||||
fog: scene ? scene.fog : null,
|
||||
sun: sun ? sun.intensity : 0,
|
||||
hemi: hemi ? hemi.intensity : 0,
|
||||
};
|
||||
const baseSky = (scene && scene.background && scene.background.isColor)
|
||||
? scene.background.clone() : CALM_SKY.clone();
|
||||
|
||||
const skyCol = baseSky.clone();
|
||||
if (scene) {
|
||||
scene.background = skyCol;
|
||||
if (!scene.fog) scene.fog = new THREE.Fog(skyCol.getHex(), 30, 140);
|
||||
}
|
||||
|
||||
let flash = 0; // decaying lightning brightness
|
||||
let flashQueue = []; // {at, power} — double-strike
|
||||
let lastTelegraph = null;
|
||||
const camPos = new THREE.Vector3();
|
||||
const w = new THREE.Vector3();
|
||||
|
||||
const fx = {
|
||||
rain, audio, dome,
|
||||
get flash() { return flash; },
|
||||
|
||||
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||
unlockAudio() { audio.unlock(); },
|
||||
|
||||
/**
|
||||
* @param {number} dt
|
||||
* @param {number} t storm time
|
||||
* @param {object} [world] {sail} — duck-typed, for creak/flog
|
||||
*/
|
||||
step(dt, t, world = {}) {
|
||||
if (!camera) return;
|
||||
camera.getWorldPosition(camPos);
|
||||
wind.sample(camPos, t, w);
|
||||
const speed = Math.hypot(w.x, w.z);
|
||||
const intensity = wind.rainAt(t);
|
||||
const storminess = clamp01(Math.max(intensity, speed / 26));
|
||||
|
||||
// --- events: lightning + the ticker ---
|
||||
for (const ev of wind.eventsBetween(t - dt, t)) {
|
||||
if (ev.type === 'lightning') {
|
||||
const p = ev.power ?? 0.7;
|
||||
flashQueue.push({ at: t, power: p });
|
||||
flashQueue.push({ at: t + 0.09 + p * 0.07, power: p * 0.55 }); // the stutter
|
||||
// thunder lags the flash — distance you can hear
|
||||
const delay = (ev.distance ?? 1.2) * 0.9;
|
||||
flashQueue.push({ at: t + delay, power: 0, thunder: p });
|
||||
} else if (ev.type === 'windchange' && ev.text && o.onEvent) {
|
||||
o.onEvent(ev.text);
|
||||
}
|
||||
}
|
||||
for (let i = flashQueue.length - 1; i >= 0; i--) {
|
||||
if (flashQueue[i].at <= t) {
|
||||
const f = flashQueue[i];
|
||||
if (f.thunder) audio.thunder(f.thunder);
|
||||
else flash = Math.max(flash, f.power);
|
||||
flashQueue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
flash *= Math.max(0, 1 - dt * 7);
|
||||
if (flash < 0.004) flash = 0;
|
||||
|
||||
// --- sky ---
|
||||
skyCol.copy(baseSky).lerp(target, storminess * darkness);
|
||||
if (flash > 0) skyCol.lerp(new THREE.Color(0xdfe8ff), Math.min(0.85, flash));
|
||||
if (scene) {
|
||||
if (scene.fog) {
|
||||
scene.fog.color.copy(skyCol);
|
||||
scene.fog.near = lerp(40, 8, storminess);
|
||||
scene.fog.far = lerp(160, 55, storminess);
|
||||
}
|
||||
}
|
||||
if (sun) sun.intensity = lerp(original.sun, original.sun * 0.12, storminess * darkness) + flash * 2.2;
|
||||
if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, storminess * darkness) + flash * 1.2;
|
||||
|
||||
dome.position.copy(camPos);
|
||||
dome.material.opacity = storminess * 0.85;
|
||||
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
|
||||
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
|
||||
|
||||
// --- rain ---
|
||||
rain.step(dt, camPos, w, intensity);
|
||||
|
||||
// --- audio ---
|
||||
audio.setLevels(speed, intensity);
|
||||
const tg = wind.gustTelegraph(t);
|
||||
if (tg && tg !== lastTelegraph) {
|
||||
// fires once per gust, right as the telegraph opens
|
||||
if (!lastTelegraph || Math.abs(tg.eta - (lastTelegraph.eta - dt)) > 0.05) {
|
||||
audio.whoosh(tg.power, tg.eta);
|
||||
}
|
||||
}
|
||||
lastTelegraph = tg;
|
||||
|
||||
const sail = world.sail;
|
||||
if (sail && sail.corners) {
|
||||
let worst = 0, broken = false;
|
||||
for (const c of sail.corners) {
|
||||
if (c.broken) { broken = true; continue; }
|
||||
const rating = c.hw && c.hw.rating ? c.hw.rating : 1;
|
||||
worst = Math.max(worst, c.load / rating);
|
||||
}
|
||||
audio.creak(dt, worst);
|
||||
if (broken) audio.flog(dt, speed);
|
||||
}
|
||||
},
|
||||
|
||||
/** Hand Lane A's scene back exactly as we found it. */
|
||||
dispose() {
|
||||
if (scene) {
|
||||
scene.remove(rain.mesh);
|
||||
scene.remove(dome);
|
||||
scene.background = original.background;
|
||||
scene.fog = original.fog;
|
||||
}
|
||||
if (sun) sun.intensity = original.sun;
|
||||
if (hemi) hemi.intensity = original.hemi;
|
||||
rain.dispose();
|
||||
dome.geometry.dispose();
|
||||
dome.material.dispose();
|
||||
domeTex.dispose();
|
||||
audio.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
return fx;
|
||||
}
|
||||
@ -1,26 +1,34 @@
|
||||
/**
|
||||
* Lane B selftests — cloth, corner loads, failure cascade.
|
||||
* Lane B selftests — cloth, corner loads, failure cascade, prep economy.
|
||||
*
|
||||
* Lane B owns this file. Lane A pre-created it so that adding your suite never
|
||||
* means editing selftest.html — if all five lanes shared that file it would be
|
||||
* the one guaranteed merge conflict in the repo.
|
||||
* The asserts themselves live next to the code they test, in
|
||||
* `js/sail.selftest.js` and `js/rigging.selftest.js`, exported as [name, fn]
|
||||
* pairs. This file is only the adapter that hands them to Lane A's Suite.
|
||||
*
|
||||
* The asserts PLAN3D §5-B asks for, once sail.js lands:
|
||||
* 1. hypar sheds load — twisted rig's peak corner load < flat rig's peak,
|
||||
* same storm, same hardware. This is the thesis of the whole game; if it
|
||||
* doesn't hold, the wind force is being applied per-node instead of
|
||||
* per-face.
|
||||
* 2. cascade — break one corner at a fixed t, a neighbour's load spikes ≥2×.
|
||||
* 3. determinism — two runs, same inputs, byte-equal load traces.
|
||||
* The reason for the indirection: those two modules also run under plain
|
||||
* `node web/world/js/sail.selftest.js` — no browser, no server, no renderer,
|
||||
* ~6 s — which is how the cloth got proven before M0 landed. Keeping the
|
||||
* asserts there means the browser suite and the headless suite can never drift,
|
||||
* because they are literally the same array.
|
||||
*
|
||||
* Useful imports when you get there:
|
||||
* import { FIXED_DT, STORM_LEN, HARDWARE, createStubWind } from '../contracts.js';
|
||||
* import { assert, assertLess, fixedLoop } from '../testkit.js';
|
||||
* Drive time with fixedLoop(), never rAF. Use createStubWind({seed}) until
|
||||
* Lane C's weather.js lands — but don't tune against it, it's uniform in space.
|
||||
* PLAN3D §5-B asked for three asserts. All three are in there, plus a statics
|
||||
* balance that pins the load meter to real newtons:
|
||||
* 1. hypar sheds load — scored on WORST CASE over eight wind directions
|
||||
* rather than one, because Lane C's storms veer and the player never gets
|
||||
* to pick the wind. Per-direction would be a false assert: a flat sail
|
||||
* sitting edge-on to the wind genuinely has low drag and beats the hypar
|
||||
* from that one angle. Worst-case is what the hardware has to survive.
|
||||
* 2. cascade — break a corner at fixed t, a neighbour's load spikes >= 2x.
|
||||
* 3. determinism — byte-equal load traces, plus ragged frame dt converging on
|
||||
* the fixed-dt trace (Lane A's render loop delivers ragged dt, so the
|
||||
* accumulator has to absorb it or none of this applies to the real game).
|
||||
*/
|
||||
|
||||
import { SAIL_TESTS } from '../sail.selftest.js';
|
||||
import { RIGGING_TESTS } from '../rigging.selftest.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
t.skip('sail.js not landed yet — Lane B');
|
||||
for (const [name, fn] of SAIL_TESTS) t.test(name, fn);
|
||||
for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn);
|
||||
}
|
||||
|
||||
@ -1,25 +1,81 @@
|
||||
/**
|
||||
* Lane C selftests — wind field, storm timelines, rain, debris.
|
||||
*
|
||||
* Lane C owns this file. Lane A pre-created it so adding your suite never means
|
||||
* editing selftest.html.
|
||||
* The asserts live in weather.selftest.js as a plain case list, because they
|
||||
* import nothing but weather.core.js (no THREE, no DOM) and so also run under
|
||||
* `node web/world/js/tests/run-node.mjs` — a one-second loop for tuning a storm
|
||||
* curve, instead of a browser round trip. This file is the browser half: it
|
||||
* feeds them the fetched storms and adds the checks that need the real
|
||||
* weather.js adapter (contract shape, THREE.Vector3 out).
|
||||
*
|
||||
* The asserts PLAN3D §5-C asks for, once weather.js lands:
|
||||
* 1. gust telegraph lead ≥ 1.2 s — the promise the storm rests on. Lane A's
|
||||
* suite already asserts this against the stub wind (see a.test.js,
|
||||
* 'gust telegraph always gives at least 1.2 s of warning'); lift that test
|
||||
* onto the real wind.sample and delete the stub version's claim to it.
|
||||
* 2. wind.sample continuity — no frame-to-frame jump beyond a sane bound, or
|
||||
* the cloth explodes and it looks like Lane B's bug.
|
||||
* 3. storm JSON schema validation for everything in data/storms/.
|
||||
*
|
||||
* Useful imports:
|
||||
* import { FIXED_DT, STORM_LEN } from '../contracts.js';
|
||||
* import { assert, assertLess, fixedLoop } from '../testkit.js';
|
||||
* wind.sample(pos, t) must be pure in (pos, t): selftest samples out of order.
|
||||
* Lane A: a.test.js's 'gust telegraph always gives at least 1.2 s of warning'
|
||||
* is lifted onto the real wind below, per your note — the stub's claim to it is
|
||||
* now redundant and yours to drop whenever suits. Logged in THREADS.
|
||||
*/
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { assert, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT, checkContract } from '../contracts.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { weatherCases } from './weather.selftest.js';
|
||||
|
||||
const STORMS = ['storm_01_gentle', 'storm_02_wildnight'];
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
t.skip('weather.js not landed yet — Lane C');
|
||||
export default async function run(t) {
|
||||
const storms = {};
|
||||
for (const name of STORMS) storms[name] = await loadStorm(name);
|
||||
|
||||
// --- the shared case list (also runs headless in node) ---
|
||||
const { cases } = weatherCases(storms);
|
||||
for (const c of cases) t.test(c.name, c.fn);
|
||||
|
||||
// --- the bits that need the THREE adapter, not just the core ---
|
||||
|
||||
t.test('weather.js satisfies the wind contract', () => {
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const problems = checkContract('wind', wind);
|
||||
assert(problems.length === 0, problems.join('; '));
|
||||
});
|
||||
|
||||
t.test('sample() returns a Vector3 and honours an out param', () => {
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const pos = new THREE.Vector3(3, 0, -2);
|
||||
const a = wind.sample(pos, 12.5);
|
||||
assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3');
|
||||
assert(a.y === 0, `wind should be horizontal, got y=${a.y}`);
|
||||
// out param must not change the answer, only where it lands
|
||||
const out = new THREE.Vector3();
|
||||
const b = wind.sample(pos, 12.5, out);
|
||||
assert(b === out, 'out param was ignored');
|
||||
assert(a.x === b.x && a.z === b.z, 'out param changed the result');
|
||||
});
|
||||
|
||||
// Lifted from a.test.js onto the real wind (Lane A's note in this file's
|
||||
// header). This is the promise the whole storm rests on: the player can
|
||||
// always react. Deliberately walks the public surface, not the core.
|
||||
t.test('gust telegraph always gives at least 1.2 s of warning', () => {
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
let had = false, edges = 0;
|
||||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||
const tel = wind.gustTelegraph(time);
|
||||
if (tel && !had) {
|
||||
edges++;
|
||||
assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`);
|
||||
assert(Number.isFinite(tel.power) && tel.power > 0, 'telegraph carried no power');
|
||||
assert(Number.isFinite(tel.dir), 'telegraph carried no direction');
|
||||
}
|
||||
had = !!tel;
|
||||
});
|
||||
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`);
|
||||
});
|
||||
|
||||
t.test('every storm in data/storms/ loads and validates', () => {
|
||||
// loadStorm throws on invalid, so reaching here with all of them is the pass
|
||||
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');
|
||||
for (const [name, def] of Object.entries(storms)) {
|
||||
assert(def.duration > 0, `${name} has no duration`);
|
||||
assert(Array.isArray(def.baseCurve), `${name} has no baseCurve`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,26 +1,295 @@
|
||||
/**
|
||||
* Lane D selftests — player state machine and interactions.
|
||||
* Lane D selftests — player state machine, weather effects, interactions. (PLAN3D §5-D.5)
|
||||
*
|
||||
* Lane D owns this file. Lane A pre-created it so adding your suite never means
|
||||
* editing selftest.html.
|
||||
* Everything asserted here is renderer-free: player.sim.js and interact.js import nothing, so this
|
||||
* suite is pure logic driven at fixed dt, never rAF. (rAF being throttled in a hidden tab is not
|
||||
* theoretical — this lane's dev harness froze mid-knockdown at stateT=0.333 until it was driven by
|
||||
* a fixed loop, which is exactly the trap Lane A called out.)
|
||||
*
|
||||
* The asserts PLAN3D §5-D asks for, once player.js lands:
|
||||
* 1. anim state machine table test — every state reachable, none stuck
|
||||
* (idle/walk/run + one-shot interact + knockdown → get-up).
|
||||
* 2. interact radius respects the busy and carrying flags.
|
||||
*
|
||||
* Note for integration: main.js currently drives a placeholder capsule that
|
||||
* satisfies the Player contract ({pos, carrying, busy, update}). When player.js
|
||||
* lands, Lane A swaps the factory call in boot() and deletes the placeholder —
|
||||
* you shouldn't need to touch main.js yourself. Ping THREADS.md when you're
|
||||
* ready and Lane A will do the swap.
|
||||
*
|
||||
* Useful imports:
|
||||
* import { FIXED_DT, createStubWind } from '../contracts.js';
|
||||
* import { assert, assertEq, fixedLoop } from '../testkit.js';
|
||||
* The rig half of player.js — bone binding, head-bone scale, clip retarget — can't be asserted
|
||||
* headlessly; it needs a GL context and two GLB fetches. That is verified by hand in
|
||||
* web/world/dev_player.html and written up in THREADS.md: head bone 1.715 m at fig scale 0.983,
|
||||
* all six clips bound, Hips tracks correctly absent.
|
||||
*/
|
||||
import { PlayerSim, STATES, TUNE } from '../player.sim.js';
|
||||
import { Interact, wireYardActions } from '../interact.js';
|
||||
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT } from '../contracts.js';
|
||||
|
||||
const DT = FIXED_DT;
|
||||
|
||||
/** Steady wind blowing +X at `speed` m/s. */
|
||||
const windX = (speed) => ({ x: speed, y: 0, z: 0 });
|
||||
|
||||
/** Drive the sim for `secs` at fixed dt. */
|
||||
const drive = (sim, secs, input = {}, wind = null, t0 = 0) =>
|
||||
fixedLoop(secs, DT, (dt, t) => sim.step(dt, t0 + t, input, wind));
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
t.skip('player.js not landed yet — Lane D');
|
||||
// ---------------------------------------------------------------- state machine table
|
||||
t.test('state table: every state\'s clip exists in player_anims.glb', () => {
|
||||
const clips = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction']);
|
||||
for (const [name, st] of Object.entries(STATES)) {
|
||||
assert(clips.has(st.clip), `state ${name} wants missing clip ${st.clip}`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('state table: no stuck states — every locked state drains to a free one', () => {
|
||||
for (const [name, st] of Object.entries(STATES)) {
|
||||
if (!st.locked) continue;
|
||||
assert((st.next && st.secs > 0) || !!st.releasedBy,
|
||||
`locked state ${name} has neither a timed exit nor an external releaser`);
|
||||
if (st.next) assert(!!STATES[st.next], `state ${name}.next=${st.next} is not a state`);
|
||||
}
|
||||
for (const name of Object.keys(STATES)) {
|
||||
let cur = name, hops = 0;
|
||||
const seen = new Set();
|
||||
while (STATES[cur].locked && STATES[cur].next && hops < 16 && !seen.has(cur)) {
|
||||
seen.add(cur); cur = STATES[cur].next; hops++;
|
||||
}
|
||||
assert(!STATES[cur].locked || !!STATES[cur].releasedBy,
|
||||
`state ${name} chains into a dead end at ${cur}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- locomotion
|
||||
t.test('locomotion: idle -> walk -> run -> idle at the tuned speeds', () => {
|
||||
const sim = new PlayerSim();
|
||||
assertEq(sim.state, 'idle', 'spawns idle');
|
||||
assert(!sim.busy, 'idle is not busy');
|
||||
|
||||
drive(sim, 1.0, { x: 0, z: 1, camYaw: 0 });
|
||||
assertEq(sim.state, 'walk', 'W walks');
|
||||
assertClose(sim.speed, TUNE.walkSpeed, 0.05, 'walk speed');
|
||||
|
||||
drive(sim, 1.0, { x: 0, z: 1, run: true, camYaw: 0 });
|
||||
assertEq(sim.state, 'run', 'shift runs');
|
||||
assertClose(sim.speed, TUNE.runSpeed, 0.05, 'run speed');
|
||||
|
||||
drive(sim, 1.0, {});
|
||||
assertEq(sim.state, 'idle', 'releasing input returns to idle');
|
||||
assertLess(sim.speed, 0.15, 'and stops');
|
||||
});
|
||||
|
||||
t.test('locomotion: movement is relative to camera.yaw', () => {
|
||||
const a = new PlayerSim(); drive(a, 1.0, { x: 0, z: 1, camYaw: 0 });
|
||||
assertLess(a.pos.z, -0.5, 'at yaw 0, W drives -Z (three.js camera-forward)');
|
||||
assertClose(a.pos.x, 0, 1e-6, 'and not sideways');
|
||||
|
||||
const b = new PlayerSim(); drive(b, 1.0, { x: 0, z: 1, camYaw: Math.PI / 2 });
|
||||
assertLess(b.pos.x, -0.5, 'at yaw 90 deg, the same key drives -X');
|
||||
assertClose(b.pos.z, 0, 1e-6, 'and not forward');
|
||||
|
||||
assertClose(Math.abs(a.facing), Math.PI, 0.05, 'facing chases the movement direction');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- weather on the player
|
||||
t.test('weather: wind slows you, capped at TUNE.slowMax', () => {
|
||||
const calm = new PlayerSim(); drive(calm, 2, { x: 0, z: 1, camYaw: 0 }, windX(0));
|
||||
const blow = new PlayerSim(); drive(blow, 2, { x: 0, z: 1, camYaw: 0 }, windX(20));
|
||||
assertLess(blow.speed, calm.speed, 'wind slows you');
|
||||
|
||||
// Isolate the slow curve from the knockdown — in a real 1e4 m/s you are flat on your back
|
||||
// inside half a second (which is correct, and is what this assert used to accidentally measure).
|
||||
const gale = new PlayerSim({ tune: { knockWind: Infinity } });
|
||||
drive(gale, 2, { x: 0, z: 1, camYaw: 0 }, windX(1e4));
|
||||
assertClose(gale.speed, TUNE.walkSpeed * (1 - TUNE.slowMax), 0.05,
|
||||
'slow saturates at slowMax and never goes past it');
|
||||
});
|
||||
|
||||
t.test('weather: steady wind reads as baseline, not as a gust', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 60, {}, windX(20));
|
||||
assertLess(s.gust, 1.0, 'the EMA learns a constant 20 m/s as base');
|
||||
assertLess(Math.hypot(s.shove.x, s.shove.z), 0.05, 'so it does not shove you forever');
|
||||
});
|
||||
|
||||
t.test('weather: a gust over the baseline shoves you downwind, and scales with speed squared', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, {}, windX(6)); // learn a calm baseline
|
||||
const x0 = s.pos.x;
|
||||
drive(s, 1.6, {}, windX(22), 30); // 16 m/s over baseline -> past shoveGustMin
|
||||
assert(s.pos.x - x0 > 0.05, `gust should shove downwind, dx=${(s.pos.x - x0).toFixed(3)}`);
|
||||
|
||||
const push = (ws) => {
|
||||
const p = new PlayerSim();
|
||||
drive(p, 30, {}, windX(2));
|
||||
const p0 = p.pos.x;
|
||||
drive(p, 1.0, {}, windX(ws), 30);
|
||||
return p.pos.x - p0;
|
||||
};
|
||||
const p15 = push(15), p30 = push(30);
|
||||
assert(p30 > p15 * 2.5,
|
||||
`shove must grow faster than linearly with speed: ${p15.toFixed(3)} -> ${p30.toFixed(3)}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- knockdown
|
||||
t.test('knockdown: needs SUSTAINED overload, like a sail corner letting go', () => {
|
||||
const brief = new PlayerSim();
|
||||
drive(brief, 0.3, {}, windX(TUNE.knockWind + 5));
|
||||
assert(brief.state !== 'knocked', 'a brief spike must not put you down');
|
||||
|
||||
const held = new PlayerSim();
|
||||
held.carrying = 'spare';
|
||||
drive(held, TUNE.knockSustain + 0.2, {}, windX(TUNE.knockWind + 5));
|
||||
assertEq(held.state, 'knocked', 'sustained overload does');
|
||||
assertEq(held.carrying, null, 'and drops what you were carrying');
|
||||
assert(held.knockDir.x > 0.99, 'and you fall downwind');
|
||||
});
|
||||
|
||||
t.test('knockdown: exposure bleeds off — flickering gusts never creep into one', () => {
|
||||
const s = new PlayerSim();
|
||||
fixedLoop(10, DT, (dt, t) => {
|
||||
const on = Math.round(t / DT) % 40 < 10; // 0.17 s on, 0.5 s off
|
||||
s.step(dt, t, {}, windX(on ? TUNE.knockWind + 5 : 5));
|
||||
});
|
||||
assert(s.state !== 'knocked',
|
||||
`flickering gusts must not accumulate (exposure=${s.exposure.toFixed(3)})`);
|
||||
});
|
||||
|
||||
t.test('knockdown: knocked -> getup -> idle, unaided, and upright again', () => {
|
||||
const s = new PlayerSim();
|
||||
s.knockdown(0);
|
||||
assert(s.busy, 'knocked is locked');
|
||||
drive(s, 0.5, { x: 0, z: 1, camYaw: 0 });
|
||||
assertClose(s.pos.z, 0, 1e-9, 'knocked ignores movement input');
|
||||
assert(s.pitch > 0.99, 'body goes flat');
|
||||
drive(s, 1.1, {});
|
||||
assertEq(s.state, 'getup', 'knocked drains to getup on its own');
|
||||
drive(s, 1.4, {});
|
||||
assertEq(s.state, 'idle', 'getup drains to idle on its own');
|
||||
assertLess(s.pitch, 0.01, 'and the body is upright again');
|
||||
assert(!s.busy, 'player is free');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- determinism (PLAN3D §4)
|
||||
t.test('determinism: two identical 50 s runs produce byte-equal traces', () => {
|
||||
const trace = () => {
|
||||
const s = new PlayerSim();
|
||||
const out = [];
|
||||
fixedLoop(50, DT, (dt, tt) => {
|
||||
const w = { x: 6 + 24 * Math.max(0, Math.sin(tt * 0.7)), y: 0, z: 3 * Math.cos(tt * 0.31) };
|
||||
s.step(dt, tt, { x: Math.sin(tt), z: Math.cos(tt * 0.5), run: tt % 4 < 2, camYaw: tt * 0.2 }, w);
|
||||
out.push(`${s.state}|${s.pos.x.toFixed(9)}|${s.pos.z.toFixed(9)}|${s.pitch.toFixed(9)}`);
|
||||
});
|
||||
return out.join(';');
|
||||
};
|
||||
assertEq(trace(), trace(), 'same inputs must give the same trace');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- interact
|
||||
const mk = () => {
|
||||
const sim = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
let done = 0;
|
||||
it.register({ id: 'x', pos: { x: 0, y: 0, z: 0 }, radius: 1.5, holdSecs: 1, label: 'do it',
|
||||
onDone: () => { done++; } });
|
||||
return { sim, it, done: () => done };
|
||||
};
|
||||
|
||||
t.test('interact: radius gates the action', () => {
|
||||
const { sim, it, done } = mk();
|
||||
sim.pos.x = 5;
|
||||
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(done(), 0, 'out of radius never fires');
|
||||
assert(!sim.busy, 'and never makes you busy');
|
||||
});
|
||||
|
||||
t.test('interact: hold to completion fires once and hands the player back', () => {
|
||||
const { sim, it, done } = mk();
|
||||
fixedLoop(70 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(done(), 1, 'fires once');
|
||||
assert(!sim.busy && sim.state === 'idle', 'player released');
|
||||
assert(it.events.some((e) => e.type === 'done' && e.id === 'x'), 'emits a done event');
|
||||
});
|
||||
|
||||
t.test('interact: player is busy mid-hold, with a partial radial', () => {
|
||||
const { sim, it } = mk();
|
||||
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assert(sim.busy && sim.state === 'busy', 'busy while holding');
|
||||
assert(it.progress > 0.4 && it.progress < 0.6, `radial partway, got ${it.progress.toFixed(2)}`);
|
||||
});
|
||||
|
||||
t.test('interact: one press, one action — a held key must not re-arm itself', () => {
|
||||
const { sim, it, done } = mk();
|
||||
fixedLoop(6.7, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(done(), 1, 'holding E for 6.7 s over a 1 s action still fires once');
|
||||
it.step(DT, 7, sim, false);
|
||||
fixedLoop(70 * DT, DT, (dt, tt) => it.step(dt, 7 + tt, sim, true));
|
||||
assertEq(done(), 2, 'releasing re-arms it');
|
||||
});
|
||||
|
||||
t.test('interact: releasing or walking away cancels the hold', () => {
|
||||
const a = mk();
|
||||
fixedLoop(30 * DT, DT, (dt, tt) => a.it.step(dt, tt, a.sim, true));
|
||||
a.it.step(DT, 0.5, a.sim, false);
|
||||
assertEq(a.it.progress, 0, 'release cancels');
|
||||
assert(!a.sim.busy && a.sim.state === 'idle', 'and frees the player');
|
||||
|
||||
const b = mk();
|
||||
fixedLoop(30 * DT, DT, (dt, tt) => b.it.step(dt, tt, b.sim, true));
|
||||
b.sim.pos.x = 9;
|
||||
b.it.step(DT, 0.5, b.sim, true);
|
||||
assertEq(b.it.progress, 0, 'leaving the radius cancels');
|
||||
assertEq(b.done(), 0, 'without firing');
|
||||
});
|
||||
|
||||
t.test('interact: a knockdown mid-hold aborts without stomping the knocked state', () => {
|
||||
const { sim, it, done } = mk();
|
||||
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
sim.knockdown(0.5);
|
||||
it.step(DT, 0.51, sim, true);
|
||||
assertEq(it.progress, 0, 'hold aborted');
|
||||
assertEq(done(), 0, 'action did not fire');
|
||||
assertEq(sim.state, 'knocked', 'and the abort did not overwrite the knocked state');
|
||||
});
|
||||
|
||||
t.test('interact: canUse() gates on the carrying flag', () => {
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
let fired = 0;
|
||||
it.register({ id: 'gated', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
|
||||
canUse: (p) => !p.carrying, onDone: () => { fired++; } });
|
||||
sim.carrying = 'spare';
|
||||
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(fired, 0, 'hands full: gated');
|
||||
assert(!sim.busy, 'and never went busy');
|
||||
sim.carrying = null;
|
||||
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(fired, 1, 'fires once the gate opens');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- hands-full + wiring
|
||||
t.test('hands-full rule: one item at a time', () => {
|
||||
const sim = new PlayerSim();
|
||||
assertEq(sim.pickUp('spare'), true, 'first item accepted');
|
||||
assertEq(sim.pickUp('wrench'), false, 'second refused');
|
||||
assertEq(sim.carrying, 'spare', 'still holding the first');
|
||||
assertEq(sim.drop(), 'spare', 'drop returns it');
|
||||
assertEq(sim.carrying, null, 'hands empty');
|
||||
});
|
||||
|
||||
t.test('wireYardActions: duck-types against a half-landed world', () => {
|
||||
const empty = new Interact();
|
||||
wireYardActions(empty, {});
|
||||
assertEq(empty.targets.size, 0, 'no sailRig yet (Lane B) -> no actions, no crash');
|
||||
|
||||
const it = new Interact();
|
||||
const corners = [{ anchorId: 'p1', broken: true, pos: { x: 0, y: 2, z: 0 }, load: 0 }];
|
||||
let repaired = -1;
|
||||
wireYardActions(it, {
|
||||
sailRig: { corners, repair: (i) => { repaired = i; }, trim: () => {} },
|
||||
world: { shedTable: { pos: { x: 10, y: 0, z: 0 } } },
|
||||
});
|
||||
assertEq(it.targets.size, 3, 're-rig + trim + spare table');
|
||||
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assertEq(repaired, -1, 're-rig refuses without a spare');
|
||||
p.carrying = 'spare';
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, 10 + tt, p, true));
|
||||
assertEq(repaired, 0, 're-rig fires with a spare');
|
||||
assertEq(p.carrying, null, 'and consumes it');
|
||||
});
|
||||
}
|
||||
|
||||
@ -69,6 +69,10 @@ const ASSETS = [
|
||||
{ name: 'carabiner', h: [0.06, 0.15], nodes: ['body', 'gate'] },
|
||||
{ name: 'turnbuckle', h: [0.12, 0.25], nodes: ['body', 'eye_a', 'eye_b'] },
|
||||
{ name: 'tramp_01', h: [0.6, 1.0], nodes: ['mat', 'rim', 'pad', 'legs'], sub: 'debris/' },
|
||||
{ name: 'wheelie_bin_01', h: [1.0, 1.2],
|
||||
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
|
||||
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
|
||||
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -148,6 +152,88 @@ export default async function run(t) {
|
||||
assert(Number.isFinite(p.x) && Number.isFinite(p.z), 'anchor world position is not finite');
|
||||
});
|
||||
|
||||
// A canopy that can't sway is just decoration, and the gust front the player
|
||||
// reads a beat before it hits the sail (world.js) is the canopy leaning. Lane A
|
||||
// rotates a `canopy` group; the blobs must swing about the TRUNK, not spin
|
||||
// about their own centres — a sphere spinning in place is invisible, which is
|
||||
// exactly the failure this catches.
|
||||
t.test('canopy sways about the trunk when Lane A rotates it', () => {
|
||||
const g = loaded.get('tree_gum_01');
|
||||
assert(g, 'tree_gum_01 did not load');
|
||||
const canopy = g.scene.getObjectByName('canopy');
|
||||
assert(canopy, 'no `canopy` group node — world.js rotates this to sway the tree');
|
||||
const blob = g.scene.getObjectByName('canopy_01');
|
||||
assert(blob, 'canopy_01 missing');
|
||||
|
||||
g.scene.updateWorldMatrix(true, true);
|
||||
const before = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
|
||||
const restZ = canopy.rotation.z;
|
||||
canopy.rotation.z = restZ + 0.20; // ≈ world.js's max lean of 0.22 rad
|
||||
canopy.updateWorldMatrix(true, true);
|
||||
const after = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
|
||||
canopy.rotation.z = restZ;
|
||||
canopy.updateWorldMatrix(true, true);
|
||||
|
||||
const moved = before.distanceTo(after);
|
||||
assert(moved > 0.15,
|
||||
`canopy_01 moved only ${moved.toFixed(3)} m under a 0.2 rad lean — the pivot is at ` +
|
||||
'the blob centre, not the trunk top, so the tree cannot visibly sway');
|
||||
});
|
||||
|
||||
// DESIGN.md: rake the post away from the load — so rake is a runtime rotation,
|
||||
// not baked. Rotating rake_pivot must carry the post and its top_anchor over
|
||||
// while the concrete footing stays level in the ground. A post whose footing
|
||||
// tips out of the dirt with it isn't raked, it's falling.
|
||||
t.test('sail_post rakes about rake_pivot with the footing left planted', () => {
|
||||
const g = loaded.get('sail_post');
|
||||
assert(g, 'sail_post did not load');
|
||||
const rake = g.scene.getObjectByName('rake_pivot');
|
||||
const anchor = g.scene.getObjectByName('top_anchor');
|
||||
const footing = g.scene.getObjectByName('footing');
|
||||
assert(rake && anchor && footing, 'sail_post needs rake_pivot, top_anchor and footing');
|
||||
|
||||
const at = () => {
|
||||
g.scene.updateWorldMatrix(true, true);
|
||||
return [new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld),
|
||||
new THREE.Vector3().setFromMatrixPosition(footing.matrixWorld)];
|
||||
};
|
||||
const [a0, f0] = at();
|
||||
rake.rotation.x += (8 * Math.PI) / 180; // Lane A rakes 8°
|
||||
const [a1, f1] = at();
|
||||
rake.rotation.x -= (8 * Math.PI) / 180;
|
||||
g.scene.updateWorldMatrix(true, true);
|
||||
|
||||
const head = a0.distanceTo(a1), foot = f0.distanceTo(f1);
|
||||
assert(head > 0.3,
|
||||
`an 8° rake moved the head only ${head.toFixed(3)} m — rake_pivot has no children`);
|
||||
assert(foot < 0.01,
|
||||
`the footing moved ${foot.toFixed(3)} m — concrete should stay planted`);
|
||||
});
|
||||
|
||||
// Same pivot class as the canopy: the Hills Hoist head freewheels, so spinning
|
||||
// it has to carry the arms round. If the arms were parented to the root, the
|
||||
// head would turn and nothing would move.
|
||||
t.test('washing line head carries the arms round when spun', () => {
|
||||
const g = loaded.get('washing_line_01');
|
||||
assert(g, 'washing_line_01 did not load');
|
||||
const head = g.scene.getObjectByName('head');
|
||||
const arms = g.scene.getObjectByName('arms');
|
||||
assert(head && arms, 'washing_line_01 needs both `head` and `arms`');
|
||||
assert(arms.parent === head || arms.parent?.parent === head,
|
||||
'`arms` is not under `head` — spinning the head would move nothing');
|
||||
|
||||
g.scene.updateWorldMatrix(true, true);
|
||||
const before = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
|
||||
head.rotation.y += Math.PI / 2;
|
||||
head.updateWorldMatrix(true, true);
|
||||
const after = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
|
||||
head.rotation.y -= Math.PI / 2;
|
||||
head.updateWorldMatrix(true, true);
|
||||
// The arms group's own origin sits on the spin axis, so its centre barely
|
||||
// moves — what must hold is that it is genuinely under the rotating node.
|
||||
assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
|
||||
});
|
||||
|
||||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||
// rather than reloading, so all three have to be present at once.
|
||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||||
|
||||
32
web/world/js/tests/run-node.mjs
Normal file
32
web/world/js/tests/run-node.mjs
Normal file
@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// SHADES — Lane C — headless runner for the weather suite.
|
||||
//
|
||||
// node web/world/js/tests/run-node.mjs
|
||||
//
|
||||
// The same suite Lane A's selftest.html runs, but with no browser and no server,
|
||||
// so tuning a storm curve is a one-second loop. Exits non-zero on failure.
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { runWeatherSuite } from './weather.selftest.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const stormDir = join(here, '..', '..', 'data', 'storms');
|
||||
|
||||
const storms = {};
|
||||
for (const f of readdirSync(stormDir).filter((f) => f.endsWith('.json')).sort()) {
|
||||
storms[f.replace(/\.json$/, '')] = JSON.parse(readFileSync(join(stormDir, f), 'utf8'));
|
||||
}
|
||||
|
||||
const r = runWeatherSuite(storms);
|
||||
|
||||
for (const res of r.results) {
|
||||
console.log(res.ok ? ` ok ${res.name}` : ` FAIL ${res.name}\n ${res.err}`);
|
||||
}
|
||||
console.log('\n metrics (Lane B: tune cloth rho against these):');
|
||||
for (const [k, v] of Object.entries(r.metrics)) console.log(` ${k.padEnd(32)} ${v}`);
|
||||
console.log(`\n ${r.pass} passed, ${r.fail} failed\n`);
|
||||
|
||||
process.exit(r.fail ? 1 : 0);
|
||||
299
web/world/js/tests/weather.selftest.js
Normal file
299
web/world/js/tests/weather.selftest.js
Normal file
@ -0,0 +1,299 @@
|
||||
'use strict';
|
||||
// SHADES — Lane C — weather selftest.
|
||||
//
|
||||
// Imports the pure core only (no THREE, no DOM), so this runs in node OR from
|
||||
// Lane A's selftest.html. Fixed-dt loops, never rAF (rAF pauses in hidden tabs
|
||||
// — PLAN3D §0).
|
||||
//
|
||||
// node web/world/js/tests/run-node.mjs
|
||||
//
|
||||
// Lane A: `import { runWeatherSuite } from './js/tests/weather.selftest.js'` and
|
||||
// call it with the fetched storm defs.
|
||||
|
||||
import {
|
||||
createWindField, validateStorm, gustEnvelope, GUST,
|
||||
} from '../weather.core.js';
|
||||
|
||||
const DT = 1 / 60;
|
||||
|
||||
// yard is ~30×20 m, origin at centre — probe the corners and the middle
|
||||
const PROBES = [
|
||||
{ x: 0, z: 0 }, { x: -14, z: -9 }, { x: 14, z: -9 },
|
||||
{ x: -14, z: 9 }, { x: 14, z: 9 }, { x: 5, z: -3 },
|
||||
];
|
||||
|
||||
// t1 and t2 from Lane A's landed yard (THREADS: "yard layout is now FACT")
|
||||
const TREE_SHELTERS = [
|
||||
{ x: -9, z: 2, radius: 3, strength: 0.45, length: 14 },
|
||||
{ x: 8, z: -2, radius: 2.5, strength: 0.4, length: 12 },
|
||||
];
|
||||
|
||||
/**
|
||||
* The case list, defined once and run by two harnesses: run-node.mjs for a
|
||||
* one-second tuning loop, and js/tests/c.test.js for Lane A's selftest.html at
|
||||
* merge time. Cases are sync, matching testkit's Suite.test(label, fn).
|
||||
*
|
||||
* @param {Object<string, object>} storms parsed storm defs, keyed by name
|
||||
* @returns {{cases: {name:string, fn:() => void}[], metrics: object}}
|
||||
* `metrics` fills in as the cases run — read it after, not before.
|
||||
*/
|
||||
export function weatherCases(storms) {
|
||||
const cases = [];
|
||||
const metrics = {};
|
||||
const test = (name, fn) => cases.push({ name, fn });
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
|
||||
const defs = Object.entries(storms);
|
||||
|
||||
// ---- 1. gust telegraph lead (PLAN3D §5-C.5: always >= 1.2 s before ramp) ----
|
||||
// The whole gust read is "you SEE it coming, then it hits". If the lead ever
|
||||
// collapses, the storm stops being fair and starts being a dice roll.
|
||||
for (const [name, def] of defs) {
|
||||
test(`${name}: gust telegraph lead >= 1.2s`, () => {
|
||||
const field = createWindField(def);
|
||||
assert(field.gusts.length > 0, 'storm scheduled no gusts at all');
|
||||
const step = 1 / 240;
|
||||
let worst = Infinity;
|
||||
for (const g of field.gusts) {
|
||||
// when does THIS gust first actually push?
|
||||
let rise = null;
|
||||
for (let t = g.t0; t < g.endAt; t += step) {
|
||||
if (gustEnvelope(t - g.t0, g.pow) > 1e-6) { rise = t; break; }
|
||||
}
|
||||
assert(rise !== null, `gust at t=${g.t0.toFixed(2)} never rises`);
|
||||
|
||||
// when did the HUD first get told about it?
|
||||
let announced = null;
|
||||
for (let t = Math.max(0, g.t0 - 2); t < rise; t += step) {
|
||||
const tg = field.telegraph(t);
|
||||
if (tg && Math.abs(tg.eta - (g.rampAt - t)) < 1e-6) { announced = t; break; }
|
||||
}
|
||||
assert(announced !== null, `gust at t=${g.t0.toFixed(2)} was never telegraphed`);
|
||||
|
||||
const lead = rise - announced;
|
||||
worst = Math.min(worst, lead);
|
||||
assert(lead >= 1.2,
|
||||
`gust at t=${g.t0.toFixed(2)}: telegraph lead ${lead.toFixed(3)}s < 1.2s`);
|
||||
|
||||
// and the telegraph window must be silent — no force before the ramp
|
||||
for (let t = g.t0; t < g.rampAt; t += step) {
|
||||
assert(gustEnvelope(t - g.t0, g.pow) === 0,
|
||||
`gust at t=${g.t0.toFixed(2)} pushes during its telegraph window`);
|
||||
}
|
||||
}
|
||||
metrics[`${name}.worstTelegraphLead`] = +worst.toFixed(3);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 2. wind.sample continuity ----
|
||||
// Sail load goes with wind², so a discontinuity here is an impulse that can
|
||||
// snap a corner out of nowhere. Both axes matter: time (a standing player)
|
||||
// and space (a running one).
|
||||
const MAX_JUMP = 1.5; // m/s per 1/60 frame
|
||||
for (const [name, def] of defs) {
|
||||
test(`${name}: wind continuity in time (< ${MAX_JUMP} m/s per frame)`, () => {
|
||||
const field = createWindField(def).setShelters(TREE_SHELTERS);
|
||||
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
|
||||
let worst = 0, worstT = 0, worstP = null;
|
||||
for (const p of PROBES) {
|
||||
field.vecAt(p.x, p.z, 0, a);
|
||||
for (let t = DT; t <= field.duration; t += DT) {
|
||||
field.vecAt(p.x, p.z, t, b);
|
||||
const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||||
if (d > worst) { worst = d; worstT = t; worstP = p; }
|
||||
a.x = b.x; a.y = b.y; a.z = b.z;
|
||||
}
|
||||
}
|
||||
metrics[`${name}.maxTemporalJump`] = +worst.toFixed(4);
|
||||
assert(worst < MAX_JUMP,
|
||||
`jump ${worst.toFixed(3)} m/s at t=${worstT.toFixed(2)} probe=(${worstP.x},${worstP.z})`);
|
||||
});
|
||||
|
||||
test(`${name}: wind continuity in space (< ${MAX_JUMP} m/s per 0.1 m)`, () => {
|
||||
const field = createWindField(def).setShelters(TREE_SHELTERS);
|
||||
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
|
||||
let worst = 0, worstAt = null;
|
||||
// sweep the yard at the storm's angriest moments, straight through both trees
|
||||
for (const t of [10, 30, 57, 64, 80]) {
|
||||
for (let z = -10; z <= 10; z += 1) {
|
||||
field.vecAt(-15, z, t, a);
|
||||
for (let x = -15 + 0.1; x <= 15; x += 0.1) {
|
||||
field.vecAt(x, z, t, b);
|
||||
const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||||
if (d > worst) { worst = d; worstAt = { x: +x.toFixed(1), z, t }; }
|
||||
a.x = b.x; a.y = b.y; a.z = b.z;
|
||||
}
|
||||
}
|
||||
}
|
||||
metrics[`${name}.maxSpatialJump`] = +worst.toFixed(4);
|
||||
assert(worst < MAX_JUMP,
|
||||
`jump ${worst.toFixed(3)} m/s at ${JSON.stringify(worstAt)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 3. storm JSON validator ----
|
||||
for (const [name, def] of defs) {
|
||||
test(`${name}: validates`, () => {
|
||||
const { ok, errors } = validateStorm(def, name);
|
||||
assert(ok, errors.join('; '));
|
||||
});
|
||||
}
|
||||
|
||||
// A validator that only ever says yes isn't a validator.
|
||||
test('validator rejects broken storms', () => {
|
||||
const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
const cases = [
|
||||
['duration missing', (d) => { delete d.duration; }],
|
||||
['duration negative', (d) => { d.duration = -5; }],
|
||||
['baseCurve absent', (d) => { delete d.baseCurve; }],
|
||||
['baseCurve non-monotonic t', (d) => { d.baseCurve = [[0, 5], [50, 9], [20, 7], [90, 6]]; }],
|
||||
['baseCurve negative speed', (d) => { d.baseCurve = [[0, 5], [90, -2]]; }],
|
||||
['baseCurve ends before duration', (d) => { d.baseCurve = [[0, 5], [40, 9]]; }],
|
||||
['dirCurve absent', (d) => { delete d.dirCurve; }],
|
||||
['gusts absent', (d) => { delete d.gusts; }],
|
||||
['gusts.minGap zero', (d) => { d.gusts.minGap = 0; }],
|
||||
['gusts.maxGap < minGap', (d) => { d.gusts.minGap = 9; d.gusts.maxGap = 4; }],
|
||||
['gusts overlap (minGap < gust length)', (d) => { d.gusts.minGap = 2; }],
|
||||
['debris event with no model', (d) => { d.events = [{ t: 10, type: 'debris' }]; }],
|
||||
['event with no type', (d) => { d.events = [{ t: 10 }]; }],
|
||||
['windchange that dirCurve never delivers', (d) => {
|
||||
d.dirCurve = [[0, 0.9], [90, 1.0]];
|
||||
d.events = [{ t: 55, type: 'windchange', telegraph: 6 }];
|
||||
}],
|
||||
];
|
||||
for (const [label, mutate] of cases) {
|
||||
const d = base();
|
||||
mutate(d);
|
||||
const { ok } = validateStorm(d, 'broken');
|
||||
assert(!ok, `validator ACCEPTED a storm with: ${label}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 4. determinism ----
|
||||
// Everything downstream (selftest fast-forward, Lane B's byte-equal load
|
||||
// traces) rests on this. Two builds of the same storm must be indiscernible.
|
||||
test('same def + same seed => identical trace', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const a = createWindField(def).setShelters(TREE_SHELTERS);
|
||||
const b = createWindField(def).setShelters(TREE_SHELTERS);
|
||||
const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
|
||||
for (let t = 0; t <= def.duration; t += DT) {
|
||||
for (const p of PROBES) {
|
||||
a.vecAt(p.x, p.z, t, va);
|
||||
b.vecAt(p.x, p.z, t, vb);
|
||||
assert(va.x === vb.x && va.z === vb.z,
|
||||
`diverged at t=${t.toFixed(3)} probe=(${p.x},${p.z})`);
|
||||
}
|
||||
}
|
||||
assert(a.gusts.length === b.gusts.length, 'gust timelines differ in length');
|
||||
a.gusts.forEach((g, i) => {
|
||||
assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `gust ${i} differs`);
|
||||
});
|
||||
});
|
||||
|
||||
test('different seed => different storm', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const a = createWindField(def, { seed: 1 });
|
||||
const b = createWindField(def, { seed: 2 });
|
||||
const same = a.gusts.length === b.gusts.length
|
||||
&& a.gusts.every((g, i) => g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow);
|
||||
assert(!same, 'seed is being ignored — every storm would be identical');
|
||||
});
|
||||
|
||||
// ---- 5. sampling order must not matter ----
|
||||
// sample() is called by sail/player/debris/rain in whatever order the frame
|
||||
// happens to run. If it ever depends on call order, storms stop replaying.
|
||||
test('sample order independent', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
// same fixed t values, walked in two different orders, on two fields
|
||||
const times = [0, 3.5, 17.3, 4.1, 55.0, 88.9, 63.2, 21.7, 39.9, 70.4];
|
||||
const sorted = [...times].sort((a, b) => a - b);
|
||||
const inOrder = createWindField(def).setShelters(TREE_SHELTERS);
|
||||
const shuffled = createWindField(def).setShelters(TREE_SHELTERS);
|
||||
const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
|
||||
|
||||
const seen = new Map();
|
||||
for (const t of sorted) {
|
||||
inOrder.vecAt(3, -2, t, va);
|
||||
seen.set(t, { x: va.x, z: va.z });
|
||||
}
|
||||
for (const t of times) { // deliberately out of order, and jumping backwards
|
||||
shuffled.vecAt(3, -2, t, vb);
|
||||
const want = seen.get(t);
|
||||
assert(vb.x === want.x && vb.z === want.z,
|
||||
`sampling order changed the wind at t=${t}: ${vb.x},${vb.z} vs ${want.x},${want.z}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 6. the storms are what the design says they are (PLAN3D §7) ----
|
||||
// storm_02 has to be able to destroy a flat cheap rig; storm_01 must not.
|
||||
test('storm_02 is genuinely violent, storm_01 is not', () => {
|
||||
const wild = createWindField(storms.storm_02_wildnight);
|
||||
const gentle = createWindField(storms.storm_01_gentle);
|
||||
const peak = (f) => {
|
||||
let mx = 0, mxBase = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
mx = Math.max(mx, f.uniformSpeed(t));
|
||||
mxBase = Math.max(mxBase, f.uniformSpeed(t) - f.gustOnly(t));
|
||||
}
|
||||
return { mx, mxBase };
|
||||
};
|
||||
const w = peak(wild), g = peak(gentle);
|
||||
metrics['storm_02.peakGustSpeed'] = +w.mx.toFixed(2);
|
||||
metrics['storm_02.peakSustained'] = +w.mxBase.toFixed(2);
|
||||
metrics['storm_01.peakGustSpeed'] = +g.mx.toFixed(2);
|
||||
metrics['storm_01.peakSustained'] = +g.mxBase.toFixed(2);
|
||||
assert(w.mx >= 30, `storm_02 peaks at only ${w.mx.toFixed(1)} m/s — won't break a cheap rig`);
|
||||
assert(w.mxBase >= 18, `storm_02 sustained peaks at only ${w.mxBase.toFixed(1)} m/s`);
|
||||
assert(g.mx <= 15, `storm_01 peaks at ${g.mx.toFixed(1)} m/s — too wild for the gentle storm`);
|
||||
assert(w.mx > g.mx * 2, 'storm_02 should be far worse than storm_01');
|
||||
});
|
||||
|
||||
// ---- 7. wind change actually swings the wind ----
|
||||
test('storm_02 southerly change swings the wind', () => {
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
const ev = (storms.storm_02_wildnight.events || []).find((e) => e.type === 'windchange');
|
||||
assert(ev, 'storm_02 has no windchange event');
|
||||
const before = f.dirAt(ev.t - 5);
|
||||
const after = f.dirAt(ev.t + 8);
|
||||
const swing = Math.abs(after - before);
|
||||
metrics['storm_02.changeSwingRad'] = +swing.toFixed(3);
|
||||
assert(swing > 0.9, `change only swings ${swing.toFixed(2)} rad — should be a real slew`);
|
||||
// and the player must be warned before it lands
|
||||
assert((ev.telegraph ?? 0) >= 4, 'windchange telegraph is too short to react to');
|
||||
});
|
||||
|
||||
// ---- 8. shelters ----
|
||||
test('tree wind shadow bites downwind and nowhere else', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const bare = createWindField(def);
|
||||
const shad = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
|
||||
const t = 30;
|
||||
const d = bare.dirAt(t);
|
||||
const dx = Math.cos(d), dz = Math.sin(d);
|
||||
const lee = { x: dx * 5, z: dz * 5 }; // 5 m downwind of the tree
|
||||
const luv = { x: -dx * 5, z: -dz * 5 }; // 5 m upwind
|
||||
const leeS = shad.speedAt(lee.x, lee.z, t), leeB = bare.speedAt(lee.x, lee.z, t);
|
||||
const luvS = shad.speedAt(luv.x, luv.z, t), luvB = bare.speedAt(luv.x, luv.z, t);
|
||||
metrics['shelter.leeDrop'] = +(1 - leeS / leeB).toFixed(3);
|
||||
assert(leeS < leeB * 0.85, `lee side only dropped to ${(leeS / leeB).toFixed(2)}× — shadow too weak`);
|
||||
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
/** Run every case and collect results. Used by run-node.mjs. */
|
||||
export function runWeatherSuite(storms) {
|
||||
const { cases, metrics } = weatherCases(storms);
|
||||
const results = cases.map((c) => {
|
||||
try {
|
||||
c.fn();
|
||||
return { name: c.name, ok: true };
|
||||
} catch (e) {
|
||||
return { name: c.name, ok: false, err: e.message };
|
||||
}
|
||||
});
|
||||
const pass = results.filter((r) => r.ok).length;
|
||||
return { suite: 'weather', pass, fail: results.length - pass, results, metrics };
|
||||
}
|
||||
399
web/world/js/weather.core.js
Normal file
399
web/world/js/weather.core.js
Normal file
@ -0,0 +1,399 @@
|
||||
'use strict';
|
||||
// SHADES — Lane C — wind field core.
|
||||
//
|
||||
// Pure math. Zero imports: no THREE, no DOM, no Date.now, no rAF. Everything is
|
||||
// a closed-form function of (pos, t) given a storm def + seed, which buys us:
|
||||
// - selftest can fast-forward a 90 s storm and get identical numbers every run
|
||||
// - consumers can sample any t, in any order, as often as they like
|
||||
// - the determinism rule (PLAN3D §4) is structural, not a promise
|
||||
//
|
||||
// weather.js wraps this to expose the contracts.js surface (Vector3 in/out).
|
||||
// The prototype scheduled gusts by INTEGRATING (wind.gustT += dt). We can't —
|
||||
// sample(pos,t) is called by everyone at arbitrary t. So gusts are precomputed
|
||||
// into a timeline from a seeded PRNG at storm load; the envelope shape below is
|
||||
// a faithful port of prototype/game.js, just read from t instead of accumulated.
|
||||
|
||||
// ---------- gust envelope (ported from prototype/game.js windVec) ----------
|
||||
// telegraph: wind hasn't risen yet, but you can SEE it coming (grass, band, audio)
|
||||
export const GUST = Object.freeze({
|
||||
TELEGRAPH: 1.5, // gt < 1.5 → 0 "it's coming"
|
||||
RAMP: 0.8, // 1.5 .. 2.3 → 0 → pow
|
||||
HOLD: 1.7, // 2.3 .. 4.0 → pow
|
||||
FADE: 1.0, // 4.0 .. 5.0 → pow → 0
|
||||
TOTAL: 5.0,
|
||||
});
|
||||
const RAMP_AT = GUST.TELEGRAPH; // 1.5
|
||||
const HOLD_AT = RAMP_AT + GUST.RAMP; // 2.3
|
||||
const FADE_AT = HOLD_AT + GUST.HOLD; // 4.0
|
||||
const END_AT = FADE_AT + GUST.FADE; // 5.0
|
||||
|
||||
/** Gust strength at local gust time gt (seconds since telegraph began). */
|
||||
export function gustEnvelope(gt, pow) {
|
||||
if (gt <= 0 || gt >= END_AT) return 0;
|
||||
if (gt < RAMP_AT) return 0; // telegraph window
|
||||
if (gt < HOLD_AT) return pow * (gt - RAMP_AT) / GUST.RAMP;
|
||||
if (gt < FADE_AT) return pow;
|
||||
return pow * (END_AT - gt) / GUST.FADE;
|
||||
}
|
||||
|
||||
// ---------- deterministic noise ----------
|
||||
// mulberry32 — small, fast, good enough, and identical in every JS engine.
|
||||
export function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// int32 hash — Math.imul keeps it exact (plain * would drift past 2^31 as a double)
|
||||
function hash2(ix, iz, seed) {
|
||||
let h = (Math.imul(ix, 374761393) + Math.imul(iz, 668265263) + Math.imul(seed, 1274126177)) | 0;
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||
h ^= h >>> 16;
|
||||
return (h >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
const smooth = (f) => f * f * (3 - 2 * f);
|
||||
|
||||
/**
|
||||
* Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps.
|
||||
*
|
||||
* @param {number} [period] Wrap the lattice at this many cells, making the noise
|
||||
* tile seamlessly over [0, period). The wind doesn't want this (the yard would
|
||||
* repeat); a scrolling cloud texture does, or every wrap boundary is a visible
|
||||
* straight edge in the sky. Pass an integer that matches your frequency.
|
||||
*/
|
||||
export function valueNoise2(x, z, seed, period = 0) {
|
||||
const ix = Math.floor(x), iz = Math.floor(z);
|
||||
const ux = smooth(x - ix), uz = smooth(z - iz);
|
||||
// branch, not a closure: this is the wind's hot path (the cloth alone samples
|
||||
// it thousands of times a second) and a per-call allocation would show up.
|
||||
let x0 = ix, x1 = ix + 1, z0 = iz, z1 = iz + 1;
|
||||
if (period > 0) {
|
||||
x0 = ((x0 % period) + period) % period;
|
||||
x1 = ((x1 % period) + period) % period;
|
||||
z0 = ((z0 % period) + period) % period;
|
||||
z1 = ((z1 % period) + period) % period;
|
||||
}
|
||||
const a = hash2(x0, z0, seed), b = hash2(x1, z0, seed);
|
||||
const c = hash2(x0, z1, seed), d = hash2(x1, z1, seed);
|
||||
return (a + (b - a) * ux) * (1 - uz) + (c + (d - c) * ux) * uz;
|
||||
}
|
||||
|
||||
export function smoothstep(e0, e1, x) {
|
||||
if (e0 === e1) return x < e0 ? 0 : 1;
|
||||
const f = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
|
||||
return smooth(f);
|
||||
}
|
||||
|
||||
// ---------- curves ----------
|
||||
/** Piecewise-linear [[t,v],...] lookup, clamped at both ends. */
|
||||
export function sampleCurve(curve, t) {
|
||||
if (!curve || curve.length === 0) return 0;
|
||||
if (t <= curve[0][0]) return curve[0][1];
|
||||
const last = curve[curve.length - 1];
|
||||
if (t >= last[0]) return last[1];
|
||||
for (let i = 1; i < curve.length; i++) {
|
||||
if (t <= curve[i][0]) {
|
||||
const [ta, va] = curve[i - 1], [tb, vb] = curve[i];
|
||||
const span = tb - ta;
|
||||
return span <= 0 ? vb : va + (vb - va) * ((t - ta) / span);
|
||||
}
|
||||
}
|
||||
return last[1];
|
||||
}
|
||||
|
||||
/** Shortest-arc angle lerp — so a curve crossing ±π doesn't spin the long way. */
|
||||
export function lerpAngle(a, b, k) {
|
||||
const TAU = Math.PI * 2;
|
||||
let d = ((b - a + Math.PI) % TAU + TAU) % TAU - Math.PI;
|
||||
return a + d * k;
|
||||
}
|
||||
|
||||
function sampleAngleCurve(curve, t) {
|
||||
if (!curve || curve.length === 0) return 0;
|
||||
if (t <= curve[0][0]) return curve[0][1];
|
||||
const last = curve[curve.length - 1];
|
||||
if (t >= last[0]) return last[1];
|
||||
for (let i = 1; i < curve.length; i++) {
|
||||
if (t <= curve[i][0]) {
|
||||
const [ta, va] = curve[i - 1], [tb, vb] = curve[i];
|
||||
const span = tb - ta;
|
||||
return span <= 0 ? vb : lerpAngle(va, vb, (t - ta) / span);
|
||||
}
|
||||
}
|
||||
return last[1];
|
||||
}
|
||||
|
||||
// ---------- gust timeline ----------
|
||||
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
|
||||
export function buildGustTimeline(def, seed) {
|
||||
const g = def.gusts || {};
|
||||
const rng = mulberry32(seed >>> 0);
|
||||
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
|
||||
const out = [];
|
||||
let t = g.firstAt ?? 3;
|
||||
// hard cap: a malformed gap can't spin us forever
|
||||
while (t < def.duration && out.length < 512) {
|
||||
const p = def.duration > 0 ? t / def.duration : 0;
|
||||
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
|
||||
out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
|
||||
t += minGap + rng() * Math.max(0, maxGap - minGap);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- the field ----------
|
||||
/**
|
||||
* @param {object} def parsed storm JSON (see data/storms/*.json)
|
||||
* @param {object} [opts] {seed}
|
||||
*/
|
||||
export function createWindField(def, opts = {}) {
|
||||
const seed = (opts.seed ?? def.seed ?? 1) >>> 0;
|
||||
const duration = def.duration ?? 90;
|
||||
const gusts = buildGustTimeline(def, seed);
|
||||
const sp = def.spatial || {};
|
||||
const amp = sp.amp ?? 0.18; // ±18% speed across the yard
|
||||
const scale = sp.scale ?? 12; // metres per noise cell — yard is 30×20
|
||||
const advect = sp.advect ?? 0.5; // noise drifts downwind (frozen turbulence)
|
||||
const wander = def.dirWander || {};
|
||||
const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13;
|
||||
const nSeed = (seed ^ 0x9e3779b9) | 0;
|
||||
|
||||
let shelters = [];
|
||||
|
||||
/** Spatially-uniform part: base curve + every gust envelope live at t. */
|
||||
function uniformSpeed(t) {
|
||||
let s = sampleCurve(def.baseCurve, t);
|
||||
for (let i = 0; i < gusts.length; i++) {
|
||||
const g = gusts[i];
|
||||
if (t <= g.t0) break; // sorted — nothing later can be live
|
||||
if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function gustOnly(t) {
|
||||
let s = 0;
|
||||
for (let i = 0; i < gusts.length; i++) {
|
||||
const g = gusts[i];
|
||||
if (t <= g.t0) break;
|
||||
if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function dirAt(t) {
|
||||
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
|
||||
}
|
||||
|
||||
// ---- noise drift ----
|
||||
// The noise pattern rides downwind with the mean flow (Taylor's frozen
|
||||
// turbulence), so a gust visibly travels ACROSS the yard instead of blinking on
|
||||
// everywhere at once. That displacement is an integral, D(t) = ∫ advect·U·dir dτ,
|
||||
// and it has to be integrated as one: the obvious closed form `U(t)·advect·t`
|
||||
// is not the integral, and it whips the whole accumulated field sideways the
|
||||
// instant U or dir moves — a 6.8 m/s single-frame jump at the southerly change,
|
||||
// which the continuity assert caught. So integrate once at build time into an
|
||||
// immutable table; sampling stays a pure function of t.
|
||||
// Mean flow only (base curve, no gusts): eddies are carried by the wind, they
|
||||
// don't surf their own gust, and it keeps the drift rate smooth.
|
||||
const DRIFT_DT = 0.25;
|
||||
const driftX = [], driftZ = [];
|
||||
{
|
||||
let dx = 0, dz = 0;
|
||||
const n = Math.ceil((duration + 2) / DRIFT_DT) + 2;
|
||||
for (let i = 0; i < n; i++) {
|
||||
driftX.push(dx); driftZ.push(dz);
|
||||
const tt = i * DRIFT_DT;
|
||||
const u = sampleCurve(def.baseCurve, tt) * advect;
|
||||
const d = dirAt(tt);
|
||||
dx += Math.cos(d) * u * DRIFT_DT;
|
||||
dz += Math.sin(d) * u * DRIFT_DT;
|
||||
}
|
||||
}
|
||||
const drift = { x: 0, z: 0 };
|
||||
function driftAt(t) {
|
||||
if (t <= 0) { drift.x = 0; drift.z = 0; return drift; }
|
||||
const f = t / DRIFT_DT;
|
||||
let i = Math.floor(f);
|
||||
if (i > driftX.length - 2) i = driftX.length - 2; // past the end: extrapolate
|
||||
const k = f - i;
|
||||
drift.x = driftX[i] + (driftX[i + 1] - driftX[i]) * k;
|
||||
drift.z = driftZ[i] + (driftZ[i + 1] - driftZ[i]) * k;
|
||||
return drift;
|
||||
}
|
||||
|
||||
/** Speed multiplier: smooth noise, carried downwind. */
|
||||
function spatialFactor(x, z, t) {
|
||||
if (amp <= 0) return 1;
|
||||
const d = driftAt(t);
|
||||
const nx = (x - d.x) / scale;
|
||||
const nz = (z - d.z) / scale;
|
||||
const n = 0.65 * valueNoise2(nx, nz, nSeed)
|
||||
+ 0.35 * valueNoise2(nx * 2.2 + 31.7, nz * 2.2 + 11.3, nSeed ^ 0x51ed270b);
|
||||
return 1 + (n - 0.5) * 2 * amp;
|
||||
}
|
||||
|
||||
/** Trees knock a hole downwind of themselves. Cheap, and very juicy. */
|
||||
function shelterFactor(x, z, dirX, dirZ) {
|
||||
let f = 1;
|
||||
for (let i = 0; i < shelters.length; i++) {
|
||||
const s = shelters[i];
|
||||
const rx = x - s.x, rz = z - s.z;
|
||||
const along = rx * dirX + rz * dirZ; // >0 = downwind of the tree
|
||||
if (along <= 0 || along >= s.length) continue;
|
||||
const perp = Math.abs(rx * dirZ - rz * dirX);
|
||||
if (perp >= s.radius) continue;
|
||||
// ramp in over the first half-radius so the shadow can't snap on at along=0
|
||||
const fAlong = smoothstep(0, s.radius * 0.5, along) * (1 - smoothstep(0, s.length, along));
|
||||
const fPerp = 1 - smoothstep(0, s.radius, perp);
|
||||
f *= 1 - s.strength * fAlong * fPerp;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
const field = {
|
||||
def,
|
||||
seed,
|
||||
gusts,
|
||||
duration,
|
||||
|
||||
/**
|
||||
* Trees/house register wind shadows. Lane A calls this after building the
|
||||
* yard; unset = no shadows, so nothing breaks before world.js lands.
|
||||
* @param {Array<{x,z,radius,strength,length}>} list
|
||||
*/
|
||||
setShelters(list) {
|
||||
shelters = (list || []).map((s) => ({
|
||||
x: s.x, z: s.z,
|
||||
radius: s.radius ?? 2.5,
|
||||
strength: Math.min(1, Math.max(0, s.strength ?? 0.45)),
|
||||
length: s.length ?? (s.radius ?? 2.5) * 4,
|
||||
}));
|
||||
return field;
|
||||
},
|
||||
get shelters() { return shelters; },
|
||||
|
||||
/** Scalar wind speed (m/s) at a point. 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;
|
||||
},
|
||||
|
||||
dirAt,
|
||||
uniformSpeed,
|
||||
gustOnly,
|
||||
|
||||
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
|
||||
vecAt(x, z, t, out) {
|
||||
const uni = uniformSpeed(t);
|
||||
const d = dirAt(t);
|
||||
const dirX = Math.cos(d), dirZ = Math.sin(d);
|
||||
let s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
|
||||
if (s < 0) s = 0;
|
||||
out.x = dirX * s;
|
||||
out.y = 0; // wind is horizontal; lift is the sail's job (Lane B)
|
||||
out.z = dirZ * s;
|
||||
return out;
|
||||
},
|
||||
|
||||
/**
|
||||
* The next gust that has been telegraphed but hasn't started ramping.
|
||||
* eta = seconds until the wind actually rises. Null when nothing's inbound.
|
||||
*/
|
||||
telegraph(t) {
|
||||
for (let i = 0; i < gusts.length; i++) {
|
||||
const g = gusts[i];
|
||||
if (t < g.t0) return null; // sorted — next one hasn't telegraphed yet
|
||||
if (t < g.rampAt) {
|
||||
return { eta: g.rampAt - t, dir: dirAt(g.rampAt), power: g.pow };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/** Storm events (windchange/debris) fired in (a, b]. Pure — replayable. */
|
||||
eventsBetween(a, b) {
|
||||
const evs = def.events || [];
|
||||
const out = [];
|
||||
for (let i = 0; i < evs.length; i++) {
|
||||
if (evs[i].t > a && evs[i].t <= b) out.push(evs[i]);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** 0..1 rain intensity for skyfx. */
|
||||
rainAt(t) {
|
||||
const r = def.rain;
|
||||
if (!r) return 0;
|
||||
if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t)));
|
||||
return Math.min(1, Math.max(0, r.intensity ?? 0));
|
||||
},
|
||||
};
|
||||
|
||||
return field;
|
||||
}
|
||||
|
||||
// ---------- storm JSON validator ----------
|
||||
// Storms are data so design can tune without code (PLAN3D §4) — which means a
|
||||
// typo is a data bug, and data bugs should fail loud, not silently blow calm.
|
||||
export function validateStorm(def, name = 'storm') {
|
||||
const errors = [];
|
||||
const bad = (m) => errors.push(`${name}: ${m}`);
|
||||
const isCurve = (c) => Array.isArray(c) && c.length > 0
|
||||
&& c.every((p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite));
|
||||
const monotonic = (c) => c.every((p, i) => i === 0 || p[0] >= c[i - 1][0]);
|
||||
|
||||
if (!def || typeof def !== 'object') { bad('not an object'); return { ok: false, errors }; }
|
||||
if (!Number.isFinite(def.duration) || def.duration <= 0) bad('duration must be a positive number');
|
||||
|
||||
if (!isCurve(def.baseCurve)) bad('baseCurve must be [[t,speed],...] of finite numbers');
|
||||
else {
|
||||
if (!monotonic(def.baseCurve)) bad('baseCurve t must be non-decreasing');
|
||||
if (def.baseCurve.some((p) => p[1] < 0)) bad('baseCurve speed must be >= 0');
|
||||
const end = def.baseCurve[def.baseCurve.length - 1][0];
|
||||
if (Number.isFinite(def.duration) && end < def.duration) {
|
||||
bad(`baseCurve ends at t=${end} but storm runs to ${def.duration} — tail would flatline`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCurve(def.dirCurve)) bad('dirCurve must be [[t,radians],...] of finite numbers');
|
||||
else if (!monotonic(def.dirCurve)) bad('dirCurve t must be non-decreasing');
|
||||
|
||||
const g = def.gusts;
|
||||
if (!g || typeof g !== 'object') bad('gusts block missing');
|
||||
else {
|
||||
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
|
||||
if (!(minGap > 0)) bad('gusts.minGap must be > 0 (else the timeline never advances)');
|
||||
if (maxGap < minGap) bad('gusts.maxGap must be >= minGap');
|
||||
// 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');
|
||||
}
|
||||
|
||||
for (const e of def.events || []) {
|
||||
if (!Number.isFinite(e.t)) bad(`event ${JSON.stringify(e)} has no finite t`);
|
||||
if (!e.type) bad(`event at t=${e.t} has no type`);
|
||||
if (e.type === 'debris' && !e.model) bad(`debris event at t=${e.t} has no model`);
|
||||
// A windchange event is HUD metadata; dirCurve is the physics. If they drift
|
||||
// apart the player gets warned about a swing that never comes.
|
||||
if (e.type === 'windchange' && isCurve(def.dirCurve)) {
|
||||
const before = sampleAngleCurve(def.dirCurve, e.t - 0.5);
|
||||
const after = sampleAngleCurve(def.dirCurve, e.t + (e.over ?? 6));
|
||||
const swing = Math.abs(lerpAngle(before, after, 1) - before);
|
||||
if (swing < 0.5) {
|
||||
bad(`windchange at t=${e.t} promises a swing but dirCurve only turns ${swing.toFixed(2)} rad by t=${e.t + (e.over ?? 6)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (def.rain && def.rain.curve && !isCurve(def.rain.curve)) bad('rain.curve must be [[t,intensity],...]');
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
101
web/world/js/weather.js
Normal file
101
web/world/js/weather.js
Normal file
@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
// SHADES — Lane C — weather: the wind field everyone samples.
|
||||
//
|
||||
// Implements the contracts.js wind surface (PLAN3D §4):
|
||||
// wind.sample(pos, t) -> Vector3 m/s, includes gusts & local effects
|
||||
// wind.gustTelegraph(t) -> {eta, dir, power} | null
|
||||
//
|
||||
// All the maths lives in weather.core.js (pure, no imports). This file is just
|
||||
// the THREE adapter + storm loading, so the sim stays node-testable and the
|
||||
// determinism rule can't be broken by accident.
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { createWindField, validateStorm, GUST } from './weather.core.js';
|
||||
|
||||
export { GUST, validateStorm };
|
||||
|
||||
// Resolved against this module, not the server root: server.py serves the repo
|
||||
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
||||
// import.meta.url is right under both, and under whatever Lane A does next.
|
||||
const STORM_DIR = new URL('../data/storms', import.meta.url).href;
|
||||
|
||||
/** Fetch + validate a storm def. Throws loud on bad data — storms are content. */
|
||||
export async function loadStorm(name, dir = STORM_DIR) {
|
||||
const url = `${dir}/${name}.json`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`weather: cannot load ${url} (${res.status})`);
|
||||
const def = await res.json();
|
||||
const { ok, errors } = validateStorm(def, name);
|
||||
if (!ok) throw new Error(`weather: ${url} is invalid:\n ${errors.join('\n ')}`);
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} def parsed storm JSON
|
||||
* @param {object} [opts] {seed} — same seed + same def = same storm, every run
|
||||
* @returns the `wind` object from contracts.js
|
||||
*/
|
||||
export function createWind(def, opts = {}) {
|
||||
const field = createWindField(def, opts);
|
||||
const scratch = { x: 0, y: 0, z: 0 };
|
||||
|
||||
const wind = {
|
||||
/**
|
||||
* Wind velocity at a world position, m/s.
|
||||
* @param {THREE.Vector3} pos
|
||||
* @param {number} t storm time, seconds
|
||||
* @param {THREE.Vector3} [out] pass one to avoid allocating — sail.js
|
||||
* samples per-face per-frame, so this matters
|
||||
*/
|
||||
sample(pos, t, out) {
|
||||
const v = out || new THREE.Vector3();
|
||||
field.vecAt(pos.x, pos.z, t, scratch);
|
||||
return v.set(scratch.x, scratch.y, scratch.z);
|
||||
},
|
||||
|
||||
/** Scalar speed — for HUD, rain, grass. Cheaper than sample(); no allocation. */
|
||||
speedAt(pos, t) {
|
||||
return field.speedAt(pos.x, pos.z, t);
|
||||
},
|
||||
|
||||
/** {eta, dir, power} while a gust is inbound but hasn't risen yet, else null. */
|
||||
gustTelegraph(t) {
|
||||
return field.telegraph(t);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register wind shadows (trees, house). Lane A: call after the yard is built.
|
||||
* Until then there are simply no shadows — nothing breaks.
|
||||
* @param {Array<{x,z,radius,strength,length}>} list
|
||||
*/
|
||||
setShelters(list) { field.setShelters(list); return wind; },
|
||||
|
||||
/** Convenience: take shadows straight off world.anchors' tree entries. */
|
||||
setSheltersFromTrees(trees, o = {}) {
|
||||
return wind.setShelters(trees.map((tr) => ({
|
||||
x: tr.pos ? tr.pos.x : tr.x,
|
||||
z: tr.pos ? tr.pos.z : tr.z,
|
||||
radius: o.radius ?? tr.radius ?? 3,
|
||||
strength: o.strength ?? 0.45,
|
||||
length: o.length ?? 14,
|
||||
})));
|
||||
},
|
||||
|
||||
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
|
||||
eventsBetween(a, b) { return field.eventsBetween(a, b); },
|
||||
|
||||
/** 0..1 rain intensity. */
|
||||
rainAt(t) { return field.rainAt(t); },
|
||||
|
||||
/** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
|
||||
dirAt(t) { return field.dirAt(t); },
|
||||
|
||||
get duration() { return field.duration; },
|
||||
get gusts() { return field.gusts; },
|
||||
get def() { return field.def; },
|
||||
get seed() { return field.seed; },
|
||||
core: field,
|
||||
};
|
||||
|
||||
return wind;
|
||||
}
|
||||
@ -343,7 +343,11 @@ export function createWorld(scene, opts = {}) {
|
||||
sunDir: SUN_DIR.clone(),
|
||||
solids,
|
||||
root,
|
||||
// Lane C's skyfx MODULATES these as the storm builds and hands them back
|
||||
// untouched on dispose() — it doesn't own them. That's why the yard exposes
|
||||
// its lights rather than keeping them private.
|
||||
sun,
|
||||
hemi,
|
||||
|
||||
/** @param {string} id */
|
||||
anchor(id) {
|
||||
|
||||
BIN
web/world/models/debris/wheelie_bin_01_v1.glb
Normal file
BIN
web/world/models/debris/wheelie_bin_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/garden_gnome_01_v1.glb
Normal file
BIN
web/world/models/garden_gnome_01_v1.glb
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
web/world/models/textures/sail_tears.png
Normal file
BIN
web/world/models/textures/sail_tears.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
web/world/models/textures/sail_weave.png
Normal file
BIN
web/world/models/textures/sail_weave.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
Binary file not shown.
BIN
web/world/models/washing_line_01_v1.glb
Normal file
BIN
web/world/models/washing_line_01_v1.glb
Normal file
Binary file not shown.
@ -35,6 +35,10 @@
|
||||
<div id="summary">running…</div>
|
||||
<div id="out"></div>
|
||||
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "./vendor/three.module.js",
|
||||
"three/addons/": "./vendor/addons/" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import { runAll } from './js/testkit.js';
|
||||
|
||||
|
||||
263
web/world/weather_demo.html
Normal file
263
web/world/weather_demo.html
Normal file
@ -0,0 +1,263 @@
|
||||
<!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