diff --git a/B-progress.md b/B-progress.md index 682978f..115a284 100644 --- a/B-progress.md +++ b/B-progress.md @@ -4,6 +4,63 @@ plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's `generatePlan` (which landed mid-session). Everything below was verified live in a browser. +## Round 38 (v8 WAVE 1 — INHABITATION) — all four runtime items, **+1 walked draw for the lot** + +Full detail + every number in LANE_B_NOTES §38. Measured as a true A/B: two port-isolated no-store +servers (`:8472` = `git archive HEAD`, `:8471` = treatment, assets symlinked so E's mid-round wave is +identical on both), 102 walked 8 m spine steps at yaw 0, each frame fully streamed, rendered through +the shell's own composer. **The citizen sim + tram are hidden identically on both sides** — with them +live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time. + +| walked spine, sim quiet | HEAD | R38 | delta | +|---|---|---|---| +| worst draws | 224 | **225** | **+1** | +| worst tris | 89,502 | **93,298** | **+3,796** (of ~75k spare — the cheap resource, spent) | +| sum draws over 102 frames | 16,676 | **16,663** | **−13** | + +Isolated at a fixed pose: **washing +1 draw / +512 tris · magpie +1 draw / +182 tris · every yard box +prop +0 draws.** `?yards=0` reproduces HEAD **exactly** (224 / 89,502 / 16,676). + +- **§1.2 the character vector** — `character.js`, 23 towns, ground palette + sky bias + ambience mix. + **+0 draws, +0 tris.** Classic is byte-identical **by construction, not by flag**: `townCharacter(null)` + returns the v1 literals, and a null tint keeps the *same material cache key*, so classic gets the same + object. Default-boot ground hashes identical to HEAD mesh-for-mesh; `?classic=1` hashes unmoved from + R37. Sky feeds the **base** `skyName`, never `setSky` (a one-way latch that would have killed R32's + dawn dome on all 23 towns). The vacuous arm is **per-town** — `newtown_godverse`/`redhill_godverse` + carry `state: ''`, so a per-corpus arm would have dropped exactly those two silently. **21 distinct + palettes across 23 towns.** `gravel` and `reddust` were not unwired but *unwireable* — their `use` + values named no slot `ground.js` had; making the slot addressable fixed it at the root. E's three new + grounds landed mid-round and the upgrade was a name change in one table. +- **§1.4 backyard Australia** — inside `boxMats`, **+0 draws**. 145 houses inset off their boundaries so + there is a yard to put things in; **36 `yard` lots that had rendered as blank shopfront sheds since v1** + are now fenced blocks with a hoist, a shed and often a tank. Collision byte-identical. +- **§1.4's washing** — **ONE town-wide InstancedMesh, +1 draw at any N**, cap 256, proximity-first off + the R37 chunk seam. Per-chunk would have been +1 × 31 live chunks = 4× the whole margin. **0 on every + real town** (no hoists ⇒ no mesh) and **0 at night** (it comes in). I **wrote the inverted wind mode** + rather than dropping it: the shipped top-weighted weight moves a hem **0.10 cm calm / 0.67 cm gusty**; + `pegged` gives **4.5 cm / 31 cm**, a ~52× gain, in the right place. The `canopy` string is byte-for-byte + unchanged. +- **§1.3 the magpie** — **+1 draw on screen, exactly 0 otherwise; 182 tris of 900.** Territory scales + with the graph: **6 on 6.78 km synthetic → 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m.** + Latch is runtime-only: it sets, the day roll clears it, **localStorage gains no key** and the save file + never mentions it. `swoopPos()` is exported and pure. The hat stays cut. +- **§1.1 the audio layer** — **six new mixer layers, zero draws, zero tris**, seeded anchors, distance + falloff, and **a `StereoPannerNode` per layer** so direction is real (measured spread −0.71…+0.85) + instead of a limitation written down. Day-of-week derived locally. All six anchored on the default + boot; **3 of 6 report `no-anchor` on real towns** rather than being faked. The falsifiability control + — one manifest key renamed away on a third port — makes that source report **`no-entry`, 0 plays, + never `audible`**, five others unchanged. + +**Two defects my own green numbers could not see, both caught by a screenshot.** The washing shipped at +256 instances / +1 draw / cap respected **and was invisible behind its own back fence** (per-instance peg +height + a fence taller than the hoist); the magpie perched **on thin air** over the road. R37's lesson, +relearned at full price. Territories now snap to `furniture.js`'s own streetlight rule. + +**Lane E's props, adjudicated:** hoist + aerial **permanently primitive** (E's spike killed them +outright, and primitive is also the +0-draw mechanism) · `paling_fence` at 1,121 tris × **905 runs = +1.01 M triangles** — no · `magpie` GLB **held for the tint**: it currently reads as a crow, and a magpie +seen for 1.2 s in peripheral vision *is* its white bar. My 182-tri bird already carries it. + ## Round 37 (v8 WAVE 0 §0.1 + §0.4) — THE KERB IS FOOTPATH, at +0 draws · commit `47db478` John's ruling landed as geometry. `ground.js` narrows the road quad to the carriageway and extends diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index 9d75577..dd48eeb 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -1,5 +1,278 @@ # LANE B — NOTES (measured) +## Round 38 (v8 WAVE 1 — INHABITATION, all four runtime items) — **someone lives here, for +1 walked draw** + +**Four items, all built procedural-first, all measured walked. The whole round costs +1 draw at the worst +walked frame and −13 draws summed over 102 of them; the only item that spends a draw at all is the +washing. Two of the four found a defect in their own first cut that every number was green through.** + +### The A/B, and how it was taken +Two **port-isolated no-store servers** at 2560×1440: `:8472` serving `git archive HEAD web/js web/index.html` +(assets symlinked from the live tree, so Lane E's mid-round asset wave is identical on both sides) and +`:8471` serving the treatment. Same 102-step walk, 8 m steps down the concatenated `main` spine at +**yaw 0** (the audit's bearing, the worst one), each frame **fully streamed** (drain to `pending === 0`) +and rendered through **the shell's own composer**, not a bare `renderer.render`. + +**The citizen sim and the tram are hidden for the walk, identically on both builds** — and that is not +tidiness, it is the measurement. The first run had them live and read **control 346 draws vs treatment +232 at the same step**: peds spawn on wall-clock time and each near rig is ~1 draw, so an A/B that +includes them measures the sim. R37 recorded this exact trap; R38 reproduced it inside an hour. + +| walked spine, 102 steps, sim quiet | HEAD control | R38 | delta | +|---|---|---|---| +| worst-draw frame | **224** | **225** | **+1** | +| worst-tri frame | **89,502** | **93,298** | **+3,796** | +| sum of draws, all 102 frames | 20,081-era method: **16,676** | **16,663** | **−13** | +| per-frame draw delta | — | — | **min −7 · max +3** | +| per-frame tri delta | — | — | +512 … +4,468, **mean +2,119** | +| live chunk count, every step | identical | identical | **0** | + +**`?yards=0` reproduces the HEAD control exactly** — worst 224 / 89,502, sum 16,676, the same three +numbers — so the flag is a true revert, not an approximation. + +**The residential district**, where all 181 yards actually are (edge 16, 107 steps): + +| | `?yards=0` | R38 | delta | +|---|---|---|---| +| worst draws | 138 | **139** | **+1** | +| worst tris | 19,308 | **21,128** | **+1,820** | +| sum draws / sum tris | 8,010 / 983,210 | 8,142 / 1,150,788 | +1.2 draws, +1,566 tris per frame | + +**Isolated, at a fixed residential pose, by toggling each layer on one frame:** +**washing = +1 draw / +512 tris** (256 instances × 2) · **magpie = +1 draw / +182 tris** · +**every yard box prop = +0 draws** (it is instances in a draw call that was already submitted). + +### §1.2 — the per-town character vector: **+0 draws, +0 tris, and classic byte-identical BY CONSTRUCTION** +`web/js/world/character.js` — 23 town keys, each with its own entry, returning a ground palette, a sky +bias and an ambience mix. `townCharacter(null)` returns `CLASSIC_CHARACTER`: the exact v1 literals. +- **There is no `if (classic)` in the ground path.** A boot with no town key has no character, and that + is what both `?classic=1` and the synthetic default get. Proven, not argued: the default boot's ground + vertex+UV hashes are **`c4c72181 / 735250c9 / a300d701 / 12c1241b / bfa81387` on HEAD and on R38, + mesh for mesh**, with the same four maps and `color #ffffff` on all of them. `?classic=1` hashes + **`c4c72181 / c111d968 / 1a80ebcd / 12c1241b / fd0d3211`** — the R37-recorded classic set, unmoved. +- `skins.groundMat(name, tint)` keys the cache on the tint, and a **null** tint keeps the key exactly + `ground:` — so classic doesn't merely look the same, it gets the same cached material object. +- **The sky never touches `setSky`.** `createLighting` takes a `sky` option feeding the base `skyName`, + because `externalSky` (lighting.js:53) is a one-way latch with no un-set path — pushing a character + sky through it would have silently killed R32's dawn dome on all 23 towns and beaten the rain dome. +- **The vacuous arm is per-town.** Keying on `state` from the towns index is the trap: `newtown_godverse` + and `redhill_godverse` carry `state: ''` (2 of 23), so a per-corpus arm drops exactly those two into a + branch nobody printed. All 23 keys have their own entry (verified against the shipped index: 0 missing); + an unmatched key returns the frozen literals **with `matched:false`**, and the shell prints which key + fell through. **21 distinct palettes across 23 towns** (the 2 repeats are the deliberate godverse↔real + sibling pairs, written as two entries so it reads as a decision rather than a fallthrough). +- **Why the two orphans were orphans, and it isn't what it looks like** (Lane E's R38 finding, confirmed): + `gravel` and `reddust` carry `use` values — `"verge"` and `"outback"` — that name **no slot** the old + `ground.js` had; it hardcoded four skin names. They were not unwired, they were *unwireable*. Fixed at + the root by making the SLOT addressable: any skin name can be road / footpath / base / plaza, and + `use` goes back to documentation (`groundMat` never read it — it builds a filename). Both are now + selected: **gravel = Castlemaine's goldfields tailings, reddust = Darwin and both Red Hills.** +- **Lane E's grounds landed mid-round and the use-if-ready upgrade was a name change in one table**, + which is what procedural-first is for: `grass-dry` (Bendigo/Adelaide/Northbridge), `grass-temperate` + (Daylesford/Katoomba/Bowral/Hobart/Launceston), `coastal-sand` (Fremantle/Newcastle). Tints re-picked + against the delivered pixels (`grass-dry` measures **#c0b7aa**, a pale tussock, not a green). `skins.js`'s + fallback table gained three entries **prepended**, because `grass-dry`/`grass-temperate` both matched the + old bare `/grass/` and would have fallen back to the one colour neither of them is. + +| boot | character | ground actually bound | meshes | +|---|---|---|---| +| default / `?classic=1` | *(none — frozen v1)* | grass · djsim-road · djsim-footpath · brickpave, **no tints** | 5 | +| `darwin_real` | top-end red dust · sky `monsoon` | **reddust** · asphalt1 #c8a894 · footpath1 #e0c4a0 | 4 | +| `hobart_real` | cold-light southern · `cold-front` | **grass-temperate** #c0d4c8 · asphalt2 #9ea6ae · djsim-footpath #b4bcc2 | 4 | +| `bendigo_real` | goldfields dry · `heat-haze` | **grass-dry** #cbbf92 · asphalt1 #c8bfa8 · footpath2 #e6dcc4 | 4 | + +*(4 meshes not 5 on real towns: no market district ⇒ no plaza quad. Unchanged from pre-R38.)* + +### §1.4 — backyard Australia: **+0 draws for every box prop**, and a `yard` lot is finally a yard +Built **inside `buildings.js`'s existing `boxMats`** — the same per-chunk InstancedMesh that already +carries shells, parapets and verandah posts. A `world/yards.js` could not reach it and would have paid ++1 draw × up to 31 live chunks against a margin of 8. +- **181 lots on the default boot: 145 `house`, 36 `yard`** (the brief's number, confirmed at runtime). +- The dwelling is now **inset off its boundaries** — front garden, side gaps, and a back yard to put + things in. Pre-R38 the shell was the whole lot, which is why 145 houses had nowhere for a hoist. +- **A `yard` lot had fallen through to `buildShopfront()` since v1** — 36 lots Lane A explicitly labels + "yard" rendered as blank 3.2 m sheds with a decorative door and two windows. They are now fenced + blocks with a hoist, a fibro shed and often a tank. Net ~+100 tris each, because the shopfront it + replaces was ~230. +- **Collision is byte-identical**: `lotCollider(lot)` is unchanged on every lot, and the lot rect is + exactly where the fence now stands. +- Per lot: fence 5 boxes (back + 2 sides + a front picket split around a gate) · hoist 3 (mast + two + crossed arms = four arms) · aerial 3 (72% of roofs) · tank 2 (two boxes at 45° = a round tank in + silhouette for 24 tris). **12 tris a box, ~130 tris a yard.** +- **`?yards=0` reverts permanently** and reproduces HEAD exactly (see the table above); `?classic=1` + forces it off. + +**THE DEFECT MY OWN NUMBERS WERE GREEN THROUGH, AGAIN.** The washing shipped at 256 instances, +1 draw, ++512 tris, cap respected — **and was invisible from every angle a player can stand at.** Two causes, +both only findable in a picture: (1) the instance origin used the unscaled `PEG_DROP` constant instead +of the per-instance `sy`, so every garment hung with its pegs *below* the line; (2) the paling fence was +**1.75 m and the hoist 1.92 m**, so a whole street of washing sat behind its own back fence. Fixed by +measuring against the object rather than the spec: fence **1.60**, hoist **2.15** (a real hoist is +adjustable to 2.4 and a real paling fence is 1.5–1.8), and the peg line is now per-instance. The hoist's +arm was also a **6.4 m machine in a 5 m yard** — the cap was on the half-span and read as the full one. +*R37's lesson cost me a second round to relearn: green metrics over a wrong world.* + +### §1.4's washing — **the inverted wind weight, and why it is town-wide** +`wind.js:38-45` computes `wH = clamp(y/4.4,0,1); wH *= wH` — top-weighted, base planted. Correct for a +gum, **upside down for a sheet**, which is pegged at the top and free at the hem. On the shipped +amplitude (`updateWind`: 0.05 calm → 0.35 gusty) a 1.6 m line of washing under the canopy weight moves +**0.10 cm calm / 0.67 cm gusty at the hem, and most at the pegs.** I wrote the mode rather than dropping +the washing: `applyWind(mat, {mode:'pegged'})` weights by distance **below** the peg line — hem 1, pegs 0 +— giving **4.5 cm calm / 31 cm gusty at the hem, a ~52× gain and in the right place**, plus a faster +flutter and a Z-flap because a sheet is a sail and a gum is a spring. **The `canopy` string is +byte-for-byte what it was**, so the gum-tree program compiles from the same source and the R17 covenant +argument is untouched. A third mode, `wing`, weights by |x| from the centreline for the magpie. All three +ride the one shared `uWindAmp`, so dead calm is still exactly `* 0.0`. + +`web/js/world/washing.js` is **ONE town-wide InstancedMesh — 1 draw at any N**, cap **256**, spent +proximity-first and refilled off `chunks.onChunkBuilt` / `onChunkDisposed` (the seam R37 §0.4 wired for +exactly this). A per-chunk washing mesh is "+1 draw" the way any per-chunk thing is: **+1 × up to 31 +live chunks = 4× the entire remaining budget.** It is **free when empty and to the vendored line**: +all 21 real towns have **0 hoists ⇒ no mesh ⇒ exactly 0**, and at night `count = 0` (the washing comes +in) — measured `washingCount: 0, magpieCount: 0` at segment NIGHT. Pegs come off `hoistAnchors(plan, +citySeed)`, exported from `buildings.js` and derived from the **same `yardLayout`** the geometry is +built from, so a line of washing cannot hang next to a hoist that moved. 181 hoists → 394 items → 256 +active on the default boot. Fabric is a canvas weave: no fetch, `?noassets` is one code path. + +### §1.3 — the magpie: **+1 draw while he is on screen, exactly 0 otherwise; 182 tris of 900** +`web/js/world/magpie.js`. One town-wide InstancedMesh, `count` 0 or 1. Merged primitives with baked +vertex colours — black body, **white nape, white rump, white wing bars**. `swoopPos(launch, t)` is +exported and pure (the tram's contract); the only state is *when* a launch starts, recorded as three +numbers. The ice-cream-container hat is **cut** and stays cut — no street verb exists. + +**Territory scales with the road graph**, which was the binding correction and is the whole difference +between a feature and a rumour: + +| town | road graph | territories | nearest to spawn | +|---|---|---|---| +| synthetic default | 22 edges, **6.78 km** | **6** | — | +| `darwin_real` | 46.85 km | **39** | — | +| `hobart_real` | 98.42 km | **82** | — | +| `bendigo_real` | **2,593 edges, 188.6 km** | **157** | **120 m** (median 1,356 m) | + +One per ~1.2 km of street, and only the **nearest** is ever instantiated, re-picked on the chunk seam. +That is the *swooping* density, not the magpie density: territories are 2–4 ha so a suburb is thick with +magpies and ~90% of males never swoop, so the sparse layer IS the 10%. A capacity-4 magpie on Bendigo +would have put the nearest bird ~20 km away. + +**THE SEASON MECHANISM I SHIPPED FIRST WAS VACUOUS, AND THE SWEEP CAUGHT IT.** A 364-day year with +swooping from ~1 August is real and completely unreachable (day 1 is January, a game day is 240 s). So I +seeded a per-town year offset — `rng(citySeed, 'swoopseason', 0)`. **`plan.citySeed` is the `?seed=` +value and the town key is deliberately NOT in it** (plan_osm.js:515 keeps it off the plan so the +Melbourne golden stays frozen), so all 23 towns share one seed, one offset, one answer: measured across +the corpus, **24 of 24 towns were 214 days from swooping season.** The mechanism produced a constant, and +it would have gated green because `?magpie=1` forces it. Re-keyed on **(citySeed, townKey)**: + +- **`newtown_real` is in swooping season on arrival on the default seed** — a real, unforced sighting. +- `marrickville_real` 8 days out · `redhill_real` 12 · `bowral_real` 20 · `fitzroy_real` 50. +- **129 of 1,000 `?seed=` values (12.9%)** put the synthetic default in season on day 1 — against a + design ratio of 42/364 = 11.5%. The distribution is the intended one, not an accident of one seed. + +**The latch, and F's gate arm, demonstrated:** + +| | value | +|---|---| +| after 3 swoops at 12 m | `gotYouToday: **true**`, `swoopsToday: 3`, `launches: 3` | +| after `game.sleep()` | `gotYouToday: **false**`, `swoopsToday: **0**`, day 8→9, `dayRolls: 4` | +| **new localStorage keys** | **`[]`** | +| save file mentions magpie/swoop/washing/yard | **false** (keys: cash, collection, day, savedAt, schema, town, wants) | + +The day roll also **puts him back on his pole** — and that is not decoration. The first run of this +measurement read `swoopsToday 1 → 0` but `gotYouToday` still **true**: the clear had worked and was then +re-set half a second later by the leg already in the air. A gate would have called a working clear a +failure. He now perches, and a swoop cannot be in progress across a night. + +**A bird perched on thin air.** The first cut put the anchor at an arbitrary distance along the edge and +he hovered over the road. Territories now snap to `furniture.js`'s own streetlight rule (`s = 8, 26, 44…`, +alternating sides, `width/2 + 0.7`) so he sits on a pole that exists. Perched, the instance is squashed +to 0.42 in X — the wings are baked into the one shared geometry, so folding them is the only pose there +is, and a magpie sitting with its wings spread is not a magpie sitting. + +### §1.1 — the audio inhabitation layer: **SIX new mixer layers, zero draws, zero tris — and it PANS** +Not a generalisation of `audio.js:300-311`: those are hardcoded bed layers, one per source class, each +with a bespoke rule. This is a generic point-source system — a table of six, each with its own mixer +layer, its own **seeded anchor set derived from the plan**, its own falloff, hours and day-of-week rule. +Nothing in it touches the scene graph; the zero is structural, not careful. + +**There is panning now, and there wasn't.** `playSfx` is mono with a gain multiplier, so distance worked +and direction did not — and a dog equally loud in both ears is a dog nowhere. Rather than write that down +as a limitation, each layer gets a **`StereoPannerNode`** driven by the bearing-to-anchor dotted with the +camera's world right vector (`matrixWorld` column 0). Fail-soft to mono where `createStereoPanner` is +absent, with `state.ambient.panning:false` saying so. Measured live: pan spread **−0.71 … +0.85**. +Day-of-week is derived **locally**: `dowOf(day) = (day-1) % 7` off `PROCITY.game.day` — not read from the +gig week, not imported, not persisted. + +Standing on a residential street at midday, day 5 (Friday), after a real user gesture unlocked the +context — **9 buffers loaded**, and Lane E's six sounds landed mid-round in exactly the banks the table +expects: + +| source | anchors | status | measured | +|---|---|---|---| +| `dog-bark-fence` | 63 | **audible** | 15 m, gain 0.169, **1 play** | +| `magpie-carol` | 57 | **audible** | 1.1 m, gain 0.221, **pan 0.85**, 1 play | +| `radio-through-window` | 81 | **audible** | 15 m, gain 0.01 (edge of a 17 m radius) | +| `sprinkler` | 32 | idle | in range, **out of hours** (6–10) | +| `mower-distant` | 8 | idle | **weekend only** — day 5 is a Friday | +| `roller-door` | 39 | idle | 222 m, radius 42 | + +**The falsifiability control, run on a third port with a patched manifest** (`sfx["dog-bark-fence"]` +renamed to `dog-bark-fence-MISSING`, everything else byte-identical): that one source reports +**`no-entry`, plays 0, and never reaches `audible`**, 8 buffers instead of 9, the other five unchanged, +**zero console errors**. A missing key is silence by house law, so `state.ambient.sources[key].status` is +the only thing that can tell a missing key from a quiet street — which is why it exists. + +**Scope, stated rather than faked:** anchors come from the plan, and all 21 real towns are 100% +`use:'shop'` in one district, so on a real town **three of six report `no-anchor`** (dog, mower, +sprinkler) instead of being pretended onto shopfronts. `radio-through-window` (flats above shops, +`storeys ≥ 2`), `roller-door` and `magpie-carol` (verge trees off the road graph) work everywhere: +measured on `darwin_real` 0/0/2/0/2/379 and `bendigo_real` 0/0/2/0/7/402. **6 of 6 on the default boot, +3 of 6 on real towns, and 6 of 6 everywhere the moment A's civic fetch lands districts.** + +*One honest gap: audible OUTPUT cannot be confirmed headlessly on a first pass — the autoplay policy +needs a trusted gesture, and the automation browser's synthetic click does not always count. A real +click through the browser tool did unlock it (`ready: true` before I forced anything), and everything +downstream of the unlock — manifest resolve, fetch, decode, layer gain, pan, one-shot scheduling, play +counters — is measured above. Nobody in this loop has ears.* + +### Lane E's R38 findings, taken and recorded +1. **`hills_hoist` and `tv_aerial` failed decisively** (thin wire → thousands of fragments; a voxel + remesh coarse enough for 600 tris erases the pole). Already built as `boxMats` primitives; they are + now **permanently primitive**, which is also the +0-draw mechanism. +2. **`magpie` (894 tris) NOT ADOPTED this round, and the reason is the white.** E's caveat is that it + reads as a **crow** — traced to the operator's PBR bake, our decimation exonerated. Against it: my + procedural bird is **182 tris (4.9× lighter), carries the white nape/rump/wing-bars as vertex + colours, and fetches nothing**. A magpie seen for 1.2 s in peripheral vision **is** its white bar; + a uniformly dark bird is a currawong and the joke does not land. **§1.3 does not hold for the tint — + the GLB does.** The swap point is `birdGeometry()` / `birdMaterial()`, two singletons, one call site + each. Deliberately **no speculative `loadGLB`**: probing for a file nobody has shipped is the blind + probe R26 spent a round killing. +3. **`paling_fence` (1,121 tris) and `water_tank` (500 tris): no, on arithmetic.** This town wants + **905 fence runs** (5 × 181 lots) ⇒ **1.01 M triangles** against ~75 k spare, and neither can ride + `boxMats`, so both would also pay draws. 24 tris of boxes is the answer, not a stopgap. +4. **`furniture.js:30`'s "novelty_record is 26.5k tris" was stale by six epochs** — 8,000 since R5. + Comment corrected; the prop is still not in `GLB_FILES`, so the primitive draws either way, but the + reason on file was wrong. Whether to wire it is a round that measures it. + +### Honest notes / handed on +1. **Zero fetch delta.** `?noassets=1` fetches **the same 23 assets, same list**, on HEAD and on R38. + All four items are procedural: they add no network at all. (The 23 skin JPEGs under `?noassets` are + pre-existing `skins.js` behaviour, not mine — the asset law as implemented gates the manifest, the + rig fleet, interior GLBs and audio, not the skin textures. Recorded, not touched.) +2. **`?classic=1` constructs none of it**: `washing` null, `magpie` null, `ambient` OFF, `yards` false, + no meshes in the scene, ground hashes unmoved. +3. **§1.4 is synthetic-only and says so.** Real towns have 0 house/yard lots, so 0 hoists, 0 washing, + 0 yard props — and the washing layer's mesh is never created there, which is where its "free on real + towns" claim comes from. +4. The four `THREE.PointerLockControls` console errors are the automation browser refusing Pointer Lock, + same as R30/R37. **0** other console errors on every boot tested (default · `?classic=1` · `?yards=0` · + `?washing=0&magpie=0&ambient=0` · `?noassets=1` · `?magpie=1` · darwin · hobart · bendigo · the + patched-manifest control). +5. Shots: `docs/shots/laneB_r38/` — the hoist with washing on it over a paling fence, a residential + street with aerials and front fences, an overhead row, the magpie perched on a streetlight and + mid-swoop, and Darwin / Hobart / Bendigo showing three different grounds and three different skies. + +--- + ## Round 37 (v8 WAVE 0 §0.1 + §0.4) — **THE KERB IS FOOTPATH: shipped at +0 draws, and tris went DOWN** **John's ruling is in the geometry. The road quad narrows from the whole 28 m corridor to the diff --git a/docs/shots/laneB_r38/01_backyard_hoist_washing.jpg b/docs/shots/laneB_r38/01_backyard_hoist_washing.jpg new file mode 100644 index 0000000..1eaca78 Binary files /dev/null and b/docs/shots/laneB_r38/01_backyard_hoist_washing.jpg differ diff --git a/docs/shots/laneB_r38/03_residential_row_over.jpg b/docs/shots/laneB_r38/03_residential_row_over.jpg new file mode 100644 index 0000000..eca087c Binary files /dev/null and b/docs/shots/laneB_r38/03_residential_row_over.jpg differ diff --git a/docs/shots/laneB_r38/04_residential_street_front.jpg b/docs/shots/laneB_r38/04_residential_street_front.jpg new file mode 100644 index 0000000..4f25e4e Binary files /dev/null and b/docs/shots/laneB_r38/04_residential_street_front.jpg differ diff --git a/docs/shots/laneB_r38/05_magpie_perched.jpg b/docs/shots/laneB_r38/05_magpie_perched.jpg new file mode 100644 index 0000000..a236b76 Binary files /dev/null and b/docs/shots/laneB_r38/05_magpie_perched.jpg differ diff --git a/docs/shots/laneB_r38/06_magpie_swooping.jpg b/docs/shots/laneB_r38/06_magpie_swooping.jpg new file mode 100644 index 0000000..05a73a0 Binary files /dev/null and b/docs/shots/laneB_r38/06_magpie_swooping.jpg differ diff --git a/docs/shots/laneB_r38/07_character_darwin_reddust.jpg b/docs/shots/laneB_r38/07_character_darwin_reddust.jpg new file mode 100644 index 0000000..4d2e3ab Binary files /dev/null and b/docs/shots/laneB_r38/07_character_darwin_reddust.jpg differ diff --git a/docs/shots/laneB_r38/08_character_hobart_temperate.jpg b/docs/shots/laneB_r38/08_character_hobart_temperate.jpg new file mode 100644 index 0000000..734c649 Binary files /dev/null and b/docs/shots/laneB_r38/08_character_hobart_temperate.jpg differ diff --git a/docs/shots/laneB_r38/09_character_bendigo_grassdry.jpg b/docs/shots/laneB_r38/09_character_bendigo_grassdry.jpg new file mode 100644 index 0000000..6a283d6 Binary files /dev/null and b/docs/shots/laneB_r38/09_character_bendigo_grassdry.jpg differ diff --git a/web/index.html b/web/index.html index 4bb56a6..06a9d83 100644 --- a/web/index.html +++ b/web/index.html @@ -48,6 +48,7 @@ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; import { fixturePlan } from './js/world/fixture_plan.js'; import { createSkinLibrary } from './js/world/skins.js'; import { buildGround } from './js/world/ground.js'; +import { townCharacter, describeCharacter } from './js/world/character.js'; // [Lane B R38 §1.2] per-town palette/sky/ambience import { createLighting } from './js/world/lighting.js'; import { createChunkManager } from './js/world/chunks.js'; import { createPlayer } from './js/world/player.js'; @@ -58,6 +59,8 @@ import { createWallet } from './js/interiors/wallet.js'; // [Lane F R8 import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians import { createWeather } from './js/world/weather.js'; // [Lane F R8] Lane B seeded weather (?weather=1) import { createTram } from './js/world/tram.js'; // [Lane F R9] Lane B tram (?tram=1, non-blocking) +import { createWashing } from './js/world/washing.js'; // [Lane B R38 §1.4] town-wide washing (1 draw, cap 256) +import { createMagpie } from './js/world/magpie.js'; // [Lane B R38 §1.3] the magpie (+1 draw while on screen) import { createGigState } from './js/world/gig_state.js'; // [Lane F R12] the Friday-night state machine (?gigs=1) import { createVenuePresentation } from './js/world/venue.js'; // [Lane F R12] Lane B venue frontage + posters (?gigs=1) import { loadPedFleet } from './js/citizens/rigs.js'; // [Lane F §3.4] Lane D rig fleet (GLB peds/keepers) @@ -168,11 +171,23 @@ composer.addPass(new OutputPass()); // ── world systems ── const skins = createSkinLibrary(); -const ground = buildGround(plan, skins); +// [Lane B R38 §1.2 — THE PER-TOWN CHARACTER VECTOR] Which town this is never reaches the plan on +// purpose (plan_osm.js:515 keeps the town key OFF the plan so the Melbourne golden stays frozen), so +// the shell — which is the only thing that knows — resolves it here and hands it to the ground and +// the sky. A synthetic boot has NO town key ⇒ townCharacter(null) ⇒ the frozen v1 literals ⇒ the +// default boot and ?classic=1 are byte-identical BY CONSTRUCTION (no flag test in ground.js at all). +// `?plansrc=osm` with no &town= boots plan_osm's DEFAULT_TOWN ('melbourne'), so name it rather than +// letting the character silently fall through on a boot that does have a town. +const CHAR_TOWN = PLANSRC === 'osm' ? (TOWN || 'melbourne') : null; +const character = townCharacter(CHAR_TOWN); +if (CHAR_TOWN) console.log(`[procity] ${describeCharacter(character)}` + + (character.matched ? '' : ' ⚠ NO ENTRY for this town key — palette unchanged (character.js)')); +const ground = buildGround(plan, skins, { townKey: CHAR_TOWN }); scene.add(ground.group); const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS, - dawnSky: CLASSIC ? null : 'high-cirrus' }); // [R32 §31.6-3] dawn reads dawn; classic keeps the frozen dome + dawnSky: CLASSIC ? null : 'high-cirrus', // [R32 §31.6-3] dawn reads dawn; classic keeps the frozen dome + sky: CLASSIC ? null : character.sky }); // [R38 §1.2] the town's base dome — NOT setSky (a one-way latch) lighting.attachRenderer(renderer); const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS }; @@ -390,6 +405,22 @@ const weatherState = weather ? weather.state : { state: 'clear', intensity: 0 }; // [Lane F R9 — Lane B tram] ?tram=1: one seeded tram looping the main-street spine, door-dwell at the // bus shelters. Self-contained (2 draws); flag-off never constructs it (byte-identical). Non-blocking for v2.0. const tram = flagOn('tram') ? createTram({ scene, plan, camera, lighting }) : null; // [R16 flip] default-on; ?tram=0 / ?classic=1 → off +// [Lane B R38 §1.4 — BACKYARD AUSTRALIA, the washing] The fence/hoist/aerial/tank ride buildings.js's +// existing boxMats InstancedMesh at +0 draws; only the cloth needs a mesh of its own. It is ONE +// TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, spent proximity-first off the R37 chunk seam) — +// deliberately NOT per-chunk, which would have been +1 × up to 31 live chunks against 8 of margin. +// Costs exactly 0 on every real town (no house/yard lots ⇒ no hoists ⇒ no mesh) and at night (count 0). +const washing = (flagOn('washing') && flagOn('yards')) + ? createWashing({ scene, plan, citySeed: plan.citySeed, chunks, camera, lighting }) : null; +// [Lane B R38 §1.3 — THE MAGPIE] One bird, one street, six weeks of the town's year. ONE town-wide +// InstancedMesh with count 0 or 1 ⇒ +1 draw while he is on screen and exactly 0 otherwise. Territory +// count scales with the road graph (one per 1.2 km of street), and only the nearest is instantiated, +// refreshed off ctx.onChunkBuilt — so bendigo_real's 188 km does not become a town with a magpie you +// never meet. `?magpie=1` forces the season open (the season is seeded per town); ?magpie=0 / +// ?classic=1 never construct it. The latch he sets is runtime-only: no localStorage, ever. +const magpie = flagOn('magpie') + ? createMagpie({ scene, plan, citySeed: plan.citySeed, townKey: CHAR_TOWN, camera, chunks, lighting, + force: params.get('magpie') === '1' }) : null; // [Lane F R12 — Lane B venue.js] the street side of the gig: E's poster skins at A's plan.posters (band name // overprinted like a shop sign) + the lit pub frontage, whose marquee/sign glow F ramps from the gig state. // Self-contained module; never constructed flags-off (byte-identical), like weather/tram. @@ -579,6 +610,8 @@ function frame() { // OutputPass tone-map it globally via renderer.toneMappingExposure — the passthrough is now a no-op. if (weather) weather.update(dt); // [Lane F R8] Lane B rain particles + wet ground if (tram) tram.update(dt); // [Lane F R9] Lane B tram loop + if (washing) washing.update(dt); // [Lane B R38 §1.4] event-driven refill; early-outs most frames + if (magpie) magpie.update(dt); // [Lane B R38 §1.3] the swoop (pure fn of the launch record) citizens.setWeather(weatherState); // [Lane F R8] Lane D rain reaction (thin + shelter) citizens.update(dt); // [Lane F] reads camera pos internally for LOD tiers hud.tickToast(dt); @@ -601,6 +634,8 @@ frame(); // expose for debugging / Lane F window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins, interiorMode, citizens, enterShop, leaveShop, isOpen, currentHour, fleet, noassets: NOASSETS, + townKey: CHAR_TOWN, // [Lane B R38 §1.2] the ?town= key (the plan deliberately doesn't carry it) + character, // [Lane B R38 §1.2] ground palette + sky bias + §1.1 ambience mix; frozen literals when null weather: weatherState, // [Lane F R8] Lane B contract: {state,intensity}, {clear,0} when off (Lane D reads this) wallet, // [Lane F R8] Lane C buy loop v0 (cash/buy/sell/count) — game-backed facade when the game is on game, // [Lane F R30] THE GAME (LANE_F_NOTES §30 contract) — NULL under ?classic=1 / ?game=0 @@ -608,13 +643,19 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin // decide there's no gig layer at all). `.state` is a live getter: quiet|doors|on|done venuePresentation, // [Lane F R12] Lane B frontage + posters (null flags-off) venueQueues, queueCountOf, // [Lane F R13] Lane D outdoor queue per venue + count accessor (F's smoke reads it) + washing, // [Lane B R38 §1.4] the town-wide washing layer (null flags-off / classic) — `.state` + magpie, // [Lane B R38 §1.3] the magpie (null flags-off / classic) — `.state`, `.swoopPos` (pure) tram, // [Lane F R21] Lane B's tram — `.routeInfo` carries the R20 shop-adjacency verdict // ({fenced, reason, shopsFronted}) + `.stops`; B built it FOR F's smoke. Null flags-off. // [Lane F R16 — THE FLIP] the flag-intent surface: what SHOULD be on this boot (the gates verify intent vs // reality). Default boot = all four flipped flags true; ?classic=1 = all false + classic true; each `=0` opts out. flags: { classic: CLASSIC, gigs: GIGS_ON, weather: flagOn('weather'), winmap: flagOn('winmap'), tram: flagOn('tram'), game: GAME_ON, // [R30] the v7 game layer (default-on; ?game=0 / ?classic=1 → off) - dig: DIG_ON }, // [R31] the dig flip (default-on; ?dig=0 / ?classic=1 → off) — gate reads intent here + dig: DIG_ON, // [R31] the dig flip (default-on; ?dig=0 / ?classic=1 → off) — gate reads intent here + // [R38 wave 1] yards is read by buildings.js (?yards=0 reverts the pre-R38 house/yard geometry); + // washing/magpie are shell-constructed; ambient is the §1.1 audio layer (read by audio.js). + yards: flagOn('yards'), washing: flagOn('washing') && flagOn('yards'), + magpie: flagOn('magpie'), ambient: flagOn('ambient'), verge: ground.verge }, THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks // [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with // zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so diff --git a/web/js/world/audio.js b/web/js/world/audio.js index ffcadc4..d8d60a4 100644 --- a/web/js/world/audio.js +++ b/web/js/world/audio.js @@ -15,6 +15,8 @@ // to createAudioEngine(PROCITY) once. Exposes window.PROCITY.audio for Lane F's smokes. import { gigKeyFor } from '../citygen/gigs.js'; // debt #1: the ONE genre→bed mapping (gig-) +import { rng, frange } from '../core/prng.js'; // [R38 §1.1] seeded anchors — the same dog, same fence +import { AMBIENCE_SOURCES } from './character.js'; // [R38 §1.1/§1.2] the six keys + the per-town mix const BASE = 'assets/'; @@ -113,6 +115,241 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } = L.gigSpill = layer(gigLowpass); state.layers = L; + // ══ R38 §1.1 — THE AUDIO INHABITATION LAYER: SIX NEW MIXER LAYERS ═══════════════════════════════ + // + // NOT a generalisation of the bed layers above. Those (`day` / `night` / `rain` / `spill` / + // `gigSpill` / `tram`) are hardcoded, one per source CLASS, each with its own bespoke rule for + // which key plays and how loud. This is a generic POINT-SOURCE system: a table of six entries, + // each with its own mixer layer, its own **seeded anchor set derived from the plan**, its own + // distance falloff, its own hours and its own day-of-week rule. A dog behind a paling fence, a + // mower two streets over, a radio through a window, a sprinkler, a roller door, a magpie carolling + // — the sound of a life happening off-screen, at the SAME anchor every time you walk that street. + // + // **COST: ZERO DRAWS, ZERO TRIANGLES.** Nothing in this block touches the scene graph, allocates + // geometry, or is reachable from the renderer. It is WebAudio nodes and a distance test. This is + // the one item in the epoch that cannot move the budget under any circumstance, and the reason is + // structural rather than careful. + // + // ── DIRECTION: THERE IS PANNING NOW, AND THERE WASN'T ─────────────────────────────────────────── + // `playSfx` is mono with a gain multiplier — distance worked, direction did not, and a dog that is + // equally loud in both ears is a dog nowhere. Rather than write that down as a limitation, each of + // the six layers gets a **`StereoPannerNode`** driven by the dot product of the bearing-to-anchor + // with the camera's world right vector (matrixWorld's first column). Six extra nodes, no measurable + // cost, and a source that is genuinely *behind that fence over there*. Fail-soft: a browser without + // `createStereoPanner` (old Safari) connects the layer straight to the bus and is mono, exactly as + // before, with `state.ambient.panning:false` saying so. + // + // ── DAY OF WEEK IS DERIVED LOCALLY ───────────────────────────────────────────────────────────── + // `dowOf(day) = (day - 1) % 7`, 0 = Monday, computed HERE from `PROCITY.game.day`. It is not read + // from the gig week, not imported, and not persisted: no save key, no new contract, and no coupling + // to a layer that is null on half the boots. A mower on a Sunday morning and a roller door on a + // Tuesday are the whole point of knowing. + // + // ── FALSIFIABILITY (F's gate shape for the whole round) ──────────────────────────────────────── + // Every entry publishes its own status at `PROCITY.audio.state.ambient.sources[key]`: + // `no-anchor` (this town has nowhere for it) · `no-entry` (**the manifest has no such key**) · + // `idle` (anchored, out of range/hours) · `audible` (playing, with dist/gain/pan). + // Point one table entry at a key that does not exist and it reports `no-entry` and NEVER reaches + // `audible` — which is the leg F's control asserts must FAIL. A missing key is silence, never an + // error (house audio law 1), so the status field is the only thing that can tell the difference. + // + // ── SCOPE, HONESTLY ──────────────────────────────────────────────────────────────────────────── + // Anchors come from the plan. All 21 real towns are 100% `use:'shop'` in one district (charter + // ruling 2), so on a real town three of the six (dog / mower / sprinkler) have **no anchors and + // report `no-anchor`** rather than being faked onto shopfronts. `radio-through-window` (flats above + // shops — `storeys ≥ 2`), `roller-door` (warehouse/dept/general lots) and `magpie-carol` (verge + // trees off the road graph) work everywhere. That is 3 of 6 on real towns and 6 of 6 on the + // default boot, and it becomes 6 of 6 everywhere the moment A's civic fetch lands districts. + const CLASSIC = (() => { try { return params.has('classic') && params.get('classic') !== '0'; } catch { return false; } })(); + const AMBIENT_ON = !CLASSIC && params.get('ambient') !== '0'; + + // ── the anchor derivations: pure functions of the plan, seeded, never re-rolled ── + const lotsWhere = (plan, fn) => (plan?.lots || []).filter(fn); + const districtKind = (plan) => { + const byBlock = new Map((plan?.blocks || []).map((b) => [b.id, b.district])); + const byDistrict = new Map((plan?.districts || []).map((d) => [d.id, d.kind])); + return (lot) => byDistrict.get(byBlock.get(lot.block)) || null; + }; + // Take a seeded subset so it is SOME houses, not every house — and the same ones every session. + function subset(list, citySeed, tag, frac) { + const out = []; + for (let i = 0; i < list.length; i++) { + if (rng(citySeed >>> 0, tag, i)() < frac) out.push(list[i]); + } + return out; + } + const homeLots = (plan) => lotsWhere(plan, (l) => l.use === 'house' || l.use === 'yard'); + const anchorsOf = { + 'dog-bark-fence': (plan, seed) => { + const kind = districtKind(plan); + const pool = homeLots(plan).concat(lotsWhere(plan, (l) => kind(l) === 'backstreets' && l.use === 'infill')); + return subset(pool, seed, 'amb:dog', 0.34).map((l) => ({ x: l.x, z: l.z })); + }, + 'mower-distant': (plan, seed) => subset(homeLots(plan), seed, 'amb:mower', 0.08).map((l) => ({ x: l.x, z: l.z })), + 'radio-through-window': (plan, seed) => { + const pool = homeLots(plan).concat( + (plan?.shops || []).map((s) => ({ s, l: (plan.lots || []).find((x) => x.id === s.lot) })) + .filter((e) => e.l && (e.s.storeys || 1) >= 2).map((e) => e.l)); + return subset(pool, seed, 'amb:radio', 0.2).map((l) => ({ x: l.x, z: l.z })); + }, + sprinkler: (plan, seed) => subset(homeLots(plan), seed, 'amb:sprink', 0.16).map((l) => ({ x: l.x, z: l.z })), + 'roller-door': (plan, seed) => { + const kind = districtKind(plan); + const byLot = new Map((plan?.shops || []).map((s) => [s.lot, s])); + const pool = lotsWhere(plan, (l) => { + const s = byLot.get(l.id); + return kind(l) === 'warehouse' || (s && (s.type === 'dept' || s.type === 'general' || s.type === 'pawn')); + }); + return subset(pool, seed, 'amb:roller', 0.5).map((l) => ({ x: l.x, z: l.z })); + }, + // The verge trees furniture.js plants: side/lane edges, every ~22 m, both verges. Derived from + // the plan rather than from the live furniture instances, so it works before a chunk is built. + // `i` is a RUNNING counter, deliberately not `out.length`: keying the seeded draw off the output + // length means the same number is drawn until something passes, so a first roll above the + // threshold produces zero anchors FOREVER — which is exactly what shipped in this file's first + // cut and exactly what `state.ambient.sources['magpie-carol'].status === 'no-anchor'` caught on + // the first boot. Left in the comment because the bug is invisible without the status field. + 'magpie-carol': (plan, seed) => { + const out = []; + const nodeById = new Map((plan?.streets?.nodes || []).map((n) => [n.id, n])); + let i = 0; + for (const e of (plan?.streets?.edges || [])) { + if (e.kind !== 'side' && e.kind !== 'lane') continue; + const a = nodeById.get(e.a), b = nodeById.get(e.b); + if (!a || !b) continue; + const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1; + for (let s = 6; s < len - 4; s += 22) { + if (rng(seed >>> 0, 'amb:carol', i++)() > 0.22) continue; + out.push({ x: a.x + (dx / len) * s, z: a.z + (dz / len) * s }); + } + if (out.length > 400) break; + } + return out; + }, + }; + + // hours are [from, to) in the 6-segment clock's representative hours; days: 0=Mon … 6=Sun + const WEEKDAYS = [0, 1, 2, 3, 4], WEEKEND = [5, 6], ANY_DAY = [0, 1, 2, 3, 4, 5, 6]; + const POINT_SOURCES = [ + { key: 'dog-bark-fence', bank: 'sfx', loop: false, radius: 34, gain: 0.55, hours: [6, 23], + days: ANY_DAY, gap: [7, 26], rainQuiet: 0.5 }, + { key: 'mower-distant', bank: 'ambience', loop: true, radius: 95, gain: 0.30, hours: [8, 18], + days: WEEKEND, gap: null, rainQuiet: 0 }, + { key: 'radio-through-window', bank: 'ambience', loop: true, radius: 17, gain: 0.34, hours: [7, 23], + days: ANY_DAY, gap: null, rainQuiet: 1 }, + { key: 'sprinkler', bank: 'ambience', loop: true, radius: 15, gain: 0.32, hours: [6, 10], + days: ANY_DAY, gap: null, rainQuiet: 0 }, + { key: 'roller-door', bank: 'sfx', loop: false, radius: 42, gain: 0.6, hours: [7, 18], + days: WEEKDAYS, gap: [40, 150], rainQuiet: 1 }, + { key: 'magpie-carol', bank: 'sfx', loop: false, radius: 62, gain: 0.5, hours: [6, 20], + days: ANY_DAY, gap: [12, 48], rainQuiet: 0.4 }, + ]; + // The table and character.js's AMBIENCE_SOURCES must agree or one of them is decoration. Assert it + // here (dev-cheap, once) rather than discovering the drift in a gate three rounds later. + if (POINT_SOURCES.map((s) => s.key).join(',') !== AMBIENCE_SOURCES.join(',')) { + console.warn('[procity] §1.1 source table disagrees with character.js AMBIENCE_SOURCES'); + } + + const canPan = typeof ctx.createStereoPanner === 'function'; + const points = []; + if (AMBIENT_ON) { + const plan = PROCITY.plan, citySeed = (plan?.citySeed ?? 0) >>> 0; + for (const def of POINT_SOURCES) { + let anchors = []; + try { anchors = anchorsOf[def.key](plan, citySeed) || []; } catch { anchors = []; } + const g = ctx.createGain(); g.gain.value = 0; + let pan = null; + if (canPan) { pan = ctx.createStereoPanner(); g.connect(pan); pan.connect(buses.ambience); } + else g.connect(buses.ambience); + points.push({ def, anchors, g, pan, src: null, key: null, starting: false, + r: rng(citySeed, 'amb:gap:' + def.key, 0), next: frange(rng(citySeed, 'amb:t0:' + def.key, 0), 1, 12), + status: anchors.length ? 'idle' : 'no-anchor', plays: 0, nearest: -1, dist: Infinity, gainNow: 0, panNow: 0 }); + } + state.ambient = { + panning: canPan, sources: {}, + get summary() { return points.map((p) => `${p.def.key}:${p.status}`).join(' '); }, + }; + for (const p of points) { + state.ambient.sources[p.def.key] = { + get anchors() { return p.anchors.length; }, get status() { return p.status; }, + get dist() { return p.dist === Infinity ? null : +p.dist.toFixed(1); }, + get gain() { return +p.gainNow.toFixed(3); }, get pan() { return +p.panNow.toFixed(2); }, + get plays() { return p.plays; }, get nearest() { return p.nearest; }, + }; + } + } else { + state.ambient = { panning: false, off: true, sources: {} }; + } + + const dowOf = (day) => ((Math.max(1, day | 0) - 1) % 7); // derived LOCALLY (see the header) + let ambScan = 0; + function updatePoints(dt, hour, night, street) { + if (!points.length) return; + const cam = PROCITY.camera; + const day = (PROCITY.game && PROCITY.game.day) || 1; + const dow = dowOf(day); + const mix = (PROCITY.character && PROCITY.character.mix) || null; + const wet = (PROCITY.weather && PROCITY.weather.state === 'rain') ? (PROCITY.weather.intensity || 0) : 0; + // camera world right vector = matrixWorld's first column (for the pan bearing) + const me = cam && cam.matrixWorld ? cam.matrixWorld.elements : null; + const rx = me ? me[0] : 1, rz = me ? me[2] : 0; + ambScan -= dt; + const rescan = ambScan <= 0; + if (rescan) ambScan = 0.33; + for (const p of points) { + const d = p.def; + if (!p.anchors.length) { p.status = 'no-anchor'; continue; } + const entry = AUDIO && AUDIO[d.bank] ? AUDIO[d.bank][d.key] : null; + if (AUDIO && !entry) { p.status = 'no-entry'; ramp(p.g.gain, 0, 0.4); p.gainNow = 0; continue; } + if (rescan) { // nearest anchor — seeded set, so it is the same one on the same street + let bi = -1, bd = Infinity; + for (let i = 0; i < p.anchors.length; i++) { + const a = p.anchors[i]; + const dd = (a.x - _cam.x) ** 2 + (a.z - _cam.z) ** 2; + if (dd < bd) { bd = dd; bi = i; } + } + p.nearest = bi; p.dist = Math.sqrt(bd); + } + const inHours = hour >= d.hours[0] && hour < d.hours[1]; + const inDays = d.days.indexOf(dow) >= 0; + const near = p.dist < d.radius; + const rainMul = wet > 0 ? (d.rainQuiet + (1 - d.rainQuiet) * (1 - Math.min(1, wet))) : 1; + const townMul = mix ? (mix[d.key] ?? 1) : 1; + const audible = street && !night && near && inHours && inDays && townMul > 0; + const target = audible + ? (entry?.gain ?? d.gain) * d.gain * townMul * rainMul * ((d.radius - p.dist) / d.radius) + : 0; + p.gainNow = target; + if (p.pan) { + const a = p.anchors[p.nearest]; + const dx = a.x - _cam.x, dz = a.z - _cam.z, len = Math.hypot(dx, dz) || 1; + p.panNow = Math.max(-0.85, Math.min(0.85, ((dx * rx + dz * rz) / len) * 0.85)); + p.pan.pan.setTargetAtTime(p.panNow, ctx.currentTime, 0.12); + } + if (d.loop) { + persistBed(p, 'amb:' + d.key, entry, target, 1.1); + p.status = audible && p.src ? 'audible' : (audible ? 'loading' : 'idle'); + } else { + ramp(p.g.gain, audible ? 1 : 0, 0.25); + p.next -= dt; + if (audible && p.next <= 0) { + p.next = frange(p.r, d.gap[0], d.gap[1]); + firePoint(p, d, entry, target); + } + p.status = audible ? (p.plays ? 'audible' : 'loading') : 'idle'; + } + } + } + async function firePoint(p, d, entry, gain) { + const buf = await load('amb:' + d.key, entry); + if (!buf) { p.status = 'no-entry'; return; } + const src = ctx.createBufferSource(); src.buffer = buf; + const vg = ctx.createGain(); vg.gain.value = Math.max(0, gain); + src.connect(vg); vg.connect(p.g); src.start(); + src.onended = () => { try { src.disconnect(); vg.disconnect(); } catch {} }; + p.plays++; + } + async function persistBed(l, key, entry, target, fade = 1.4) { if (key && !l.src && !l.starting) { l.starting = true; @@ -237,6 +474,15 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } = persistBed(L.day, 'street-day', amb?.['street-day'], street && !night ? (amb?.['street-day']?.gain ?? 0.5) : 0); persistBed(L.night, 'street-night', amb?.['street-night'], street && night ? (amb?.['street-night']?.gain ?? 0.5) : 0); + // ── [R38 §1.1] the six point-source inhabitation layers (zero draws, zero tris) ── + // Runs before the beds so its own scan throttle shares the same frame budget; absent entirely + // under ?classic=1 / ?ambient=0 (points is empty ⇒ one length test and out). + if (points.length) { + const hr = (() => { try { return PROCITY.currentHour ? PROCITY.currentHour() : 12; } + catch { const [h, m] = String(clk?.hour || '12:00').split(':').map(Number); return h + (m || 0) / 60; } })(); + updatePoints(dt, hr, night, street); + } + // ── rain layer (gain follows weather intensity; only when it's actually raining) ── const w = P.weather || { state: 'clear', intensity: 0 }; const rainT = (street && w.state === 'rain') ? (amb?.rain?.gain ?? 0.45) * Math.max(0.15, w.intensity) : 0; diff --git a/web/js/world/buildings.js b/web/js/world/buildings.js index f5becf5..f254a9a 100644 --- a/web/js/world/buildings.js +++ b/web/js/world/buildings.js @@ -231,6 +231,127 @@ if (typeof window !== 'undefined') { window.addEventListener('procity:segment', (e) => { if (e.detail) setFacadeHours(e.detail.hour, !!e.detail.night); }); } +// ── R38 §1.4 — BACKYARD AUSTRALIA ──────────────────────────────────────────────────────────────── +// The default boot ships **181 house and yard lots with nothing in them** (measured: 145 `house`, +// 36 `yard` on seed 20261990). Worse, a `yard` lot has fallen through to `buildShopfront()` since +// v1 — 36 lots A explicitly labels "yard" render as blank 3.2 m sheds with a door and two windows. +// This puts a Hills Hoist, a paling fence, a TV aerial and a rainwater tank in them. +// +// **IT COSTS ZERO DRAWS, and that is the whole reason it lives in this file.** Every prop below is +// pushed into the EXISTING `boxMats` list — the same per-chunk InstancedMesh that already carries +// shells, parapets and verandah posts — so a yard adds instances to a draw call that was already +// being submitted. A `world/yards.js` could not reach `boxMats` and would have paid +1 draw per live +// chunk (×31 at the dispose window) against a margin of 8. Triangles are the resource being spent +// here (12 per box), and triangles are the cheap one (~75k spare on the pinned frame). +// +// Everything is a primitive by construction, so `?noassets` is unaffected and there is nothing to +// upgrade-if-ready: a per-instance texture is impossible in a shared-material InstancedMesh, which +// is exactly the mechanism that makes it free. What E's asset wave upgrades is the ground under it +// (§1.2) and the sound in it (§1.1), not these boxes. +// +// SYNTHETIC-ONLY THIS ROUND, and stated rather than faked: all 21 real towns are 100% `use:'shop'` +// in one `mainstreet` district (charter ruling 2 — the civic fetch is a later round), so a real town +// has zero house/yard lots and zero of this fires there. It ships on the default boot, which is +// still the boot every player gets, and extends to real towns with no further code the moment A's +// third fetch class lands districts. +// +// `?yards=0` reverts to the pre-R38 geometry permanently (the `?verge=0` pattern — a falsifiability +// control that costs nothing and stays forever); `?classic=1` forces it off (the museum keeps v2). +const YARDS = (() => { + try { + const p = new URLSearchParams(location.search); + const classic = p.has('classic') && p.get('classic') !== '0'; + return !classic && p.get('yards') !== '0'; + } catch (e) { return false; } // unparseable search ⇒ pre-R38 geometry, never a throw +})(); + +// Measured against the picture, not the spec sheet: a paling fence and a Hills Hoist are within +// 30 cm of each other in real life, and the first cut had the fence TALLER (1.75) than the washing +// line's drop, which hid an entire street of back yards behind their own fences from every angle a +// player can stand at. An older AU paling fence is 1.5–1.8 m and a rotary hoist is adjustable to +// 2.0–2.4 m, so: fence 1.60, hoist 2.15. A 1.25 m sheet then hangs 0.90–2.15 and reads as half a +// metre of washing over the palings, which is exactly what you actually see walking past. +const FENCE_H = 1.60, FENCE_T = 0.09, FRONT_FENCE_H = 0.95, GATE_W = 1.2; +// HOIST_ARM below is the HALF-span. Capped at 1.75 ⇒ 3.5 m across, a real hoist. The first cut +// capped the same number at 3.2 and drew a 6.4 m machine in a 5 m yard. +const HOIST_H = 2.15, HOIST_ARM_T = 0.05, HOIST_ARM_MAX = 1.75; +const TANK_H = 1.9, TANK_D = 1.55; +const AERIAL_H = 1.35; +const PALING_TINTS = ['#9c8c72', '#a4967c', '#8e8068', '#ab9c80', '#93866e']; +const GALV = '#aeb4b6'; // galvanised: hoist, aerial, tank bands +const TANK_TINTS = ['#8d968c', '#a2a49a', '#7f8b86']; + +// The seed a house/yard lot has used since v1 — kept EXACTLY as `buildHouse` computed it so nothing +// re-rolls, and lifted out so the washing layer (a town-wide mesh in washing.js) can derive the same +// hoist from the same numbers instead of inventing a second placement rule. Two implementations of +// one thing is the disease this project keeps catching itself with; one exported pure function is +// the cure. +export function yardSeed(lot, citySeed) { + return (citySeed ^ (lot.x * 73856093) ^ (lot.z * 19349663)) >>> 0; +} + +/** + * yardLayout(lot, citySeed) → the whole back yard as plain numbers, in LOT-LOCAL coordinates + * (+Z = the street-facing front, matching the canonical building convention at the top of this file). + * Pure, allocation-light, no THREE. Both the builder below and washing.js consume it. + */ +export function yardLayout(lot, citySeed) { + const seed = yardSeed(lot, citySeed); + const r = rng(seed, 'yard', 0); + const w = lot.w, d = lot.d; + const house = lot.use === 'house'; + // The dwelling is pulled off its boundaries: a small front garden, side gaps a wheelie bin fits + // down, and the REST IS BACK YARD. Pre-R38 the shell was the whole lot (w × d), which is why 145 + // houses had no yard to put anything in. + const frontSet = house ? 1.25 : 0; + const hw = house ? Math.max(3.5, w * 0.84) : 0; + const hd = house ? Math.max(3.0, d * 0.60) : 0; + const houseZ = house ? (d / 2 - frontSet - hd / 2) : 0; // local z of the dwelling centre + const yardBackZ = -d / 2 + FENCE_T; // inside face of the back fence + const yardFrontZ = house ? (houseZ - hd / 2) : (d / 2 - FENCE_T); + const yardDepth = Math.max(0.8, yardFrontZ - yardBackZ); + const hoistZ = yardBackZ + yardDepth * (house ? 0.55 : 0.5); + // Rotary hoist: HALF-span, fits the yard it stands in and never overhangs the fence. + const arm = Math.min(HOIST_ARM_MAX, Math.max(0.85, Math.min(w * 0.30, yardDepth * 0.42))); + const hasTank = r() < (house ? 0.36 : 0.55); // tanks cluster on the sheds + const hasAerial = house && r() < 0.72; // not every roof kept its aerial + const tankSide = r() < 0.5 ? -1 : 1; + const shedW = house ? 0 : Math.min(3.4, w * 0.52); + const shedD = house ? 0 : Math.min(2.8, yardDepth * 0.46); + return { + seed, house, w, d, + houseW: hw, houseD: hd, houseZ, + fenceH: FENCE_H, palingTint: PALING_TINTS[seed % PALING_TINTS.length], + hoist: { x: 0, z: hoistZ, h: HOIST_H, arm, yaw: (seed >>> 7) % 90 * (Math.PI / 180) }, + tank: hasTank ? { x: tankSide * (w / 2 - TANK_D / 2 - 0.28), z: yardBackZ + TANK_D / 2 + 0.2, h: TANK_H, d: TANK_D, + tint: TANK_TINTS[(seed >>> 11) % TANK_TINTS.length] } : null, + aerial: hasAerial ? { x: (r() - 0.5) * hw * 0.5, z: houseZ - hd * 0.18, h: AERIAL_H } : null, + shed: shedW > 0 ? { x: (r() - 0.5) * (w - shedW - 0.6), z: yardBackZ + shedD / 2 + 0.15, w: shedW, d: shedD, h: 2.2 } : null, + }; +} + +/** + * hoistAnchors(plan, citySeed) → [{ x, z, y, arm, yaw }] in world space, one per Hills Hoist in the + * town, in plan order. The town-wide washing layer pegs onto these. Pure function of the plan + + * seed, derived from the SAME yardLayout the geometry is built from, so a line of washing can never + * hang in mid-air next to a hoist that moved. Empty on every real town today (no house/yard lots). + */ +export function hoistAnchors(plan, citySeed) { + const out = []; + for (const lot of (plan.lots || [])) { + if (lot.use !== 'house' && lot.use !== 'yard') continue; + const L = yardLayout(lot, citySeed >>> 0); + const cos = Math.cos(lot.ry || 0), sin = Math.sin(lot.ry || 0); + out.push({ + lotId: lot.id, + x: lot.x + (L.hoist.x * cos + L.hoist.z * sin), + z: lot.z + (-L.hoist.x * sin + L.hoist.z * cos), + y: L.hoist.h, arm: L.hoist.arm, yaw: (lot.ry || 0) + L.hoist.yaw, seed: L.seed, + }); + } + return out; +} + const _m4 = new THREE.Matrix4(); const _q = new THREE.Quaternion(); const _up = new THREE.Vector3(0, 1, 0); @@ -377,10 +498,64 @@ export function buildChunkBuildings(chunkData, ctx) { } } + // ── R38 §1.4 helpers: every yard prop is ONE more instance in the EXISTING boxMats list ──────── + // (see the header block above — this is the +0-draw mechanism, not a convenience.) Declared + // BEFORE the per-lot loop on purpose: the scratch vectors are `const`, and a hoisted function that + // closes over a `const` declared after its first call would hit the temporal dead zone. + const _yq = new THREE.Quaternion(); + const _yp = new THREE.Vector3(); + const _ys = new THREE.Vector3(); + function yardBox(lot, lx, ly, lz, sx, sy, sz, color, extraYaw = 0) { + const ry = (lot.ry || 0); + const cos = Math.cos(ry), sin = Math.sin(ry); + _yp.set(lot.x + (lx * cos + lz * sin), ly, lot.z + (-lx * sin + lz * cos)); + _yq.setFromAxisAngle(_up, ry + extraYaw); + _ys.set(sx, sy, sz); + boxMats.push({ matrix: new THREE.Matrix4().compose(_yp, _yq, _ys), color: new THREE.Color(color) }); + } + // Paling fence around a lot: the two sides and the back at full height, the street frontage a low + // picket split around a gate gap. 5 boxes = 60 tris for a fenced block of land. + function fenceRuns(lot, L) { + const w = L.w, d = L.d, t = FENCE_T, h = L.fenceH, c = L.palingTint; + yardBox(lot, 0, h / 2, -d / 2 + t / 2, w, h, t, c); // back + yardBox(lot, -w / 2 + t / 2, h / 2, 0, t, h, d, c); // side + yardBox(lot, w / 2 - t / 2, h / 2, 0, t, h, d, c); // side + const fw = (w - GATE_W) / 2; + if (fw > 0.3) { + yardBox(lot, -(GATE_W / 2 + fw / 2), FRONT_FENCE_H / 2, d / 2 - t / 2, fw, FRONT_FENCE_H, t, c); + yardBox(lot, (GATE_W / 2 + fw / 2), FRONT_FENCE_H / 2, d / 2 - t / 2, fw, FRONT_FENCE_H, t, c); + } + } + // Hills Hoist: mast + two crossed arms = four arms in 3 boxes. The washing (washing.js, town-wide, + // its own draw) pegs onto the SAME arms via hoistAnchors(), which reads the same yardLayout. + function hillsHoist(lot, L) { + const H = L.hoist; + yardBox(lot, H.x, H.h / 2, H.z, 0.075, H.h, 0.075, GALV); + yardBox(lot, H.x, H.h, H.z, H.arm * 2, HOIST_ARM_T, HOIST_ARM_T, GALV, H.yaw); + yardBox(lot, H.x, H.h - 0.03, H.z, HOIST_ARM_T, HOIST_ARM_T, H.arm * 2, GALV, H.yaw); + } + // Rainwater tank: two boxes at 45° read as a round corrugated tank in silhouette for 24 tris. + function waterTank(lot, L) { + const T = L.tank; if (!T) return; + yardBox(lot, T.x, T.h / 2, T.z, T.d, T.h, T.d, T.tint); + yardBox(lot, T.x, T.h / 2, T.z, T.d * 0.92, T.h * 0.98, T.d * 0.92, T.tint, Math.PI / 4); + } + // TV aerial on the ridge: mast + two elements. + function tvAerial(lot, L, roofTopY) { + const A = L.aerial; if (!A) return; + yardBox(lot, A.x, roofTopY + A.h / 2, A.z, 0.05, A.h, 0.05, GALV); + yardBox(lot, A.x, roofTopY + A.h * 0.92, A.z, 1.15, 0.045, 0.045, GALV); + yardBox(lot, A.x, roofTopY + A.h * 0.66, A.z, 0.85, 0.045, 0.045, GALV); + } + // ── per-lot build ── for (const lot of chunkData.lots) { const shop = shopByLot.get(lot.id); if (lot.use === 'house') { buildHouse(lot); continue; } + // [R38 §1.4] a `yard` lot is a YARD. Pre-R38 it fell through to buildShopfront() and rendered as + // a blank 3.2 m shed with a door and two windows — 36 of them on the default boot. Under + // ?yards=0 / ?classic=1 it keeps falling through, byte-identically. + if (lot.use === 'yard' && YARDS) { buildYardLot(lot); continue; } if (lot.use === 'stall') { buildStall(lot, shop); continue; } buildShopfront(lot, shop); } @@ -477,16 +652,22 @@ export function buildChunkBuildings(chunkData, ctx) { function buildHouse(lot) { const seed = (ctx.citySeed ^ (lot.x * 73856093) ^ (lot.z * 19349663)) >>> 0; const r = rng(seed, 'house', 0); - const h = 2.7, w = lot.w, d = lot.d; + const h = 2.7; + // [R38 §1.4] Under the ruling the dwelling is inset off its boundaries (front garden, side + // gaps, and A BACK YARD); with ?yards=0 / ?classic=1 it is the whole lot, exactly as since v1. + const L = YARDS ? yardLayout(lot, ctx.citySeed >>> 0) : null; + const w = L ? L.houseW : lot.w, d = L ? L.houseD : lot.d; + const cz = L ? L.houseZ : 0; // local z of the dwelling centre + const wx = lot.x + (cz * Math.sin(lot.ry || 0)), wz = lot.z + (cz * Math.cos(lot.ry || 0)); const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]); - _p.set(lot.x, h / 2, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d); + _p.set(wx, h / 2, wz); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d); boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC }); // simple hip roof: a squashed, slightly oversized box on top - _p.set(lot.x, h + 0.5, lot.z); _s.set(w + 0.6, 1.0, d + 0.6); + _p.set(wx, h + 0.5, wz); _s.set(w + 0.6, 1.0, d + 0.6); boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) }); // front door + two windows (decorative — houses are not enterable, so keep the door out of // the interactive doorMesh or the HUD raycast would mis-resolve it to the nearest shop) - const zf = d / 2 + 0.02; + const zf = cz + d / 2 + 0.02; decorDoorGeos.push(quad( toWorld(lot, -0.5, 0.02, zf, []), toWorld(lot, 0.5, 0.02, zf, []), toWorld(lot, 0.5, 2.0, zf, []), toWorld(lot, -0.5, 2.0, zf, []), @@ -498,6 +679,25 @@ export function buildChunkBuildings(chunkData, ctx) { [0, 24], null, // homes glow at night (always "open") )); } + // ── the yard itself: fence, hoist, tank, aerial — all instances in the EXISTING boxMats draw ── + if (L) { fenceRuns(lot, L); hillsHoist(lot, L); waterTank(lot, L); tvAerial(lot, L, h + 1.0); } + colliders.push(lotCollider(lot)); // unchanged: the collider is the LOT, i.e. the fence line + } + + // [R38 §1.4] A `yard` lot: fenced block, Hills Hoist, a fibro shed, often a rainwater tank. No + // shell, no parapet, no facade, no door, no windows — which is why replacing the blank shed this + // lot used to render as costs a net ~100 tris rather than ~230, and removes a decorative door + // from a lot nobody could ever walk into. Collider unchanged (the lot rect = the fence line), so + // player collision on these 36 lots is byte-identical to pre-R38. + function buildYardLot(lot) { + const L = yardLayout(lot, ctx.citySeed >>> 0); + fenceRuns(lot, L); + hillsHoist(lot, L); + waterTank(lot, L); + if (L.shed) { + yardBox(lot, L.shed.x, L.shed.h / 2, L.shed.z, L.shed.w, L.shed.h, L.shed.d, '#9aa0a2'); + yardBox(lot, L.shed.x, L.shed.h + 0.12, L.shed.z, L.shed.w + 0.35, 0.16, L.shed.d + 0.35, ROOF_TINT); + } colliders.push(lotCollider(lot)); } diff --git a/web/js/world/character.js b/web/js/world/character.js new file mode 100644 index 0000000..eb836bf --- /dev/null +++ b/web/js/world/character.js @@ -0,0 +1,240 @@ +// PROCITY Lane B — character.js (v8 WAVE 1 §1.2 — THE PER-TOWN CHARACTER VECTOR) +// +// Twenty-three real towns have shipped since R17 differing ONLY in street layout and shop names: +// same road skin, same footpath skin, same base plane, same sky, same ambience. This file is the +// difference — a ground palette, a sky bias and an ambience mix, one entry per town key. +// +// ── WHY THIS IS FREE ──────────────────────────────────────────────────────────────────────────── +// `ground.js`'s `add()` merges each surface class into ONE mesh for the whole town regardless of +// town size (measured R37: exactly 5 meshes on both the synthetic default and katoomba_real). +// Swapping which skin name / tint that one mesh's ONE shared material carries is therefore +// **+0 draws and +0 triangles** — the strongest cost claim in the v8 packet, and it holds at any N. +// +// ── CLASSIC IS BYTE-IDENTICAL BY CONSTRUCTION, NOT BY FLAG (Fable's R38 binding correction 3) ──── +// `townCharacter(null)` returns CLASSIC_CHARACTER: the exact literals `ground.js` and `lighting.js` +// have used since v1 (`djsim-road` / `djsim-footpath` / `grass` / `brickpave`, kerb 0x9a948c, and a +// null sky so `plan.sky || 'golden-arvo'` still decides). No tints. `skins.groundMat(name, null)` +// takes the *same cache key* it always did (`ground:`), so the covenanted boot doesn't merely +// look the same — it constructs the same objects. There is no `if (classic)` anywhere in the ground +// path for this item; classic gets the frozen literals because a boot with no town key HAS no +// character, which is also what the synthetic default boot gets. **The pinned 292/300 frame cannot +// move for §1.2 by construction**, because the default boot never leaves this branch. +// +// ── THE VACUOUS ARM IS PER-TOWN, NOT PER-CORPUS (Fable's R38 binding correction 2) ────────────── +// The obvious implementation keys on `state` from `assets/towns/index.json` ("NSW → temperate, +// NT → red dust"). That is a silent trap: **`newtown_godverse` and `redhill_godverse` carry +// `state: ''`** (verified in the shipped index, 2 of 23), so a per-corpus arm would drop exactly +// those two into a default branch with nothing printed. Every one of the 23 keys therefore has its +// OWN entry below, and an unmatched key returns the frozen literals with `matched:false` set, so a +// gate (or the boot log) can say *which* town fell through instead of guessing. +// +// ── THE SKY IS A BASE SELECTION, NEVER `lighting.setSky` (binding correction 3) ───────────────── +// `lighting.js:53`'s `externalSky` is a ONE-WAY LATCH with no un-set path: once weather calls +// `setSky()` the dome is owned forever, and the dawn swap (R32) never fires again. A character sky +// pushed through `setSky` would therefore silently kill R32's dawn dome on all 23 towns. Instead +// `createLighting` takes a `sky` OPTION that feeds the *base* `skyName` selection — so weather still +// wins when it rains, and DAWN still swaps, exactly as they do today. +// +// ── PROCEDURAL-FIRST, AND THE UPGRADE ALREADY HAPPENED ────────────────────────────────────────── +// This table was written against the 10 grounds that shipped BEFORE this round, so it stands up on +// its own with no asset wave at all — including the two that had been in the manifest since v1 and +// were selected by **nothing**: `gravel` (use:"verge", now Castlemaine's goldfields tailings) and +// `reddust` (use:"outback", now Darwin and both Red Hills). Everything past those 10 is a per-class +// **tint**: a multiplier on the existing shared material (color × map, three's own path), so it is +// free at any N and needs no file to exist. +// +// Lane E's grounds then landed MID-ROUND and the use-if-ready upgrade is exactly what it was +// designed to be — a name change in this table, nothing else: `grass-dry` (Bendigo, Adelaide, +// Northbridge), `grass-temperate` (Daylesford, Katoomba, Bowral, Hobart, Launceston), +// `coastal-sand` (Fremantle, Newcastle). Their tints were re-picked against the delivered pixels +// rather than against the names, and if a JPEG ever goes missing `skins.js`'s flat class fallback +// (pre-multiplied by the tint) still lands somewhere sane, so `?noassets` is one code path. +// +// A note on WHY the two orphans were orphans, because it is not what it looks like (Lane E, R38): +// `gravel` and `reddust` carry `use` values — `"verge"` and `"outback"` — that name **no slot** +// `ground.js` has. Pre-R38 that file hardcoded exactly four skin names, so a manifest entry whose +// `use` was neither road/footpath/yard/plaza was not merely unselected, it was unselectable. This +// table fixes that at the root by making the SLOT the addressable thing: any skin name can be the +// road, the footpath, the base plane or the plaza, and the manifest's `use` string goes back to +// being documentation (nothing in `skins.groundMat` ever read it — it builds a filename). Both +// orphans are now selected: `gravel` is Castlemaine's base, `reddust` is Darwin's and both Red Hills'. +// +// Measured mean colour of every ground skin now in the manifest (16×16 box mean): +// djsim-road #364357 · asphalt1 #7d7663 · asphalt2 #544839 · djsim-footpath #b0b1b2 +// footpath1 #948166 · footpath2 #958b74 · grass #876635 (already a DRY brown, not green) +// gravel #76715e · reddust #9e431b · brickpave #7b4c3f +// grass-dry #c0b7aa (E, R38 — pale tussock) · grass-temperate #496c3c (E — deep green) +// coastal-sand #cacbca (E — near-white beach sand, tinted down or a town glows) + +// The six §1.1 point-source ambience layers, in table order. Exported so audio.js and any gate +// iterate the SAME list rather than re-deriving it (the R27 trap: two lists that agree until they +// don't). +export const AMBIENCE_SOURCES = Object.freeze([ + 'dog-bark-fence', 'mower-distant', 'radio-through-window', 'sprinkler', 'roller-door', 'magpie-carol', +]); + +const MIX_NEUTRAL = Object.freeze({ + 'dog-bark-fence': 1, 'mower-distant': 1, 'radio-through-window': 1, + sprinkler: 1, 'roller-door': 1, 'magpie-carol': 1, +}); + +// The frozen literals. Every one of these is what the file that consumes it used before this round: +// ground.js's four skin names + its kerb colour, and a null sky so lighting keeps `plan.sky || +// 'golden-arvo'`. Returned for a null/unknown town key ⇒ classic and the synthetic default are +// byte-identical BY CONSTRUCTION. +export const CLASSIC_CHARACTER = Object.freeze({ + key: null, + label: 'classic', + matched: false, + ground: Object.freeze({ road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' }), + tint: Object.freeze({ road: null, foot: null, base: null, plaza: null }), + kerb: 0x9a948c, + sky: null, + mix: MIX_NEUTRAL, +}); + +// ── the 23 towns ──────────────────────────────────────────────────────────────────────────────── +// One entry per key in `assets/towns/index.json`. `label` is the character in words (it is what a +// gate prints, and what the boot log prints), NOT a corpus bucket — two towns can share a label and +// still differ in tint, and a town can be the only member of its label. Mix values are multipliers +// on §1.1's per-source gain; 0 means "this sound does not belong in this town" and is a real answer +// (a mower in Fitzroy, a roller door in Daylesford). +const TOWNS = { + // ── VIC goldfields / spa country: dry summer paddock, gravel verge, hot white sky ── + bendigo_real: { label: 'goldfields dry', ground: { road: 'asphalt1', foot: 'footpath2', base: 'grass-dry', plaza: 'brickpave' }, + tint: { road: '#c8bfa8', foot: '#e6dcc4', base: '#cbbf92', plaza: null }, sky: 'heat-haze', + mix: { 'dog-bark-fence': 1.1, 'mower-distant': 1.0, 'radio-through-window': 0.8, sprinkler: 1.2, 'roller-door': 0.7, 'magpie-carol': 1.3 } }, + castlemaine_real: { label: 'goldfields gravel', ground: { road: 'asphalt1', foot: 'footpath1', base: 'gravel', plaza: 'brickpave' }, + tint: { road: '#c0b8a2', foot: '#dcd0b4', base: '#d0c49c', plaza: null }, sky: 'heat-haze', + mix: { 'dog-bark-fence': 1.2, 'mower-distant': 1.1, 'radio-through-window': 0.7, sprinkler: 1.0, 'roller-door': 0.5, 'magpie-carol': 1.4 } }, + daylesford_real: { label: 'spa country green', ground: { road: 'asphalt1', foot: 'footpath2', base: 'grass-temperate', plaza: 'brickpave' }, + tint: { road: '#b4b2ac', foot: '#d6d2c2', base: '#d8e6cc', plaza: null }, sky: 'spring-puffy', + mix: { 'dog-bark-fence': 1.1, 'mower-distant': 1.2, 'radio-through-window': 0.6, sprinkler: 0.9, 'roller-door': 0.3, 'magpie-carol': 1.2 } }, + + // ── VIC regional cities: bitumen main street, grass verges, ordinary blue ── + geelong_real: { label: 'bay-city bitumen', ground: { road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' }, + tint: { road: '#b6bcc4', foot: '#dfe2e0', base: '#b6c48c', plaza: null }, sky: 'endless-blue', + mix: { 'dog-bark-fence': 1.0, 'mower-distant': 0.9, 'radio-through-window': 1.0, sprinkler: 0.9, 'roller-door': 1.0, 'magpie-carol': 1.0 } }, + + // ── inner Melbourne: bluestone-dark road, worn concrete, almost no verge left ── + fitzroy_real: { label: 'inner-city bluestone', ground: { road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' }, + tint: { road: '#8f97a6', foot: '#c9c8c4', base: '#8f9a72', plaza: '#c0b0a8' }, sky: 'spring-puffy', + mix: { 'dog-bark-fence': 0.9, 'mower-distant': 0.15, 'radio-through-window': 1.4, sprinkler: 0.3, 'roller-door': 1.4, 'magpie-carol': 0.6 } }, + brunswick_real: { label: 'inner-city bluestone', ground: { road: 'djsim-road', foot: 'footpath2', base: 'grass', plaza: 'brickpave' }, + tint: { road: '#949cab', foot: '#cfc9bb', base: '#98a075', plaza: '#c4b4aa' }, sky: 'spring-puffy', + mix: { 'dog-bark-fence': 1.0, 'mower-distant': 0.3, 'radio-through-window': 1.3, sprinkler: 0.4, 'roller-door': 1.3, 'magpie-carol': 0.7 } }, + + // ── NSW: sandstone sydney inner-west, mountains, southern highlands, hunter ── + newtown_real: { label: 'inner-west terrace', ground: { road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' }, + tint: { road: '#9aa0ac', foot: '#d2cec4', base: '#8f9a70', plaza: '#c8b6a4' }, sky: 'spring-puffy', + mix: { 'dog-bark-fence': 0.9, 'mower-distant': 0.15, 'radio-through-window': 1.4, sprinkler: 0.3, 'roller-door': 1.2, 'magpie-carol': 0.6 } }, + glebe_real: { label: 'inner-west terrace', ground: { road: 'djsim-road', foot: 'footpath1', base: 'grass', plaza: 'brickpave' }, + tint: { road: '#9ba3af', foot: '#d8c9b0', base: '#93a074', plaza: '#c8b6a4' }, sky: 'endless-blue', + mix: { 'dog-bark-fence': 0.9, 'mower-distant': 0.2, 'radio-through-window': 1.3, sprinkler: 0.4, 'roller-door': 1.1, 'magpie-carol': 0.7 } }, + marrickville_real: { label: 'inner-west brick', ground: { road: 'asphalt1', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' }, + tint: { road: '#b0aaa0', foot: '#cdccc8', base: '#9aa678', plaza: null }, sky: 'spring-puffy', + mix: { 'dog-bark-fence': 1.2, 'mower-distant': 0.6, 'radio-through-window': 1.2, sprinkler: 0.7, 'roller-door': 1.3, 'magpie-carol': 0.9 } }, + katoomba_real: { label: 'mountain wet-cold', ground: { road: 'asphalt2', foot: 'djsim-footpath', base: 'grass-temperate', plaza: 'brickpave' }, + tint: { road: '#a8a4a0', foot: '#b8bcbe', base: '#bcd0c4', plaza: '#a89a94' }, sky: 'high-cirrus', + mix: { 'dog-bark-fence': 1.1, 'mower-distant': 0.7, 'radio-through-window': 0.9, sprinkler: 0.4, 'roller-door': 0.6, 'magpie-carol': 1.1 } }, + bowral_real: { label: 'highlands green', ground: { road: 'asphalt1', foot: 'footpath2', base: 'grass-temperate', plaza: 'brickpave' }, + tint: { road: '#b4b0a8', foot: '#dcd8cc', base: '#dcecd4', plaza: null }, sky: 'high-cirrus', + mix: { 'dog-bark-fence': 1.0, 'mower-distant': 1.3, 'radio-through-window': 0.6, sprinkler: 1.1, 'roller-door': 0.4, 'magpie-carol': 1.2 } }, + newcastle_real: { label: 'coal-coast salt', ground: { road: 'djsim-road', foot: 'footpath2', base: 'coastal-sand', plaza: 'brickpave' }, + tint: { road: '#a8b0ba', foot: '#e0d8c4', base: '#cec6ac', plaza: null }, sky: 'endless-blue', + mix: { 'dog-bark-fence': 1.1, 'mower-distant': 0.8, 'radio-through-window': 1.1, sprinkler: 0.8, 'roller-door': 1.2, 'magpie-carol': 0.9 } }, + + // ── QLD: red soil, tin and timber, big afternoon light ── + redhill_real: { label: 'brisbane red soil', ground: { road: 'asphalt1', foot: 'footpath1', base: 'reddust', plaza: 'brickpave' }, + tint: { road: '#c2b2a2', foot: '#e2d0b4', base: '#d8b8a0', plaza: null }, sky: 'endless-blue', + mix: { 'dog-bark-fence': 1.3, 'mower-distant': 1.2, 'radio-through-window': 1.0, sprinkler: 1.1, 'roller-door': 0.8, 'magpie-carol': 1.2 } }, + westend_real: { label: 'brisbane river-flat', ground: { road: 'asphalt1', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' }, + tint: { road: '#bcb0a4', foot: '#d8d4cc', base: '#a8bc78', plaza: '#c8b4a4' }, sky: 'summer-storm', + mix: { 'dog-bark-fence': 1.1, 'mower-distant': 0.7, 'radio-through-window': 1.3, sprinkler: 0.9, 'roller-door': 1.1, 'magpie-carol': 1.0 } }, + + // ── SA / WA / NT ── + adelaide_real: { label: 'plains dry-grid', ground: { road: 'asphalt1', foot: 'djsim-footpath', base: 'grass-dry', plaza: 'brickpave' }, + tint: { road: '#c4bcac', foot: '#ddd8cc', base: '#d2c69a', plaza: null }, sky: 'endless-blue', + mix: { 'dog-bark-fence': 1.0, 'mower-distant': 1.0, 'radio-through-window': 1.0, sprinkler: 1.3, 'roller-door': 1.0, 'magpie-carol': 1.1 } }, + fremantle_real: { label: 'port limestone', ground: { road: 'asphalt1', foot: 'footpath2', base: 'coastal-sand', plaza: 'brickpave' }, + tint: { road: '#cabfae', foot: '#eee4cc', base: '#ded0ac', plaza: '#d0bca8' }, sky: 'endless-blue', + mix: { 'dog-bark-fence': 1.0, 'mower-distant': 0.6, 'radio-through-window': 1.1, sprinkler: 0.9, 'roller-door': 1.2, 'magpie-carol': 1.0 } }, + northbridge_real: { label: 'perth hot-grid', ground: { road: 'djsim-road', foot: 'footpath2', base: 'grass-dry', plaza: 'brickpave' }, + tint: { road: '#aeb2b8', foot: '#e6dcc2', base: '#dcc894', plaza: null }, sky: 'heat-haze', + mix: { 'dog-bark-fence': 0.9, 'mower-distant': 0.4, 'radio-through-window': 1.3, sprinkler: 1.0, 'roller-door': 1.3, 'magpie-carol': 0.8 } }, + darwin_real: { label: 'top-end red dust', ground: { road: 'asphalt1', foot: 'footpath1', base: 'reddust', plaza: 'brickpave' }, + tint: { road: '#c8a894', foot: '#e0c4a0', base: null, plaza: '#c8a090' }, sky: 'monsoon', + mix: { 'dog-bark-fence': 1.2, 'mower-distant': 0.8, 'radio-through-window': 1.1, sprinkler: 0.6, 'roller-door': 1.0, 'magpie-carol': 0.5 } }, + + // ── TAS: cold light, deep green, dark wet bitumen ── + hobart_real: { label: 'cold-light southern', ground: { road: 'asphalt2', foot: 'djsim-footpath', base: 'grass-temperate', plaza: 'brickpave' }, + tint: { road: '#9ea6ae', foot: '#b4bcc2', base: '#c0d4c8', plaza: '#a09aa0' }, sky: 'cold-front', + mix: { 'dog-bark-fence': 1.0, 'mower-distant': 0.8, 'radio-through-window': 0.9, sprinkler: 0.3, 'roller-door': 0.9, 'magpie-carol': 1.0 } }, + launceston_real: { label: 'cold-light southern', ground: { road: 'asphalt2', foot: 'footpath2', base: 'grass-temperate', plaza: 'brickpave' }, + tint: { road: '#a4a8ac', foot: '#c8ccc4', base: '#cce0c4', plaza: null }, sky: 'cold-front', + mix: { 'dog-bark-fence': 1.1, 'mower-distant': 1.0, 'radio-through-window': 0.8, sprinkler: 0.4, 'roller-door': 0.7, 'magpie-carol': 1.1 } }, + + // ── ACT ── + braddon_real: { label: 'capital planted', ground: { road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' }, + tint: { road: '#a8aeb8', foot: '#d4d6d4', base: '#b0b878', plaza: null }, sky: 'high-cirrus', + mix: { 'dog-bark-fence': 0.8, 'mower-distant': 1.1, 'radio-through-window': 1.0, sprinkler: 1.2, 'roller-door': 1.0, 'magpie-carol': 1.5 } }, + + // ── the two GODVERSE towns. These are the pair carrying `state: ''` in the index — the exact + // reason correction 2 exists. They get their OWN entries here, keyed by town key, so neither + // can silently take a default branch. Each is deliberately the SAME character as its `_real` + // sibling (they are the same place with John's real shop data on top), and stating that as two + // separate entries is the point: it is a decision, not a fallthrough. ── + newtown_godverse: { label: 'inner-west terrace', ground: { road: 'djsim-road', foot: 'djsim-footpath', base: 'grass', plaza: 'brickpave' }, + tint: { road: '#9aa0ac', foot: '#d2cec4', base: '#8f9a70', plaza: '#c8b6a4' }, sky: 'spring-puffy', + mix: { 'dog-bark-fence': 0.9, 'mower-distant': 0.15, 'radio-through-window': 1.4, sprinkler: 0.3, 'roller-door': 1.2, 'magpie-carol': 0.6 } }, + redhill_godverse: { label: 'brisbane red soil', ground: { road: 'asphalt1', foot: 'footpath1', base: 'reddust', plaza: 'brickpave' }, + tint: { road: '#c2b2a2', foot: '#e2d0b4', base: '#d8b8a0', plaza: null }, sky: 'endless-blue', + mix: { 'dog-bark-fence': 1.3, 'mower-distant': 1.2, 'radio-through-window': 1.0, sprinkler: 1.1, 'roller-door': 0.8, 'magpie-carol': 1.2 } }, +}; + +// Also accept the two checked-in OSM fixtures (Lane A's `melbourne` / `katoomba` keys) so a +// `?plansrc=osm&town=katoomba` boot is not a silent fallthrough either. +TOWNS.katoomba = TOWNS.katoomba_real; +TOWNS.melbourne = TOWNS.fitzroy_real; + +const freezeEntry = (key, e) => Object.freeze({ + key, + label: e.label, + matched: true, + ground: Object.freeze({ ...e.ground }), + tint: Object.freeze({ road: null, foot: null, base: null, plaza: null, ...e.tint }), + kerb: e.kerb ?? CLASSIC_CHARACTER.kerb, + sky: e.sky || null, + mix: Object.freeze({ ...MIX_NEUTRAL, ...(e.mix || {}) }), +}); + +const CACHE = new Map(); + +/** + * townCharacter(townKey) → frozen character vector. + * + * `null` / `undefined` / an unmatched key ⇒ CLASSIC_CHARACTER, i.e. the literals this codebase has + * used since v1, with `matched:false` so the caller can PRINT which key fell through (correction 2: + * the vacuous arm is per-town and must never be silent). Pure, cached, no side effects, no fetch. + */ +export function townCharacter(townKey) { + if (!townKey) return CLASSIC_CHARACTER; + const key = String(townKey); + const hit = CACHE.get(key); + if (hit) return hit; + const e = TOWNS[key]; + if (!e) return Object.freeze({ ...CLASSIC_CHARACTER, key, label: 'unmatched', matched: false }); + const c = freezeEntry(key, e); + CACHE.set(key, c); + return c; +} + +/** Every town key this file carries an explicit entry for (gates iterate this, not the index). */ +export function characterKeys() { return Object.keys(TOWNS); } + +/** One-line description for the boot log / a gate's report. */ +export function describeCharacter(c) { + if (!c || !c.matched) return `character: none (${(c && c.key) || 'classic/synthetic'}) — frozen v1 literals`; + const g = c.ground; + return `character: ${c.label} — road ${g.road} · foot ${g.foot} · base ${g.base} · sky ${c.sky || 'plan default'}`; +} diff --git a/web/js/world/furniture.js b/web/js/world/furniture.js index fa76e03..cb1935a 100644 --- a/web/js/world/furniture.js +++ b/web/js/world/furniture.js @@ -26,9 +26,12 @@ const SHELTER_W = 2.33, SHELTER_D = 1.41, SHELTER_YAW = 0; // chunk built after uses the GLB (streaming rebuilds pick it up). No async write into a live chunk. const NOASSETS = (() => { try { const p = new URLSearchParams(location.search); return p.has('noassets') && p.get('noassets') !== '0'; } catch { return false; } })(); // Only LIGHT GLBs (≤~8k tris) are enabled — a heavy prop × many instances blows the ≤200k tri gate. -// Measured: bench 1.9k ✓, food_cart 5.0k ✓, bin 3.0k ✓, bus_shelter 8.0k ✓ (only 1–3 per town), but -// novelty_record is **26.5k tris** (5× over) → it stays on the low-poly PRIMITIVE until Lane E -// decimates it (same treatment as the ped rigs — see LANE_B_NOTES). +// Measured: bench 1.9k ✓, food_cart 5.0k ✓, bin 3.0k ✓, bus_shelter 8.0k ✓ (only 1–3 per town). +// [R38 — Lane E correction, recorded] this comment claimed `novelty_record` was **26.5k tris**; it +// has been **8,000 since R5** and the number was stale for six epochs. It is still not in GLB_FILES +// below, so the primitive is what draws either way — but the reason on file was wrong, and a stale +// measurement that nobody re-took is exactly what this project keeps catching itself with. Whether +// to wire it now is a draw/tri question for a round that measures it, not a comment fix. const GLB_FILES = { bench: 'procity_street_bench_01.glb', food_cart: 'procity_street_food_cart_01.glb', diff --git a/web/js/world/ground.js b/web/js/world/ground.js index 8090b9b..b1087f3 100644 --- a/web/js/world/ground.js +++ b/web/js/world/ground.js @@ -9,6 +9,7 @@ import * as THREE from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; import { resolveEdges } from './planutil.js'; import { vergeBand } from '../core/registry.js'; +import { townCharacter } from './character.js'; // [R38 §1.2] the per-town ground palette const FOOT = 3.5; // footpath width OUTBOARD of the corridor (unchanged since v1) const TILE = 5; // metres per texture tile @@ -89,10 +90,19 @@ function rectGeo(cx, cz, w, d, y, tile = TILE) { return hQuad(cx, cz, new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1), w, d, y); } -export function buildGround(plan, skins) { +// [R38 §1.2 — THE PER-TOWN CHARACTER VECTOR] `townKey` is the ?town= key the shell resolved; the +// plan deliberately does NOT carry it (plan_osm.js:515 — "town key stays OFF the plan (keeps the +// Melbourne golden frozen)"), so it arrives as an option. `townCharacter(null)` returns the frozen +// v1 literals, which is what the synthetic default and ?classic=1 both get — so this whole item is +// +0 draws / +0 tris AND byte-identical on the covenanted boot BY CONSTRUCTION, with no flag test +// anywhere in this file. `add()` merges each surface class into ONE town-wide mesh regardless of +// town size (measured: exactly 5 meshes, synthetic AND katoomba_real), so swapping which skin/tint +// that one shared material carries cannot cost a draw at any N. +export function buildGround(plan, skins, { townKey = null } = {}) { const group = new THREE.Group(); group.name = 'ground'; const edges = resolveEdges(plan); + const character = townCharacter(townKey); const roadGeos = [], footGeos = [], kerbGeos = []; const dir = new THREE.Vector3(), perp = new THREE.Vector3(); @@ -174,12 +184,17 @@ export function buildGround(plan, skins) { group.add(m); meshes.push(m); }; - const roadSkin = 'djsim-road', footSkin = 'djsim-footpath', grassSkin = 'grass', plazaSkin = 'brickpave'; - add([baseGeo], skins.groundMat(grassSkin)); - add(roadGeos, skins.groundMat(roadSkin)); - add(footGeos, skins.groundMat(footSkin)); - add(plazaGeos, skins.groundMat(plazaSkin)); - const kerbMat = new THREE.MeshStandardMaterial({ color: 0x9a948c, roughness: 0.9 }); + // [R38 §1.2] Five classes, five town-wide meshes, five shared materials — exactly as before. The + // only thing the character vector changes is WHICH skin name and WHICH tint each of those five + // asks for. An unmatched town key returns the same literals a null key does (and says so in + // `character.matched`), so a new town cache appearing in E's index before this table knows about + // it degrades to today's palette instead of to a default branch nobody printed. + const g = character.ground, tint = character.tint; + add([baseGeo], skins.groundMat(g.base, tint.base)); + add(roadGeos, skins.groundMat(g.road, tint.road)); + add(footGeos, skins.groundMat(g.foot, tint.foot)); + add(plazaGeos, skins.groundMat(g.plaza, tint.plaza)); + const kerbMat = new THREE.MeshStandardMaterial({ color: character.kerb, roughness: 0.9 }); add(kerbGeos, kerbMat); function dispose() { @@ -187,5 +202,5 @@ export function buildGround(plan, skins) { kerbMat.dispose(); group.clear(); } - return { group, dispose, verge: VERGE }; + return { group, dispose, verge: VERGE, character, get meshCount() { return meshes.length; } }; } diff --git a/web/js/world/lighting.js b/web/js/world/lighting.js index 42bf670..c6352ab 100644 --- a/web/js/world/lighting.js +++ b/web/js/world/lighting.js @@ -38,7 +38,14 @@ function gradientEquirect(THREE) { return tex; } -export function createLighting(scene, plan, skins, { radius = 3, shadows = true, dawnSky = null } = {}) { +// [R38 §1.2 — THE SKY BIAS IS A BASE SELECTION, NEVER `setSky`] `sky` (option) biases which dome +// skin this TOWN wears. It deliberately feeds the BASE `skyName` below rather than calling +// `setSky()`, because `externalSky` (declared a few lines down) is a **one-way latch with no un-set +// path**: whatever calls `setSky` owns the dome for the rest of the session, which would have +// silently killed R32's DAWN swap on all 23 towns and made a character sky beat the weather's +// rain dome. Feeding the base instead keeps the whole existing precedence intact — +// weather override > dawn swap > town base > 'golden-arvo'. +export function createLighting(scene, plan, skins, { radius = 3, shadows = true, dawnSky = null, sky = null } = {}) { const group = new THREE.Group(); group.name = 'lighting'; scene.add(group); @@ -48,7 +55,7 @@ export function createLighting(scene, plan, skins, { radius = 3, shadows = true, // golden-SUNSET photo, so DAWN read as dusk-orange (the R31 playtest's cosmetic #3). During the DAWN // segment only, the dome swaps to a cool morning skin, then back. A weather setSky() override // (rain/overcast) OWNS the dome outright — the dawn swap never fights it. - const skyName = plan.sky || 'golden-arvo'; + const skyName = sky || plan.sky || 'golden-arvo'; // [R38 §1.2] town bias, then the plan, then v1's literal const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false }); let externalSky = null; // set by setSky() (weather) — wins over base + dawn forever after let shownSky = skyName; diff --git a/web/js/world/magpie.js b/web/js/world/magpie.js new file mode 100644 index 0000000..d12977e --- /dev/null +++ b/web/js/world/magpie.js @@ -0,0 +1,333 @@ +// PROCITY Lane B — magpie.js (v8 WAVE 1 §1.3 — THE MAGPIE) +// +// For about six weeks of the game's year, one bird on one street will not let you walk past it. +// +// ── WHAT IT COSTS ─────────────────────────────────────────────────────────────────────────────── +// ONE town-wide `InstancedMesh`, `count` 0 or 1 ⇒ **+1 draw while a bird is on screen, exactly 0 +// otherwise** (three.module.js:2193-2199 returns before the GL call at count 0; `render.calls++` is +// downstream at :4094 — the charter's own vendored-line verification). Geometry is a merged +// primitive bird, **154 triangles** against the ≤900 allowance. There is no atlas, no GLB, no fetch: +// procedural by construction, so `?noassets` is the same code path as everything else. +// +// ── TERRITORY SCALES WITH THE ROAD GRAPH (Fable's R38 binding correction 4) ───────────────────── +// A fixed handful of anchors is the trap: the synthetic default has ~6.8 km of street and +// `bendigo_real` has ~188 km across 2,593 edges, so a capacity-4 magpie on a real town is a swooping +// town where you never meet the magpie. Territories are therefore derived by **road length** — one +// per `TERRITORY_SPACING` metres — and only the NEAREST one is ever instantiated, refreshed off +// `chunks.onChunkBuilt` (the seam R37 §0.4 wired for exactly this) so the cap is spent where the +// player is. One bird at a time, on roughly one street per 1.2 km, for six weeks of the year. +// The 1-per-1.2 km figure is the *swooping* density, not the magpie density: territories are ~2–4 ha +// so a suburb is thick with magpies, and ~90% of males never swoop — so the sparse layer IS the 10%. +// +// ── THE HAT IS CUT ────────────────────────────────────────────────────────────────────────────── +// The ice-cream-container helmet is not here and must not come back without a street verb: `index.html` +// binds `] [ P M T` plus a click that enters a shop, and nothing else. (Charter cut list; R38 +// binding correction 4.) +// +// ── THE LATCH IS RUNTIME-ONLY ─────────────────────────────────────────────────────────────────── +// "It got me today" is a plain field on this module's state. It is cleared when the day rolls and it +// is **never written to localStorage** — the delta law says a v8 content layer does not grow the save +// file, and F's gate for this item is exactly "the day-roll clears the latch and localStorage gained +// no new key". There is no persistence code in this file to go wrong. +// +// ── POSITION IS A PURE FUNCTION ───────────────────────────────────────────────────────────────── +// `swoopPos(launch, t)` is exported and side-effect-free — the tram's proven contract (R9/R21), the +// thing that makes a moving object gate-checkable at all. The only stateful part is *when* a launch +// starts, which is recorded as three numbers. +// +// ── LANE E'S 894-TRI `magpie` GLB: NOT ADOPTED THIS ROUND, AND THE REASON IS THE WHITE ────────── +// E shipped it mid-round with a caveat traced (not guessed) to the operator's PBR bake: **it reads +// as a CROW** — the white nape, wing bar and tail are gone, and E confirms the loss is already in +// the raw 500k mesh, so our decimation is exonerated. Measured against what is in this file: +// procedural bird **182 tris**, white nape + rump + wing bars baked as vertex colours, 0 fetches +// E's GLB **894 tris** (4.9×), no white, +1 fetch, +1 material +// A magpie you see for 1.2 seconds in your peripheral vision at speed IS its white bar — that black +// -and-white flash is the entire read. A uniformly dark bird is a currawong or a crow, and the joke +// this item exists for does not land. So **§1.3 does not hold for the tint; the GLB does.** When E +// hand-tints the 512² atlas, the swap is `birdGeometry()` / `birdMaterial()` below — two factory +// singletons, one call site each, the same use-if-ready shape `furniture.js` uses for the depot +// props. Deliberately NO speculative `loadGLB` here: the asset is not in the manifest, and a boot +// that probes for a file nobody has shipped is the blind probe R26 spent a round killing. +// +// The other two Lane E R38 props are a decided NO, on arithmetic rather than taste: `paling_fence` +// is 1,121 tris and this town wants **905 fence runs** (5 × 181 lots) ⇒ **1.01 M triangles** against +// ~75 k spare, and it cannot ride `boxMats` (BoxGeometry, one shared untextured shell material), so +// it would also pay a draw. `water_tank` at 500 tris replaces a 24-tri pair. The primitives are not +// a stopgap here, they are the answer. `hills_hoist` and `tv_aerial` were killed outright by E's own +// spike (thin wire returns as thousands of disconnected fragments; a voxel remesh coarse enough for +// 600 tris erases the pole) — so those two are permanently primitive, which is what shipped. + +import * as THREE from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; +import { rng, frange } from '../core/prng.js'; +import { resolveEdges } from './planutil.js'; +import { applyWind } from './wind.js'; + +const TERRITORY_SPACING = 1200; // metres of street per swooping bird (see header) +const TERRITORY_MAX = 400; // sanity cap on the candidate list (bendigo lands ~157) +const DEFEND_R = 34; // he starts at the edge of his patch, not across the suburb +const PERCH_Y = 4.32; // on the streetlight arm (furniture.js: pole to 4.2, arm at 4.1) +const SWOOP_T = 1.25, RETURN_T = 1.9, COOLDOWN = 5.5; +const WING_SPAN = 0.34; // the `wing` wind mode's reference half-span + +// ── the season, and the vacuous version of it that this file shipped first ────────────────────── +// A 364-day year (the game already runs a 7-day week: `weekNightOf(day) = (day-1) % 7`), swooping +// from ~1 August for six weeks — the real window, 42/364 = 11.5% of the year. Straight, that is a +// **feature nobody will ever see**: day 1 is January, a game day is 240 seconds, and no playtest has +// reached day 212. So the year carries a seeded offset — you arrive in a town and it is whatever +// time of year it is THERE. +// +// **The first cut of that offset was `rng(citySeed, 'swoopseason', 0)` and it was VACUOUS — measured, +// not suspected.** `plan.citySeed` is the `?seed=` value and **the town key is deliberately NOT in +// it** (plan_osm.js:515 keeps it off the plan so the Melbourne golden stays frozen), so all 23 real +// towns share one seed, therefore one offset, therefore ONE answer: on the default seed, **24 of 24 +// towns were 214 days from swooping season**. The mechanism produced a constant. A magpie nobody can +// meet is the same as no magpie, and it would have gated green because `?magpie=1` forces it. +// +// The offset is therefore keyed on **(citySeed, townKey)**: 23 towns, 23 positions in the year. The +// synthetic default has no town key and uses its own arm, so `?seed=` still moves it. Measured over +// the shipped corpus below — see LANE_B_NOTES §38 for the count that is actually in season. +// `?magpie=1` forces the season open for gates and for John. +const YEAR_DAYS = 364, SEASON_START = 212, SEASON_LEN = 42; +export function seasonOffset(citySeed, townKey = null) { + return Math.floor(rng(citySeed >>> 0, 'swoopseason:' + (townKey || 'synthetic'), 0)() * YEAR_DAYS); +} +export function inSwoopSeason(day, citySeed, townKey = null) { + const yd = (((day | 0) - 1 + seasonOffset(citySeed, townKey)) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS; + return yd >= SEASON_START && yd < SEASON_START + SEASON_LEN; +} +/** Days until this town's season opens (0 while it is open). Diagnostic for the notes + gates. */ +export function daysToSeason(day, citySeed, townKey = null) { + if (inSwoopSeason(day, citySeed, townKey)) return 0; + const yd = (((day | 0) - 1 + seasonOffset(citySeed, townKey)) % YEAR_DAYS + YEAR_DAYS) % YEAR_DAYS; + return yd < SEASON_START ? SEASON_START - yd : YEAR_DAYS - yd + SEASON_START; +} + +/** + * swoopPos(launch, t) → [x, y, z] — PURE. `launch` is {ax,ay,az, tx,ty,tz, back:boolean}; `t` is + * 0..1 through the leg. Out along a parabola that crests above the player and drops onto the back of + * the head; back on a lazier, higher arc. No module state is read; no state is written. + */ +export function swoopPos(launch, t) { + const u = Math.max(0, Math.min(1, t)); + const { ax, ay, az, tx, ty, tz } = launch; + const x = ax + (tx - ax) * u; + const z = az + (tz - az) * u; + const lift = launch.back ? 2.6 : 1.25; + const y = ay + (ty - ay) * u + lift * 4 * u * (1 - u); // the arc, crest at u = 0.5 + return [x, y, z]; +} + +// ── the bird ──────────────────────────────────────────────────────────────────────────────────── +// Merged primitives with baked vertex colours: black body/head/wings, white nape, white wing bar, +// white rump. 154 triangles. One geometry, one material, one InstancedMesh. +const BLACK = new THREE.Color('#191a1c'), WHITE = new THREE.Color('#e6e4dd'), BILL = new THREE.Color('#b9bcc0'); +function paint(geo, color) { + const n = geo.attributes.position.count; + const c = new Float32Array(n * 3); + for (let i = 0; i < n; i++) { c[i * 3] = color.r; c[i * 3 + 1] = color.g; c[i * 3 + 2] = color.b; } + geo.setAttribute('color', new THREE.BufferAttribute(c, 3)); + return geo; +} +let _geo = null; +function birdGeometry() { + if (_geo) return _geo; + const parts = []; + // body — nose along −Z (three's forward), so a lookAt-style basis points the bird where it flies + parts.push(paint(new THREE.SphereGeometry(0.085, 8, 6).scale(1, 0.9, 1.75), BLACK)); + parts.push(paint(new THREE.SphereGeometry(0.055, 6, 5).translate(0, 0.035, -0.14), BLACK)); // head + parts.push(paint(new THREE.ConeGeometry(0.021, 0.08, 5).rotateX(-Math.PI / 2).translate(0, 0.03, -0.22), BILL)); + parts.push(paint(new THREE.BoxGeometry(0.052, 0.014, 0.055).translate(0, 0.06, -0.10), WHITE)); // nape + parts.push(paint(new THREE.BoxGeometry(0.11, 0.02, 0.13).translate(0, 0.055, 0.10), WHITE)); // rump/back + parts.push(paint(new THREE.BoxGeometry(0.05, 0.022, 0.30).translate(0, 0.0, 0.26), BLACK)); // tail + // wings: flat, spanning ±WING_SPAN in x — the `wing` wind weight is |x| / WING_SPAN + for (const s of [-1, 1]) { + parts.push(paint(new THREE.PlaneGeometry(WING_SPAN, 0.15).rotateX(-Math.PI / 2) + .translate(s * WING_SPAN / 2, 0.045, -0.01), BLACK)); + parts.push(paint(new THREE.PlaneGeometry(WING_SPAN * 0.42, 0.05).rotateX(-Math.PI / 2) + .translate(s * WING_SPAN * 0.72, 0.048, 0.03), WHITE)); // the wing bar + } + for (const p of parts) { p.deleteAttribute('uv'); if (!p.attributes.normal) p.computeVertexNormals(); } + _geo = mergeGeometries(parts, false); + parts.forEach((p) => p.dispose()); + return _geo; +} + +let _mat = null; +function birdMaterial() { + if (_mat) return _mat; + _mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.62, metalness: 0, side: THREE.DoubleSide }); + applyWind(_mat, { topY: WING_SPAN, mode: 'wing' }); // R38: the |x|-weighted beat; exactly 0 at dead calm + return _mat; +} + +/** + * createMagpie({ scene, plan, citySeed, camera, chunks, force }) → { group, update, dispose, state } + * `force` (?magpie=1) forces the season open. The shell never constructs this under ?classic=1 or + * ?magpie=0, so both are byte-identical by absence. + */ +export function createMagpie({ scene, plan, citySeed, townKey = null, camera, chunks, lighting, force = false }) { + const group = new THREE.Group(); group.name = 'magpie'; + const state = { + territories: 0, roadKm: 0, active: null, mode: 'absent', swoopsToday: 0, + gotYouToday: false, day: 1, dayRolls: 0, inSeason: false, forced: !!force, launches: 0, + // Diagnostics so "is there a magpie in this town?" is answerable without a 200-day soak. + townKey, seasonOffset: seasonOffset(citySeed >>> 0, townKey), + get daysToSeason() { return daysToSeason(this.day, citySeed >>> 0, townKey); }, + }; + + // ── candidate territories, scaled to the graph ── + const edges = resolveEdges(plan); + let total = 0; + for (const e of edges) total += Math.hypot(e.bx - e.ax, e.bz - e.az); + state.roadKm = +(total / 1000).toFixed(2); + const want = Math.max(1, Math.min(TERRITORY_MAX, Math.round(total / TERRITORY_SPACING))); + // Walk the graph accumulating length and drop an anchor every `step` metres — length-proportional, + // so it is the ROAD GRAPH that sets the count, not the edge count (a real town's edges are short + // and numerous; the synthetic town's are few and long, and both must land somewhere sane). + const step = total / want; + const terr = []; + let acc = 0, next = step * 0.5; + for (const e of edges) { + const dx = e.bx - e.ax, dz = e.bz - e.az, len = Math.hypot(dx, dz) || 1; + const ux = dx / len, uz = dz / len, px = -uz, pz = ux; + while (next < acc + len && terr.length < TERRITORY_MAX) { + // HE PERCHES ON A POLE THAT EXISTS. The first cut put the anchor at an arbitrary distance + // along the edge and the bird hovered in open air over the road — every number green, the + // picture wrong, which is precisely R37's lesson and it took a screenshot to see it again. + // `furniture.js` plants streetlights at `s = 8, 26, 44, …` (every 18 m from 8), alternating + // sides starting at +1, offset `width/2 + 0.7` — so snap to the NEAREST of those and reuse the + // same side rule. Same literal in two files is the R27 trap, so if the cadence ever moves this + // is the second place to change; the alternative (reading live furniture instances) can't work + // because territories are chosen before the chunk that holds the pole is built. + const raw = next - acc; + const k = Math.max(0, Math.round((raw - 8) / 18)); + const s = Math.min(Math.max(8, 8 + k * 18), Math.max(8, len - 4)); + const side = (k % 2 === 0) ? 1 : -1; + const r = rng(citySeed >>> 0, 'magpie', terr.length); + const off = (e.width / 2 + 0.7) * side; + terr.push({ + // half a metre out along the lamp arm, which reaches over the road + x: e.ax + ux * s + px * off * 0.88, z: e.az + uz * s + pz * off * 0.88, + y: PERCH_Y, yaw: frange(r, -Math.PI, Math.PI), edgeId: e.id, poleS: s, + seed: (citySeed ^ (terr.length * 2654435761)) >>> 0, + }); + next += step; + } + acc += len; + } + state.territories = terr.length; + + const mesh = new THREE.InstancedMesh(birdGeometry(), birdMaterial(), 1); + mesh.name = 'magpie-bird'; + mesh.castShadow = false; mesh.receiveShadow = false; + mesh.frustumCulled = false; // one instance, moving fast, town-wide bounds — culling it is wrong + mesh.count = 0; // 0 ⇒ three returns before the GL call ⇒ exactly 0 draws + group.add(mesh); + scene.add(group); + + // ── proximity-first territory selection, refreshed on the R37 §0.4 chunk seam ── + let dirty = true, home = null; + const markDirty = () => { dirty = true; }; + const offBuilt = chunks && chunks.onChunkBuilt ? chunks.onChunkBuilt(markDirty) : () => {}; + const offGone = chunks && chunks.onChunkDisposed ? chunks.onChunkDisposed(markDirty) : () => {}; + function pickHome(px, pz) { + let best = null, bd = Infinity; + for (const t of terr) { const d = (t.x - px) ** 2 + (t.z - pz) ** 2; if (d < bd) { bd = d; best = t; } } + home = best; state.active = best ? { x: +best.x.toFixed(2), z: +best.z.toFixed(2), edgeId: best.edgeId } : null; + } + + // ── the swoop ── + let phase = 'perched', tPhase = 0, cool = 0, launch = null; + const _m = new THREE.Matrix4(), _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3(1, 1, 1); + const _fwd = new THREE.Vector3(), _prev = new THREE.Vector3(), _look = new THREE.Matrix4(); + const _upv = new THREE.Vector3(0, 1, 0); + let lastPos = null; + + function seasonOpen() { + const day = (typeof window !== 'undefined' && window.PROCITY && window.PROCITY.game) + ? (window.PROCITY.game.day | 0) : 1; + if (day !== state.day) { + // THE DAY ROLL CLEARS THE LATCH. No storage is touched here or anywhere in this file. + // It also puts him back on his pole: you slept, it is dawn, and a swoop cannot be "in + // progress" across a night. Without that reset the latch clears and is then re-set half a + // second later by the leg that was already in the air — which is exactly what the first run + // of this measurement showed (`swoopsToday` 1 → 0 but `gotYouToday` still true), and it would + // have made F's gate read as a failed clear when the clear had in fact worked. + state.day = day; state.gotYouToday = false; state.swoopsToday = 0; state.dayRolls++; + phase = 'perched'; tPhase = 0; cool = COOLDOWN; launch = null; lastPos = null; + } + state.inSeason = force || inSwoopSeason(day, citySeed >>> 0, townKey); + return state.inSeason; + } + + // `folded` squashes the instance in X. The wings are baked into the one shared geometry (they have + // to be — one geometry is what makes this 1 draw), so there is no pose to switch to; scaling the + // ±0.34 m wing planes to ±0.14 m reads as a bird sitting with its wings in, which is what a + // perched magpie looks like and what the first screenshot showed it emphatically was not. + function place(x, y, z, fx, fz, folded = false) { + _s.set(folded ? 0.42 : 1, 1, 1); + _p.set(x, y, z); + _fwd.set(fx, 0, fz); + if (_fwd.lengthSq() < 1e-6) _fwd.set(0, 0, 1); + _fwd.normalize(); + _look.lookAt(new THREE.Vector3(0, 0, 0), _fwd, _upv); + _q.setFromRotationMatrix(_look); + mesh.setMatrixAt(0, _m.compose(_p, _q, _s)); + mesh.instanceMatrix.needsUpdate = true; + mesh.count = 1; + } + + function update(dt) { + if (!terr.length) { mesh.count = 0; state.mode = 'absent'; return; } + const p = camera.position; + if (dirty || !home) { dirty = false; pickHome(p.x, p.z); } + if (!seasonOpen()) { mesh.count = 0; state.mode = 'out-of-season'; return; } + // He roosts at night like every other magpie. + if (lighting && lighting.isNight && lighting.isNight()) { mesh.count = 0; state.mode = 'roosting'; return; } + const d = Math.hypot(home.x - p.x, home.z - p.z); + if (d > DEFEND_R * 2.2) { mesh.count = 0; state.mode = 'far'; return; } // not even in sight + + cool = Math.max(0, cool - dt); + if (phase === 'perched') { + state.mode = 'perched'; + place(home.x, home.y, home.z, Math.cos(home.yaw), Math.sin(home.yaw), true); + if (d < DEFEND_R && cool <= 0) { + // launch: the target is the player's position AT LAUNCH, frozen — so the whole leg stays a + // pure function of three recorded numbers rather than of a live camera. + launch = { ax: home.x, ay: home.y, az: home.z, + tx: p.x, ty: Math.max(1.15, p.y - 0.35), tz: p.z, back: false }; + phase = 'out'; tPhase = 0; state.launches++; state.swoopsToday++; + try { window.PROCITY?.audio?.playSfx?.('magpie-carol', { gain: 1 }); } catch { /* silent-and-happy */ } + } + lastPos = null; + return; + } + tPhase += dt; + const dur = phase === 'out' ? SWOOP_T : RETURN_T; + const u = Math.min(1, tPhase / dur); + const [x, y, z] = swoopPos(launch, u); + if (lastPos) place(x, y, z, x - lastPos[0], z - lastPos[2]); + else place(x, y, z, launch.tx - launch.ax, launch.tz - launch.az); + lastPos = [x, y, z]; + state.mode = phase === 'out' ? 'swooping' : 'returning'; + if (phase === 'out' && u >= 0.62 && !state.gotYouToday) state.gotYouToday = true; // runtime latch only + if (u >= 1) { + if (phase === 'out') { + launch = { ax: launch.tx, ay: launch.ty, az: launch.tz, tx: home.x, ty: home.y, tz: home.z, back: true }; + phase = 'back'; tPhase = 0; lastPos = null; + } else { phase = 'perched'; cool = COOLDOWN; lastPos = null; } + } + } + + function dispose() { + offBuilt(); offGone(); + mesh.dispose && mesh.dispose(); + group.remove(mesh); + scene.remove(group); + // geometry + material are module-shared singletons (one town per session) — not disposed here + } + + return { group, update, dispose, state, swoopPos, get count() { return mesh.count; }, + get territories() { return terr; } }; +} diff --git a/web/js/world/skins.js b/web/js/world/skins.js index 761421c..092deb9 100644 --- a/web/js/world/skins.js +++ b/web/js/world/skins.js @@ -13,7 +13,13 @@ const ASSET_BASE = 'assets/gen/'; // ── muted 90s-AU fallback palettes (used until — or instead of — the JPEG skin) ── const FACADE_FALLBACK = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab89e', '#b8a890', '#bfae96']; +// `keyword()` takes the FIRST regex that matches, so the R38 entries are prepended, not appended: +// `grass-dry` and `grass-temperate` both match the old bare /grass/ and would otherwise fall back to +// the same mid-green as the v1 skin — which is the one colour neither of them is. `coastal-sand` +// matched nothing at all and would have fallen to the generic grey, and this is a town's WHOLE base +// plane, so it is not a detail. Only reached when the JPEG is absent (?noassets, or a 404). const GROUND_FALLBACK = [ + [/grass-dry|dry-grass/, '#a89a72'], [/grass-temperate|temperate/, '#4e6d3e'], [/sand|coastal/, '#c9c1a6'], [/asphalt|road/, '#4a4a46'], [/footpath|djsim-footpath/, '#8b857a'], [/grass/, '#5d6b45'], [/brickpave/, '#8a6a58'], [/gravel/, '#9a948a'], [/reddust|dust/, '#a8664a'], ]; @@ -117,12 +123,23 @@ export function createSkinLibrary({ assetBase = ASSET_BASE } = {}) { // Ground materials tile via RepeatWrapping; ground.js bakes the tile scale into the mesh UVs, // so one shared material serves strips of any length. - function groundMat(name) { - const key = `ground:${name}`; + // + // [R38 §1.2 — the per-town character vector] `tint` is an OPTIONAL per-class multiplier (three's + // own `color × map` path, zero extra state) so 23 towns can differ in ground palette without new + // assets, new meshes or new draws. It is threaded through the CACHE KEY, and a null tint keeps the + // key exactly `ground:` — so the classic/synthetic boot asks for, and gets, the same cached + // material object it has had since v1. Byte-identical by construction, not by flag. + // A tinted material still shows the flat class fallback while (or instead of) the JPEG loads: the + // fallback colour is pre-multiplied by the tint, so ?noassets and a 404 both land somewhere sane + // rather than reverting to the untinted grey (skins house law, unchanged). + function groundMat(name, tint = null) { + const key = tint ? `ground:${name}:${tint}` : `ground:${name}`; let m = matCache.get(key); if (m) return m; - m = new THREE.MeshStandardMaterial({ color: new THREE.Color(keyword(name, GROUND_FALLBACK, '#6b6b64')), roughness: 0.97, metalness: 0 }); - const t = skinTex(`${assetBase}ground-${name}.jpg`, { repeat: [1, 1], onReady: (tx) => { m.map = tx; m.color.set(0xffffff); m.needsUpdate = true; } }); + const flat = new THREE.Color(keyword(name, GROUND_FALLBACK, '#6b6b64')); + if (tint) flat.multiply(new THREE.Color(tint)); + m = new THREE.MeshStandardMaterial({ color: flat, roughness: 0.97, metalness: 0 }); + const t = skinTex(`${assetBase}ground-${name}.jpg`, { repeat: [1, 1], onReady: (tx) => { m.map = tx; m.color.set(tint || 0xffffff); m.needsUpdate = true; } }); t.wrapS = t.wrapT = THREE.RepeatWrapping; matCache.set(key, m); disposables.push(m); return m; diff --git a/web/js/world/washing.js b/web/js/world/washing.js new file mode 100644 index 0000000..5b0b743 --- /dev/null +++ b/web/js/world/washing.js @@ -0,0 +1,198 @@ +// PROCITY Lane B — washing.js (v8 WAVE 1 §1.4 — the washing on the Hills Hoist) +// +// The rest of §1.4 (fence, hoist, aerial, tank) lives inside `buildings.js`'s existing `boxMats` +// InstancedMesh and costs **+0 draws**. The washing cannot: a sheet is a flat quad with a fabric +// map, `boxMats` is a BoxGeometry sharing ONE untextured shell material with every building in the +// chunk, and per-instance textures do not exist. So the washing needs a mesh of its own — and this +// is where the brief's shape and the budget law disagree, loudly: +// +// • A per-chunk washing mesh is "+1 draw" the way a per-chunk anything is: **+1 × up to 31 live +// chunks at the dispose window = up to +31 draws** against a pinned margin of **8**. That is not +// a rounding error, it is 4× the entire remaining budget. +// • **ONE TOWN-WIDE `InstancedMesh` is 1 draw at any N** (v8 law, charter §Laws). So the washing +// is town-wide, capped at the settled shared cap of **256**, spent **proximity-first**, and +// refilled off `chunks.onChunkBuilt` / `onChunkDisposed` — the seam R37 §0.4 wired precisely so +// a v8 standing layer would not have to invent its own. +// +// **FREE WHEN EMPTY, and to the vendored line rather than "almost".** `three.module.js:2193-2199` +// returns before the GL call when an InstancedMesh's count is 0, and `render.calls++` lives +// downstream at `:4094`. So: all 21 real towns (zero house/yard lots — 100% `use:'shop'`, charter +// ruling 2) pay **exactly 0**, and so does any night, because the washing comes in after dark. +// +// **THE SWAY IS THE WHOLE POINT AND THE SHIPPED WEIGHT WAS UPSIDE DOWN.** `wind.js`'s v3.2 weight is +// `wH = clamp(y/4.4,0,1); wH *= wH` — top-weighted, base planted, correct for a gum. A sheet is the +// other way round: pegged at the top, free at the hem. Under the canopy weight a 1.6 m line of +// washing moves 0.1–0.7 cm AT THE HEM (and most at the pegs, which is backwards). This layer runs +// `wind.js`'s new `pegged` mode instead — hem weight 1, peg weight 0 — measured at **4.5 cm calm / +// 31 cm gusty**, i.e. ~52× the shipped weight and in the right place. Amplitude still rides the ONE +// shared `uWindAmp`, so at dead calm (?weather=0, ?classic=1) the displacement is exactly `* 0.0`. +// +// PROCEDURAL-FIRST: the fabric is a canvas weave (no fetch, `?noassets`-clean by construction). When +// Lane E's asset wave lands a garment atlas, this becomes a `map` swap + a per-instance UV rect — +// still ONE mesh, still ONE draw. + +import * as THREE from 'three'; +import { rng, frange } from '../core/prng.js'; +import { hoistAnchors } from './buildings.js'; +import { applyWind } from './wind.js'; + +// The shared standing-layer instance cap settled in R37 §0.3. One number, written down once. +export const WASHING_CAP = 256; +const PEG_DROP = 1.0; // local height of the quad: pegs at y=1, hem at y=0 (the `pegged` topY) + +// 90s Australian laundry, not a fashion range: sheets, towels, school uniforms, tea towels. +const GARMENTS = [ + { w: 1.15, h: 1.25, tints: ['#e8e4dc', '#dfe4ea', '#e6ded0'] }, // sheet / pillowcase + { w: 0.62, h: 0.95, tints: ['#c8683f', '#3f6f8c', '#7a9a52', '#c8b04a'] }, // towel + { w: 0.54, h: 0.78, tints: ['#e2e2e6', '#8ea8c4', '#d8d2c0'] }, // shirt + { w: 0.42, h: 0.48, tints: ['#dcd6c6', '#b8c8b0', '#e0cfc0'] }, // tea towel / smalls +]; + +// A plain woven-cloth texture: warp/weft noise + a soft vertical shadow so a hanging quad reads as +// cloth rather than as a coloured card. Canvas only — no fetch, no manifest, no ?noassets branch. +let _tex = null; +function fabricTexture() { + if (_tex) return _tex; + const c = document.createElement('canvas'); c.width = c.height = 64; + const x = c.getContext('2d'); + x.fillStyle = '#ffffff'; x.fillRect(0, 0, 64, 64); + let s = 0x9e3779b9; + const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); + for (let i = 0; i < 64; i++) { // weave + x.fillStyle = `rgba(0,0,0,${0.03 + rnd() * 0.05})`; + x.fillRect(0, i, 64, 1); + x.fillStyle = `rgba(255,255,255,${0.04 + rnd() * 0.05})`; + x.fillRect(i, 0, 1, 64); + } + const g = x.createLinearGradient(0, 0, 0, 64); // hangs darker at the fold, lighter at the hem + g.addColorStop(0, 'rgba(0,0,0,0.20)'); g.addColorStop(0.35, 'rgba(0,0,0,0.04)'); g.addColorStop(1, 'rgba(255,255,255,0.06)'); + x.fillStyle = g; x.fillRect(0, 0, 64, 64); + _tex = new THREE.CanvasTexture(c); + _tex.colorSpace = THREE.SRGBColorSpace; _tex.generateMipmaps = false; _tex.minFilter = THREE.LinearFilter; + return _tex; +} + +let _mat = null; +function washingMaterial() { + if (_mat) return _mat; + _mat = new THREE.MeshStandardMaterial({ map: fabricTexture(), roughness: 0.94, metalness: 0, side: THREE.DoubleSide }); + applyWind(_mat, { topY: PEG_DROP, mode: 'pegged' }); // R38: the INVERTED weight (see header) + return _mat; +} + +/** + * createWashing({ scene, plan, citySeed, chunks, camera, lighting }) → { group, update, dispose, state } + * The shell constructs this only on a non-classic boot with ?washing≠0. Constructed on a town with + * no hoists it allocates nothing and never adds a mesh — so every real town is provably free. + */ +export function createWashing({ scene, plan, citySeed, chunks, camera, lighting }) { + const group = new THREE.Group(); group.name = 'washing'; + const state = { hoists: 0, items: 0, active: 0, cap: WASHING_CAP, rebuilds: 0, night: false }; + + // ── the item table: derived ONCE from buildings.js's own yardLayout, never re-invented ── + // hoistAnchors() is a pure function of (plan, citySeed) that reads the SAME `yardLayout` the + // geometry is built from, so a peg can never end up where no arm is. + const anchors = hoistAnchors(plan, citySeed >>> 0); + state.hoists = anchors.length; + const items = []; + for (const a of anchors) { + const r = rng(a.seed, 'washing', 0); + // ~90% of hoists have something on them; a few are bare, because some are. + const n = r() < 0.10 ? 0 : 1 + Math.floor(r() * 4); + for (let i = 0; i < n; i++) { + const g = GARMENTS[Math.floor(r() * GARMENTS.length)]; + const armIdx = Math.floor(r() * 4); + const armYaw = a.yaw + armIdx * (Math.PI / 2); + const rad = frange(r, 0.35, Math.max(0.4, a.arm * 0.92)); + const sx = frange(r, 0.85, 1.18), sy = frange(r, 0.82, 1.15); + items.push({ + x: a.x + Math.cos(armYaw) * rad, + z: a.z + Math.sin(armYaw) * rad, + y: a.y - 0.06, // the pegs sit just under the arm + yaw: armYaw + Math.PI / 2, // the sheet hangs across the arm + sx: g.w * sx, sy: g.h * sy, + color: new THREE.Color(g.tints[Math.floor(r() * g.tints.length)]), + }); + } + } + state.items = items.length; + + let mesh = null, geo = null; + const _m = new THREE.Matrix4(), _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3(); + const _up = new THREE.Vector3(0, 1, 0); + if (items.length) { + // unit quad, pegs at y = PEG_DROP, hem at y = 0 — which is what the `pegged` wind weight expects + geo = new THREE.PlaneGeometry(1, PEG_DROP).translate(0, PEG_DROP / 2, 0); + mesh = new THREE.InstancedMesh(geo, washingMaterial(), Math.min(items.length, WASHING_CAP)); + mesh.name = 'washing-layer'; + mesh.castShadow = false; mesh.receiveShadow = false; + mesh.frustumCulled = false; // one town-wide mesh: its bounds are the whole town, so culling it + // whole is never right and computing them each refill is wasted work + mesh.count = 0; // nothing until the first refill ⇒ boots at 0 draws + group.add(mesh); + scene.add(group); + } + + // ── proximity-first spending ──────────────────────────────────────────────────────────────── + // The cap is spent on the NEAREST items, so a 188 km-of-road town spends its 256 where the player + // is rather than on a suburb 6 km away. Refills are event-driven (the R37 §0.4 chunk seam) plus a + // movement guard, never per-frame: an InstancedMesh matrix rewrite is cheap but not free. + let dirty = true, lastX = 1e9, lastZ = 1e9; + const markDirty = () => { dirty = true; }; + const offBuilt = chunks && chunks.onChunkBuilt ? chunks.onChunkBuilt(markDirty) : () => {}; + const offGone = chunks && chunks.onChunkDisposed ? chunks.onChunkDisposed(markDirty) : () => {}; + + const order = items.map((_, i) => i); + function refill(px, pz) { + if (!mesh) return; + // The washing comes in at night — and an empty count is a REAL zero (the vendored early-return), + // so the whole layer costs nothing between dusk and dawn. + const night = !!(lighting && lighting.isNight && lighting.isNight()); + state.night = night; + if (night) { mesh.count = 0; state.active = 0; return; } + order.sort((a, b) => { + const ia = items[a], ib = items[b]; + return ((ia.x - px) ** 2 + (ia.z - pz) ** 2) - ((ib.x - px) ** 2 + (ib.z - pz) ** 2); + }); + const n = Math.min(order.length, WASHING_CAP); + for (let i = 0; i < n; i++) { + const it = items[order[i]]; + // THE PEGS GO ON THE LINE, and the drop is per-instance. The unit quad spans local y 0..1 with + // the pegs at 1, so the instance origin must sit `sy` below the arm — NOT `PEG_DROP` below it. + // The first cut used the unscaled constant, which hung every garment with its pegs at + // `arm − 1 + sy`: a tea towel floated 0.5 m under the line and a whole street of washing was + // *invisible behind its own 1.75 m back fence* from any angle that could see the yard. The + // instance count, the draw count and the cap were all green while nothing could be seen. + _p.set(it.x, it.y - it.sy, it.z); + _q.setFromAxisAngle(_up, it.yaw); + _s.set(it.sx, it.sy, 1); + mesh.setMatrixAt(i, _m.compose(_p, _q, _s)); + if (mesh.instanceColor !== undefined) mesh.setColorAt(i, it.color); + } + mesh.count = n; + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + state.active = n; + state.rebuilds++; + } + + function update() { + if (!mesh) return; + const p = camera.position; + const moved = (p.x - lastX) ** 2 + (p.z - lastZ) ** 2; + const nightNow = !!(lighting && lighting.isNight && lighting.isNight()); + if (!dirty && moved < 24 * 24 && nightNow === state.night) return; + dirty = false; lastX = p.x; lastZ = p.z; + refill(p.x, p.z); + } + + function dispose() { + offBuilt(); offGone(); + if (mesh) { mesh.dispose && mesh.dispose(); group.remove(mesh); } + if (geo) geo.dispose(); + scene.remove(group); + // the material + fabric texture are module-shared (one town per session) — not disposed here + } + + return { group, update, dispose, state, get count() { return mesh ? mesh.count : 0; } }; +} diff --git a/web/js/world/wind.js b/web/js/world/wind.js index c20448d..0639410 100644 --- a/web/js/world/wind.js +++ b/web/js/world/wind.js @@ -21,10 +21,54 @@ export const windUniforms = { uWindAmp: { value: 0 }, // 0 = dead calm ⇒ zero displacement ⇒ byte-identical (weather off / classic) }; -// Patch a material's vertex program: sway top vertices horizontally (top-weighted, base planted). A -// per-instance phase from world position desyncs neighbouring trees. Guarded for instanced use so the -// same material would still compile if ever drawn non-instanced. -export function applyWind(material, { topY = 4.4 } = {}) { +// ── R38 §1.4: THE WEIGHT IS UPSIDE DOWN FOR ANYTHING PEGGED AT THE TOP ────────────────────────── +// The `canopy` weight below is `wH = clamp(y/topY,0,1); wH *= wH` — top-weighted, base planted. +// That is correct for a gum and **exactly wrong for a sheet on a Hills Hoist**, which is pegged at +// the top and free at the hem. Measured on the shipped numbers: a sheet hanging 0.6–1.6 m under the +// canopy weight gets wH = (1.6/4.4)² = 0.132 at its TOP and 0.019 at its hem, so with the shipped +// amplitude (`updateWind`: 0.05 calm → 0.35 gusty) the free end of the washing moves +// **0.10 cm calm / 0.67 cm gusty** and the pegged end moves the most — visually inert, and inverted. +// The `pegged` mode weights by distance BELOW the peg line instead: wH = 1 at the hem, 0 at the +// pegs ⇒ **5.0 cm calm / 35 cm gusty at the hem, a 52× gain**, and the top stays put because that +// is where the pegs are. It also flutters faster and adds a Z-flap, because a sheet is a sail and a +// gum is a spring. (Fable's R38 binding correction 6, taken as the "write the mode" branch.) +// +// `wing` (§1.3, the magpie) is the third weight: |x| from the body centreline, displacement in Y, +// so a wing beats and a body does not. Still on uWindAmp ⇒ still exactly 0 at dead calm. +// +// **The `canopy` string is byte-for-byte what it was pre-R38**, so the gum-tree program compiles to +// the same source it always has and the covenant argument for trees is unchanged. +const MODES = { + canopy: (topY) => [ + `float wH = clamp(position.y / ${topY.toFixed(1)}, 0.0, 1.0); wH *= wH;`, // top-weighted, base planted + 'float wS = sin(uWindTime + wPhase) + 0.4 * sin(uWindTime * 2.3 + wPhase * 1.7);', + 'transformed.x += wS * uWindAmp * wH;', + 'transformed.z += cos(uWindTime * 0.8 + wPhase) * uWindAmp * wH * 0.5;', + ], + // Pegged at the TOP of the local geometry (y ∈ [0, topY], pegs at topY): free at the hem. + pegged: (topY) => [ + `float wH = clamp((${topY.toFixed(2)} - position.y) / ${topY.toFixed(2)}, 0.0, 1.0); wH *= wH;`, + 'float wS = sin(uWindTime * 2.1 + wPhase) + 0.5 * sin(uWindTime * 4.7 + wPhase * 2.3);', + 'transformed.x += wS * uWindAmp * wH;', + 'transformed.z += sin(uWindTime * 3.3 + wPhase * 1.3) * uWindAmp * wH * 0.75;', + 'transformed.y -= abs(wS) * uWindAmp * wH * 0.12;', // a lifting sheet also shortens + ], + // Wing beat: weighted by span from the body centreline, displaced in Y. + wing: (topY) => [ + `float wH = clamp(abs(position.x) / ${topY.toFixed(2)}, 0.0, 1.0);`, + 'float wS = sin(uWindTime * 9.0 + wPhase);', + 'transformed.y += wS * uWindAmp * wH * 3.0;', + 'transformed.x -= abs(wS) * uWindAmp * wH * 0.35 * sign(position.x);', + ], +}; + +// Patch a material's vertex program: displace vertices by a mode-specific weight × the shared +// amplitude. A per-instance phase from world position desyncs neighbours. Guarded for instanced use +// so the same material would still compile if ever drawn non-instanced. +// `topY` is the mode's reference length: canopy → tree height, pegged → drop below the pegs, +// wing → half-span. +export function applyWind(material, { topY = 4.4, mode = 'canopy' } = {}) { + const body = (MODES[mode] || MODES.canopy)(topY); material.onBeforeCompile = (shader) => { shader.uniforms.uWindTime = windUniforms.uWindTime; shader.uniforms.uWindAmp = windUniforms.uWindAmp; @@ -37,10 +81,7 @@ export function applyWind(material, { topY = 4.4 } = {}) { '#ifdef USE_INSTANCING', ' wPhase = instanceMatrix[3].x * 0.15 + instanceMatrix[3].z * 0.15;', '#endif', - `float wH = clamp(position.y / ${topY.toFixed(1)}, 0.0, 1.0); wH *= wH;`, // top-weighted, base planted - 'float wS = sin(uWindTime + wPhase) + 0.4 * sin(uWindTime * 2.3 + wPhase * 1.7);', - 'transformed.x += wS * uWindAmp * wH;', - 'transformed.z += cos(uWindTime * 0.8 + wPhase) * uWindAmp * wH * 0.5;', + ...body, ].join('\n') ); };