Lane B R38 (v8 WAVE 1, INHABITATION): all four runtime items — the whole round costs +1 walked draw
The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it, a magpie owns one street for six weeks of the year, and six point sources put a life off-screen. MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`, :8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre spine steps at yaw 0, each frame fully streamed, through the shell's own composer: worst draws 224 -> 225 (+1) worst tris 89,502 -> 93,298 (+3,796 of ~75k spare) sum draws over 102 frames 16,676 -> 16,663 (-13) The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time. `?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw / +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws. §1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION. `townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key, so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path. The 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, and both are selected now. E's three new grounds landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for. §1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a yard; 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 (the lot rect is where the fence stands). THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37 chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin. Zero on every real town (no hoists ⇒ no mesh) and zero at night. §1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never mentions it. `swoopPos()` is exported and pure. The hat stays cut. MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on (citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%. §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 into the notes. 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 onto shopfronts. The falsifiability control — one manifest key renamed away, served from a third port — makes that source report `no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors. 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, and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to furniture.js's own streetlight rule. LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The `magpie` GLB is HELD FOR THE TINT: it 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, at a fifth of the triangles and no fetch. furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000. Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38. 0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets, magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones. Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
@ -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
|
||||
|
||||
@ -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:<name>` — 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
|
||||
|
||||
BIN
docs/shots/laneB_r38/01_backyard_hoist_washing.jpg
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
docs/shots/laneB_r38/03_residential_row_over.jpg
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
docs/shots/laneB_r38/04_residential_street_front.jpg
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
docs/shots/laneB_r38/05_magpie_perched.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
docs/shots/laneB_r38/06_magpie_swooping.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
docs/shots/laneB_r38/07_character_darwin_reddust.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
docs/shots/laneB_r38/08_character_hobart_temperate.jpg
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/shots/laneB_r38/09_character_bendigo_grassdry.jpg
Normal file
|
After Width: | Height: | Size: 138 KiB |
@ -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
|
||||
|
||||
@ -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-<genreKey>)
|
||||
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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
240
web/js/world/character.js
Normal file
@ -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:<name>`), 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'}`;
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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; } };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
333
web/js/world/magpie.js
Normal file
@ -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; } };
|
||||
}
|
||||
@ -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:<name>` — 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;
|
||||
|
||||
198
web/js/world/washing.js
Normal file
@ -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; } };
|
||||
}
|
||||
@ -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')
|
||||
);
|
||||
};
|
||||
|
||||