Lane B (Streetscape) round 13: v3.0-beta the district — N-venue frontage + night pass + posters + queue zone (?gigs=1)

Generalises the R12 one-pub slice to all 2-4 venues and lands the night-readability polish. All
gig-gated (flags-off byte-identical, verified in a fresh browser context). Files: venue.js, audio.js, dbg.js.

- venue.js -> N venues: per-venue lit marquee/frontage on B's +Z facade side + window-light spill;
  streetlamp pools on venue blocks (one instanced warm-pool mesh, night-driven -> the Friday-night
  walk reads); town-wide posters instanced per gig (14 posters = 2 draws / 2 textures, band-name
  overprint, flat printed-poster fallback). Town-wide pools deferred (would need always-on
  furniture.js -> breaks flags-off parity).
- Queue-zone marker per venue: venue.queueZone = {x,z,ry,len} for Lane D (head at the door, extends
  along the frontage; queuer faces -ry). queueZones{} + queueZoneFor(id).
- audio.js muffled spill per-genre (debt #1): import gigKeyFor; the nearest venue at doors/on within
  26 m spills its gig-<genreKey> through the 470 Hz lowpass, so pub/band_room/RSL crossfade genre.
  F: delete the GIG_BED bridge.
- update(gigStates) polymorphic: per-venue map {[venueShopId]:state} (F wires PROCITY.gigs.byVenue)
  or alpha-compat bare string (primary pub). Contract documented for F in LANE_B_NOTES.
- ?noassets fetch fix (latent R12 bug): gate the poster JPEG fetch behind !noassets -> 0 gig-layer
  network over the 22-JPEG facade/ground baseline.
- dbg.js: venue_night + district_posters bookmarks (seg 5) so tools/qa/gig_shot.py scripts the
  street money shot; poseForVenue resolves from the live plan.

Budget (fresh Chromium, gigs-on NIGHT, busiest pub block, all frontages lit): worst 154 scene /
~167 total draws, 64k tris -- <=300 / <=200k. 0 console errors (gigs / gigs+noassets / flags-off).
Verified 3 venues seed 20261990: pub->gig-pubrock, RSL->gig-covers, band_room->gig-grunge (all in
E's manifest).

Finding for Lane A: buildPosters item 1 seats each venue-frontage poster on the lot -Z (back wall,
measured 15.9 m from the +Z street facade) -- flip to +sin,+cos. Spine/pole posters render correctly
and face the footpath ("SCREAMING UTES" readable). Non-blocking (marquee + pools carry the shot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 23:11:10 +10:00
parent 82b711f962
commit 46127c7e95
4 changed files with 380 additions and 88 deletions

View File

@ -1,5 +1,95 @@
# LANE B — NOTES (measured)
## Round 13 (Fable's ROUND13 → Lane B) — the district reads by night (`?gigs=1`, v3.0-beta)
Four B pieces, all gig-gated (flags-off byte-identical, verified). Files: `venue.js` (rewrite),
`audio.js` (spill), `dbg.js` (bookmarks). Verified in a **fresh browser context** (the stale-module
trap bit me once here — the pre-warmed tab served R12 `venue.js`; a `cache:'reload'` + hard nav fixed
it, proof = the returned object's key set flipped from R12 `{frontage,venueShopId}` to R13
`{venues,venueShopIds,queueZones,…}`). Seed 20261990 = **Boolarra Heads, 3 venues**: The Exchange
Hotel (pub/pubrock, id116), Wangaratta RSL (rsl/covers, id404), The Vinyl Shed (band_room/grunge,
id490); 14 gigs/week, tonight **Screaming Utes @ pub $10** + **The Dead Ringers @ RSL free**.
### 1. `venue.js` generalised to N venues — `createVenuePresentation(plan, skins, scene)`
Now renders **every** `plan.shops` with `s.venue`, not just the pub. Returns
`{ group, venues[], update(gigStates), dispose, venueShopId (primary/alpha compat), venueShopIds[],
queueZones{}, queueZoneFor(id), frontage (primary, compat), posterCount, posterDraws }`.
- **Frontage per venue** — warm marquee slab + emissive bulb row over the door, on B's **+Z facade
side** (`sin ry, cos ry`; buildings.js canon), plus a **window-light spill** wash (additive, faint
night baseline + gig glow). Ramped per-venue by `update()`.
- **Streetlamp pools (the night-readability pass)** — one procedural warm radial sprite, **one
InstancedMesh for the whole district** (door pool + 2 footpath pools per venue). Night-driven (on at
NIGHT, off by day — streetlamps don't care about gigs), so venue blocks read on the Friday-night walk.
Scoped to venue blocks; **town-wide deferred on purpose** (would need always-on `furniture.js`
changes → breaks flags-off parity).
- **Posters, town-wide, instanced per gig.** A given gig's poster is identical wherever pasted, so B
groups `plan.posters` by `gigId`, picks ONE seeded skin per gig, and renders each group as a single
`InstancedMesh`**14 posters tonight = 2 draws / 2 canvas textures** (was 1 mesh + 1 canvas each).
Band name overprinted in the skin's `nameZone`; flat printed-poster fallback on 404 / `?noassets`.
- **`?noassets` fetch fix (latent R12 bug).** R12 `posterTexture` fetched `poster-*.jpg` even under
`?noassets`. R13 gates the fetch behind `!noassets` (read from the URL, like buildings.js `WINMAP`).
Measured `?noassets=1&gigs=1`: **0 poster fetch, 0 audio/manifest fetch** — the gig layer adds no
network over the documented 22-JPEG facade/ground baseline. Venue still builds (flat posters), no crash.
### 2. `audio.js` muffled spill → per-genre, per-venue (debt #1 closed on B's side)
The R12 spill hard-coded `pubrock-live`; E's rename made that a **silent bed** (band mimes). Now B
`import { gigKeyFor } from '../citygen/gigs.js'` (the ONE genre→bed map) and spills each venue's
`gig-<genreKey>`. Walks the venue list, the **nearest venue at doors/on within 26 m wins**, so
pub→band_room→RSL **crossfades the genre** through the same 470 Hz lowpass. Verified beds resolve to
E's manifest: pub→`gig-pubrock`, RSL→`gig-covers`, band_room→`gig-grunge` (all present). Audio engine
loads `live` with the new citygen import; flags-off it loads fine and the spill loop is skipped
(no `s.venue` shops). **F: delete the `GIG_BED` bridge — B no longer needs it.**
### 3. Queue-zone marker (for Lane D) — `venue.queueZone = { x, z, ry, len }`
Each venue publishes a queue zone: a subtle chalk-pale ground strip **beside the door** (past the
marquee edge, out on the footpath), running **along the frontage**. `queueZones` = `{ [venueShopId]:
zone }`; also `queueZoneFor(id)`. **Contract for D:** `(x,z)` = the head (front of queue, at the door
end); the line **extends** in `(sin ry, cos ry)` for `len` metres; a queuer **faces the door** =
`(sin ry, cos ry)`. D places poses at `head + (sin ry, cos ry)·s` for `s ∈ [0,len]`. Verified 3
zones seat correctly (pub head (30.9, 390.0) beside the door). D's fallback ("straight line from the
door") stays valid if the zone is absent.
### 4. `dbg.js` bookmarks — the street money shots (for `tools/qa/gig_shot.py`)
- **`venue_night`** (seg 5): stands 13 m in front of the **pub** frontage — lit marquee + streetlamp
pools + window spill + queue + posters, dark CLOSED neighbours behind. The street money shot.
- **`district_posters`** (seg 5): the spine at night, poster poles + lit frontages receding.
- New `poseForVenue(kind, dist)` resolves from the live plan (falls back to `streetViewPose` with no
gig layer, so flags-off shots don't crash).
### `update(gigStates)` — the shape to agree with F (F drives it every street frame)
`update` is **polymorphic**: pass `{ [venueShopId]: 'quiet'|'doors'|'on'|'done' }` (a plain object or
Map, or `{ byVenue: {…} }`) for **per-venue** frontage lighting; a **bare state string** is alpha
compat and lights **only the primary pub** (`plan.gigs[0].venueShopId`). **F R13 ask:** build
`window.PROCITY.gigs.byVenue = { [venueShopId]: { state, cover, paid, … } }` (keep the alpha single
object as a compat alias) and call `venuePresentation.update(PROCITY.gigs.byVenue)`. **audio.js reads
`PROCITY.gigs.byVenue[id].state` too** (falls back to the alpha alias, then a clock fallback from
`plan.gigs` night 0), so the spill works across the transition. Until F wires the map, only the pub
frontage marquee lights from the alpha string — **pools + baseline window glow (night-driven) light
all venue blocks regardless**, so the district still reads at night.
### Budget (measured, fresh Chromium, gigs-on, NIGHT, all frontages lit, busiest = pub block)
| view | scene draws | tris | gate |
|---|---|---|---|
| `venue_night` (composer total) | **96113** | 1932k | ≤300 / ≤200k ✅ |
| pub block 3/4 street (worst of 4 poses) | **154 scene (~167 total)** | **64k** | ✅ ~130 draw margin |
| `district_posters` (composer total) | 142 | — | ✅ |
Gig-night layer cost is tiny: pools **1 draw**, posters **≤2 draws** (instanced per gig), frontage
~3 draws/venue-in-frame. `?noassets=1&gigs=1` worst 176198 (post passes vary), still ≤300. **0
console errors** in every mode (gigs / gigs+noassets / flags-off).
### → Lane A (a finding for you — cross-lane, please flip one sign)
`buildPosters` **item 1** seats each venue's own frontage poster at `lot + (sin ry, cos ry)·(d/2)`
— the lot's **Z** side. But B's facades/doors are on **+Z** (buildings.js canon since round 1, and
the shipped town the player faces), so the venue-frontage poster lands on the **back wall**: measured
for the pub it's **15.9 m from the street facade, 0.07 m from the back**. The CITY_SPEC "front = Z"
poster convention disagrees with the building convention — a street-side sibling of debt #4's RY
clash. **Fix:** item 1 → `+sin, +cos` (the spine-run posters, which use the pole outward-normal
derivation, render correctly and face the footpath — verified "SCREAMING UTES" readable, not
mirrored). B renders A's `(x,z,ry)` verbatim (mesh oriented `ry+π` to face A's Z normal), so once A
flips, the pub gets its poster with no B change. Not blocking the money shot (marquee + pools carry it).
---
## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + the tram (`?tram=1`)
Two deliverables, both v2-law clean (default boot untouched, golden hash frozen, `qa.sh --strict` GREEN):

View File

@ -14,6 +14,8 @@
// Self-contained: reads live state off window.PROCITY and self-ticks via rAF, so the shell only has
// 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>)
const BASE = 'assets/';
// shop type → street-spill music key (manifest.music). Only these types leak a bed to the footpath;
@ -192,20 +194,33 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
const v = { lx: null, lz: null, stepAcc: 0, spillT: 0, tramX: null, tramZ: null, tramRang: false };
const STRIDE = 0.72, SPILL_R = 9, TRAM_R = 55, TRAM_BELL_R = 22, GIG_SPILL_R = 26;
// the pub venue (round 12), if the gig layer is present (?gigs); its lot carries the coords.
const venueShop = (PROCITY.plan?.shops || []).find((s) => s.venue) || null;
const venueLot = venueShop && (PROCITY.plan?.lots || []).find((l) => l.id === venueShop.lot);
// gig state for the muffled spill: prefer Lane F's window.PROCITY.gigs state machine, else derive
// from plan.gigs + the clock. Returns null with no gig layer (flags-off ⇒ no gig spill, byte-identical).
function gigStateNow() {
// the district's venues (round 13), if the gig layer is present (?gigs); each lot carries the coords
// and each shop its genreKey ⇒ gigKeyFor(genreKey) is the bed. Empty flags-off ⇒ no gig spill.
const venues = (PROCITY.plan?.shops || [])
.filter((s) => s.venue)
.map((s) => ({ shop: s, lot: (PROCITY.plan?.lots || []).find((l) => l.id === s.lot), genreKey: s.genreKey }))
.filter((v) => v.lot);
if (!venues.length) { // R12 fallback: the pub is the sole venue if A didn't flag venues
const pub = (PROCITY.plan?.shops || []).find((s) => s.type === 'pub');
const lot = pub && (PROCITY.plan?.lots || []).find((l) => l.id === pub.lot);
if (lot) venues.push({ shop: pub, lot, genreKey: pub.genreKey || 'pubrock' });
}
// Per-venue gig state for the muffled spill, robust across Lane F's alpha→district transition:
// 1. F R13 per-venue map window.PROCITY.gigs.byVenue[id].state
// 2. alpha alias window.PROCITY.gigs is the single machine → its state for its venueShopId
// 3. clock fallback plan.gigs (night 0) vs the current segment (works before/without F)
// Returns null only when there is no gig layer at all (flags-off ⇒ byte-identical, no spill).
function venueGigState(shopId) {
const g = PROCITY.gigs;
if (g && typeof g.state === 'string') return g.state;
if (g && typeof g.on === 'boolean') return g.on ? 'on' : 'quiet';
if (g && g.byVenue) { const e = g.byVenue[shopId]; if (e) return typeof e === 'string' ? e : (e.state || 'quiet'); }
if (g && g.venueShopId === shopId && typeof g.state === 'string') return g.state;
const gigs = PROCITY.plan?.gigs; if (!gigs || !gigs.length) return null;
const ton = gigs.find((x) => x.night === 0 && x.venueShopId === shopId);
if (!ton) return 'quiet'; // dark tonight — reads true
const clk = PROCITY.lighting?.getClock?.(); if (!clk) return 'quiet';
const s = gigs[0].startSeg ?? 5, e = gigs[0].endSeg ?? 5;
const s = ton.startSeg ?? 5, e = ton.endSeg ?? 5;
if (clk.seg >= s && clk.seg <= e) return 'on';
if (clk.seg === s - 1) return 'doors';
if (clk.seg === (s - 1 + 6) % 6) return 'doors';
return 'quiet';
}
@ -265,15 +280,23 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
ramp(L.spill.g.gain, 0, 0.4);
}
// ── muffled gig spill through the venue wall (round 12) — low-pass bed, reaches further than a
// door spill; only while the gig layer is on (?gigs) and a gig is at doors/on. Byte-identical off. ──
if (street && venueLot && P.plan?.gigs?.length) {
const gs = gigStateNow(), on = gs === 'on' || gs === 'doors';
const dist = Math.hypot(venueLot.x - _cam.x, venueLot.z - _cam.z);
const gg = (on && dist < GIG_SPILL_R)
? (AUDIO?.music?.['pubrock-live']?.gain ?? 0.6) * 0.55 * ((GIG_SPILL_R - dist) / GIG_SPILL_R) : 0;
state.gig = gs;
swapBed(L.gigSpill, gg > 0 ? 'pubrock-live' : null, gg > 0 ? AUDIO?.music?.['pubrock-live'] : null, gg, 0.9);
// ── muffled gig spill through the nearest active venue's wall (round 13, the district) — a low-pass
// bed PER GENRE (gig-<genreKey>), reaching further than a door spill; the loudest (nearest, at
// doors/on) venue wins, so walking pub→band_room→RSL crossfades the genre. Byte-identical off. ──
if (street && venues.length && P.plan?.gigs?.length) {
let bestGain = 0, bestKey = null, bestState = null;
for (let i = 0; i < venues.length; i++) {
const vn = venues[i];
const gs = venueGigState(vn.shop.id);
if (gs !== 'on' && gs !== 'doors') continue;
const dist = Math.hypot(vn.lot.x - _cam.x, vn.lot.z - _cam.z);
if (dist >= GIG_SPILL_R) continue;
const key = gigKeyFor(vn.genreKey || 'pubrock');
const gg = (AUDIO?.music?.[key]?.gain ?? 0.6) * 0.55 * ((GIG_SPILL_R - dist) / GIG_SPILL_R);
if (gg > bestGain) { bestGain = gg; bestKey = key; bestState = gs; }
}
state.gig = bestState;
swapBed(L.gigSpill, bestGain > 0 ? bestKey : null, bestGain > 0 ? AUDIO?.music?.[bestKey] : null, bestGain, 0.9);
} else if (L.gigSpill.key) { ramp(L.gigSpill.g.gain, 0, 0.6); }
// ── tram rumble (distance-gain) + bell (rings once as it settles at a stop nearby) ──

View File

@ -129,6 +129,14 @@ export function installDBG(deps) {
};
// nearest openLate (video) shop, for the night/rain money shots
const videoShop = () => (plan.shops || []).find((s) => s.hours && s.hours[1] >= 22);
// the gig-layer venues (?gigs): stand out in front so the lit frontage + queue + a poster all frame.
const venueShops = () => (plan.shops || []).filter((s) => s.venue);
const poseForVenue = (kind, dist = 13) => {
const vs = venueShops();
const shop = (kind ? vs.find((s) => (s.venueKind || s.type) === kind) : null) || vs[0]
|| (plan.shops || []).find((s) => s.type === 'pub');
return shop ? poseForShop(shop, dist) : null;
};
// Named bookmarks (LANE_F_NOTES §4). seg = day segment index (0 DAWN … 5 NIGHT).
// Predicates cover both Lane A's taxonomy (dept/stall/record/…) and the fixture's; each falls back
@ -153,6 +161,9 @@ export function installDBG(deps) {
rain_street: { seg: 2, resolve: () => streetViewPose(['main']) }, // ?weather=rain — wet strip, rain down the street
night_crowd: { seg: 5, resolve: () => { const v = videoShop(); return v ? poseForShop(v, 16) : streetViewPose(['main']); } }, // night video-shop draw (wider, shows the crowd)
shopfront_detail: { seg: 3, resolve: () => pickShop((s) => s.type === 'opshop' || s.type === 'record' || s.type === 'milkbar') }, // verandah + sign + window up close
// ── v3 gig district (round 13, ?gigs=1): the street money shots F scripts via tools/qa/gig_shot.py ──
venue_night: { seg: 5, resolve: () => poseForVenue('pub', 13) || streetViewPose(['main']) }, // ?gigs=1 — lit pub frontage + streetlamp pools + queue + posters at night
district_posters: { seg: 5, resolve: () => streetViewPose(['main']) }, // ?gigs=1 — the spine at night: poster poles + lit venue frontages receding
};
const DBG = {

View File

@ -1,90 +1,229 @@
// PROCITY Lane B — venue.js (round 12, v3.0-alpha gig layer, ?gigs=1)
// The street-side presentation of the pub venue: (1) gig POSTERS around town — E's poster skins with
// A's plan.posters placement and the band name overprinted like a shop sign; (2) the lit pub FRONTAGE
// at night while a gig is on — a warm marquee over the door + lit sign glow that F toggles from the
// gig state machine. Consumes A's plan.posters / plan.gigs / the venue shop. B owns the street side.
// PROCITY Lane B — venue.js (round 13, v3.0-beta THE DISTRICT, ?gigs=1)
// The street-side presentation of the town's 24 venues (pub / band_room / rsl). Round 13 generalises
// the R12 one-pub slice to the whole district and adds the night-readability polish:
// (1) town-wide gig POSTERS — E's poster skins at A's plan.posters positions, band name overprinted,
// instanced per gig so the whole town is a handful of draws;
// (2) a lit FRONTAGE per venue — warm marquee + bulb row over the door, ramped per-venue by F's gig
// state; window-light spill so the shopfront lifts out of the dark;
// (3) STREETLAMP POOLS on each venue block — warm pools of light on the footpath so the Friday-night
// walk reads (the money-shot polish). One instanced mesh, night-driven;
// (4) a QUEUE ZONE marker per venue, publishing `venue.queueZone = {x,z,ry,len}` for Lane D's line.
//
// Prime flag law: this module is ONLY constructed by the shell under ?gigs (like weather.js/tram.js),
// so flags-off boot is byte-identical — the shell never builds it. Asset law: a missing poster JPEG
// falls back to a flat printed-poster canvas (still a poster). Deterministic from citySeed.
// Prime flag law: this module is ONLY constructed by the shell under ?gigs (like weather.js/tram.js), so
// flags-off boot is byte-identical — the shell never builds it, and nothing here is on the always-on
// night path. Asset law: a missing poster JPEG (or ?noassets) falls back to a flat printed poster and
// fetches nothing. Deterministic from citySeed. B owns the street side of the gig seam.
//
// Orientation: a venue's facade/door is on the lot's local +Z (buildings.js canon: front = +Z), so the
// frontage, pools and queue seat on (sin ry, cos ry) — the STREET side. A's plan.posters carry their own
// ry under A's poster convention (front = local Z); posters are rendered at A's (x,z,ry) verbatim.
import * as THREE from 'three';
const POSTER_W = 1.05, POSTER_H = 1.5; // ~A2 bill on a pole/wall
const POSTER_SKINS = ['grunge', 'retro', 'screenprint', 'xerox'];
const NAME_ZONE = [0.08, 0.04, 0.84, 0.23]; // manifest skins.poster.*.nameZone (band name band)
const POSTER_SKINS = ['grunge', 'retro', 'screenprint', 'xerox']; // E's poster pack (assets/gen/poster-*.jpg)
const NAME_ZONE = [0.08, 0.04, 0.84, 0.23]; // manifest skins.poster.*.nameZone (band-name band)
export function createVenuePresentation(plan, skins, scene, { assetBase = 'assets/gen/' } = {}) {
const group = new THREE.Group(); group.name = 'venue-presentation';
const disposables = [];
const params = (() => { try { return new URLSearchParams(location.search); } catch { return new URLSearchParams(); } })();
const NOASSETS = params.get('noassets') != null && params.get('noassets') !== '0'; // house law: zero fetch
const gigs = plan.gigs || [];
const posters = plan.posters || [];
const venueShop = plan.shops?.find((s) => s.venue) || plan.shops?.find((s) => s.type === 'pub') || null;
const venueLot = venueShop && plan.lots.find((l) => l.id === venueShop.lot);
const lotById = new Map((plan.lots || []).map((l) => [l.id, l]));
const gigById = new Map(gigs.map((g) => [g.gigId, g]));
// tonight = night 0 (matches gig_state.js / A's poster selection). Primary venue = the alpha machine's
// gigs[0] venue, so a legacy single-string update() still lights the right frontage mid-round.
const primaryId = gigs.length ? gigs[0].venueShopId : null;
// ── posters ──
for (const p of posters) {
const gig = gigs.find((g) => g.gigId === p.gigId) || gigs[0];
const band = gig ? gig.bandName : 'LIVE TONIGHT';
const skin = POSTER_SKINS[(p.id + (plan.citySeed >>> 0)) % POSTER_SKINS.length]; // seeded template
const tex = posterTexture(assetBase, skin, band); disposables.push(tex);
const mat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.92, metalness: 0, side: THREE.DoubleSide });
const geo = new THREE.PlaneGeometry(POSTER_W, POSTER_H);
disposables.push(mat, geo);
const mesh = new THREE.Mesh(geo, mat);
// A's venue poster sits at the lot CENTRE (inside the shell); nudge it out to the frontage.
let px = p.x, pz = p.z;
if (venueLot && Math.hypot(p.x - venueLot.x, p.z - venueLot.z) < 1.2) {
const ry = venueLot.ry || 0, off = (venueLot.d || 8) / 2 + 0.08;
px = venueLot.x + Math.sin(ry) * off; pz = venueLot.z + Math.cos(ry) * off;
}
mesh.position.set(px, 2.0, pz);
mesh.rotation.y = p.ry || 0;
mesh.receiveShadow = true;
group.add(mesh);
const venueShops = (plan.shops || []).filter((s) => s.venue);
// R12 fallback (no A gig-layer venue flag): treat the pub as the sole venue.
if (!venueShops.length) {
const pub = (plan.shops || []).find((s) => s.type === 'pub');
if (pub) venueShops.push(pub);
}
// ── lit venue frontage (marquee over the door; emissive ramped by gig state) ──
const frontage = new THREE.Group(); frontage.name = 'venue-frontage';
let marqueeMat = null, signGlowMat = null;
if (venueLot) {
const ry = venueLot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry);
const zf = (venueLot.d || 10) / 2 + 0.06;
const mx = venueLot.x + fx * zf, mz = venueLot.z + fz * zf;
// ── town-wide posters, instanced per gig (one draw + one texture per advertised gig) ──────────────
// A given gig's poster looks the same wherever it's pasted, so we group A's poster positions by gigId,
// pick ONE seeded skin per gig, and render each group as a single InstancedMesh. Town-wide the whole
// poster layer is ≤ (tonight's playing venues) draws instead of one mesh + one canvas per bill.
let posterDraws = 0;
{
const byGig = new Map();
for (const p of posters) {
const gid = p.gigId != null ? p.gigId : -1;
if (!byGig.has(gid)) byGig.set(gid, []);
byGig.get(gid).push(p);
}
const _m = new THREE.Matrix4(), _pos = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3(1, 1, 1), _up = new THREE.Vector3(0, 1, 0);
for (const [gid, list] of byGig) {
const gig = gigById.get(gid) || gigs[0] || null;
const band = gig ? gig.bandName : 'LIVE TONIGHT';
const skin = POSTER_SKINS[(((gid | 0) + (plan.citySeed >>> 0)) % POSTER_SKINS.length + POSTER_SKINS.length) % POSTER_SKINS.length];
const tex = posterTexture(assetBase, skin, band, NOASSETS); disposables.push(tex);
const mat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.92, metalness: 0, side: THREE.DoubleSide });
const geo = new THREE.PlaneGeometry(POSTER_W, POSTER_H);
disposables.push(mat, geo);
const inst = new THREE.InstancedMesh(geo, mat, list.length);
inst.name = 'venue-posters';
for (let i = 0; i < list.length; i++) {
const p = list[i];
// A's poster ry is under the poster convention (front = local Z ⇒ world facing (sin,cos)); a
// PlaneGeometry faces +Z, so rotate ry+π to point its printed face along A's outward normal.
_q.setFromAxisAngle(_up, (p.ry || 0) + Math.PI);
_pos.set(p.x, 2.0, p.z);
inst.setMatrixAt(i, _m.compose(_pos, _q, _s));
}
inst.instanceMatrix.needsUpdate = true;
inst.receiveShadow = true;
group.add(inst);
posterDraws++;
}
}
// ── per-venue frontage + window-spill + queue zone ────────────────────────────────────────────────
const venues = [];
const poolSpecs = []; // {x,z,diam} collected across all venues → one instanced pool mesh
for (const shop of venueShops) {
const lot = lotById.get(shop.lot);
if (!lot) continue;
const ry = lot.ry || 0;
const fx = Math.sin(ry), fz = Math.cos(ry); // facade OUTWARD normal (local +Z → street side)
const rx = Math.cos(ry), rz = -Math.sin(ry); // frontage tangent ("right" along the shopfront)
const w = lot.w || 8, d = lot.d || 10;
const zf = d / 2 + 0.06; // just proud of the facade plane
const cx = lot.x + fx * zf, cz = lot.z + fz * zf; // shopfront centre, on the street
const frontage = new THREE.Group(); frontage.name = 'venue-frontage';
// warm marquee slab above the entrance
marqueeMat = new THREE.MeshStandardMaterial({ color: 0x241608, emissive: new THREE.Color(0xffc978), emissiveIntensity: 0 });
const mw = Math.min((venueLot.w || 8) * 0.92, 6.5);
const marqueeMat = new THREE.MeshStandardMaterial({ color: 0x241608, emissive: new THREE.Color(0xffc978), emissiveIntensity: 0 });
const mw = Math.min(w * 0.92, 6.5);
const mgeo = new THREE.BoxGeometry(mw, 0.55, 0.35);
const marquee = new THREE.Mesh(mgeo, marqueeMat);
marquee.position.set(mx, 3.5, mz); marquee.rotation.y = ry;
marquee.position.set(cx, 3.5, cz); marquee.rotation.y = ry;
disposables.push(marqueeMat, mgeo);
// a row of marquee "bulbs" (emissive dots) along the front edge of the slab
const bulbMat = new THREE.MeshStandardMaterial({ color: 0x3a2a10, emissive: new THREE.Color(0xffe6b0), emissiveIntensity: 0 });
signGlowMat = bulbMat;
const bgeo = new THREE.SphereGeometry(0.06, 6, 5);
const nb = Math.max(4, Math.round(mw / 0.6));
const bulbs = new THREE.InstancedMesh(bgeo, bulbMat, nb);
const _m = new THREE.Matrix4(), _p = new THREE.Vector3();
const _bm = new THREE.Matrix4();
for (let i = 0; i < nb; i++) {
const t = (i / (nb - 1) - 0.5) * mw;
_p.set(mx + Math.cos(ry) * t, 3.25, mz - Math.sin(ry) * t);
bulbs.setMatrixAt(i, _m.makeTranslation(_p.x, _p.y, _p.z));
_bm.makeTranslation(cx + rx * t, 3.25, cz + rz * t);
bulbs.setMatrixAt(i, _bm);
}
bulbs.instanceMatrix.needsUpdate = true;
frontage.add(marquee, bulbs);
disposables.push(marqueeMat, mgeo, bulbMat, bgeo);
disposables.push(bulbMat, bgeo);
// window-light spill — a soft warm wash on the ground-floor shopfront so it lifts out of the dark.
// Additive so it reads as light, not paint; opacity ramps with the gig (and a faint night baseline).
const glowMat = new THREE.MeshBasicMaterial({ color: 0xffca82, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, fog: false, toneMapped: false });
const glowGeo = new THREE.PlaneGeometry(w * 0.94, 2.4);
const glow = new THREE.Mesh(glowGeo, glowMat);
glow.position.set(cx + fx * 0.05, 1.35, cz + fz * 0.05); glow.rotation.y = ry; // faces the street (+Z)
disposables.push(glowMat, glowGeo);
frontage.add(marquee, bulbs, glow);
group.add(frontage);
// streetlamp pools on this venue's block — one bright pool at the door, a couple spaced along the
// frontage footpath (pushed out onto the verge so they pool on the footpath, not under the awning).
const footOut = 1.9;
poolSpecs.push({ x: cx + fx * 0.6, z: cz + fz * 0.6, diam: 5.0 }); // door pool (bright, wide)
const along = Math.min(w * 0.42, 3.2);
poolSpecs.push({ x: cx + rx * along + fx * footOut, z: cz + rz * along + fz * footOut, diam: 3.6 });
poolSpecs.push({ x: cx - rx * along + fx * footOut, z: cz - rz * along + fz * footOut, diam: 3.6 });
// queue zone (Lane D): the line forms on the footpath, off to one side of the door so it doesn't
// block the entrance, and runs ALONG the frontage away from the door. Published for D's poses.
const qHeadX = cx + rx * (mw * 0.5 + 0.6) + fx * 1.1; // just past the marquee edge, out on the footpath
const qHeadZ = cz + rz * (mw * 0.5 + 0.6) + fz * 1.1;
const qRy = Math.atan2(rx, rz); // heading the queue EXTENDS (along +right); face door = this
const queueZone = { x: r2(qHeadX), z: r2(qHeadZ), ry: r4(qRy), len: 5.0 };
// a subtle painted marker on the ground (chalk-pale dashes) so the zone reads even before D's line —
// primitive, cheap, night-agnostic. Collected into the pool pass? No — keep it a thin flat quad strip.
const markMat = new THREE.MeshBasicMaterial({ color: 0xbfb9a6, transparent: true, opacity: 0.16, depthWrite: false, side: THREE.DoubleSide, toneMapped: false });
const markGeo = new THREE.PlaneGeometry(0.5, queueZone.len);
markGeo.rotateX(-Math.PI / 2);
const mark = new THREE.Mesh(markGeo, markMat);
mark.position.set(qHeadX + rx * (queueZone.len / 2), 0.021, qHeadZ + rz * (queueZone.len / 2));
mark.rotation.y = qRy;
disposables.push(markMat, markGeo);
group.add(mark);
venues.push({
venueShopId: shop.id, kind: shop.venueKind || shop.type, genreKey: shop.genreKey || null,
lot, frontage, marqueeMat, bulbMat, glowMat, queueZone,
_glowCur: 0, _emisCur: 0,
});
}
group.add(frontage);
// ── one instanced streetlamp-pool mesh for the whole district (night-driven) ──────────────────────
let poolMesh = null, poolMat = null;
if (poolSpecs.length) {
const poolTex = poolTexture(); disposables.push(poolTex);
poolMat = new THREE.MeshBasicMaterial({ map: poolTex, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, fog: false, toneMapped: false });
const pg = new THREE.PlaneGeometry(1, 1); pg.rotateX(-Math.PI / 2); // lies flat, normal +Y
disposables.push(poolMat, pg);
poolMesh = new THREE.InstancedMesh(pg, poolMat, poolSpecs.length);
poolMesh.name = 'venue-pools';
const _pm = new THREE.Matrix4(), _pp = new THREE.Vector3(), _pq = new THREE.Quaternion(), _ps = new THREE.Vector3();
for (let i = 0; i < poolSpecs.length; i++) {
const s = poolSpecs[i];
_pp.set(s.x, 0.02, s.z); _ps.set(s.diam, 1, s.diam);
poolMesh.setMatrixAt(i, _pm.compose(_pp, _pq, _ps));
}
poolMesh.instanceMatrix.needsUpdate = true;
group.add(poolMesh);
}
scene.add(group);
// gig state → frontage glow. 'on' = full, 'doors' = warm-up, else dark. Accepts F's state string
// ('quiet'|'doors'|'on'|'done'), a boolean, or {state}. Idempotent; call from the loop or on change.
function update(gigState) {
const s = (gigState && gigState.state) || gigState;
let e = 0;
if (s === 'on' || s === true) e = 1.7;
else if (s === 'doors') e = 0.7;
if (marqueeMat) marqueeMat.emissiveIntensity = e;
if (signGlowMat) signGlowMat.emissiveIntensity = e * 1.15;
// ── per-venue state → frontage glow. Accepts F's per-venue map { [venueShopId]: state }, a Map, an
// { byVenue } wrapper, OR a bare state string (alpha compat → the primary pub). Idempotent; the shell
// calls it every street frame, so pool/glow ramps and the night baseline lerp here. ────────────────
function stateGetter(gigStates) {
if (gigStates == null) return () => 'quiet';
if (typeof gigStates === 'string') return (id) => (id === primaryId ? gigStates : 'quiet');
const map = gigStates.byVenue || gigStates;
return (id) => {
const v = map instanceof Map ? map.get(id) : map[id];
if (v == null) return 'quiet';
return typeof v === 'string' ? v : (v.state || 'quiet');
};
}
function levelFor(s) { return s === 'on' ? 1 : s === 'doors' ? 0.55 : 0; }
function update(gigStates) {
const get = stateGetter(gigStates);
const night = !!(typeof window !== 'undefined' && window.PROCITY?.lighting?.getClock?.().night);
for (const v of venues) {
const lvl = levelFor(get(v.venueShopId));
// marquee/bulbs emissive follow the gig (they only matter visually near night anyway)
const emisT = lvl * 1.7;
v._emisCur += (emisT - v._emisCur) * 0.25;
if (Math.abs(emisT - v._emisCur) < 0.004) v._emisCur = emisT;
v.marqueeMat.emissiveIntensity = v._emisCur;
v.bulbMat.emissiveIntensity = v._emisCur * 1.15;
// window spill: a faint night baseline (someone's in) + the gig glow, off entirely by day
const glowT = night ? 0.14 + lvl * 0.4 : 0;
v._glowCur += (glowT - v._glowCur) * 0.15;
if (Math.abs(glowT - v._glowCur) < 0.004) v._glowCur = glowT;
v.glowMat.opacity = v._glowCur;
}
// streetlamp pools: on at night (streetlamps don't care about gigs), off by day
if (poolMat) {
const poolT = night ? 0.5 : 0;
poolMat.opacity += (poolT - poolMat.opacity) * 0.1;
if (Math.abs(poolT - poolMat.opacity) < 0.004) poolMat.opacity = poolT;
if (poolMesh) poolMesh.visible = poolMat.opacity > 0.01;
}
}
update('quiet');
@ -94,17 +233,44 @@ export function createVenuePresentation(plan, skins, scene, { assetBase = 'asset
scene.remove(group);
}
const queueZones = {};
for (const v of venues) queueZones[v.venueShopId] = v.queueZone;
return {
group, frontage, update, dispose,
venueShopId: venueShop ? venueShop.id : null,
group, venues, update, dispose,
venueShopId: primaryId || (venues[0] && venues[0].venueShopId) || null, // alpha compat
venueShopIds: venues.map((v) => v.venueShopId),
queueZones,
queueZoneFor(id) { const v = venues.find((x) => x.venueShopId === id); return v ? v.queueZone : null; },
// R12 compat: the primary venue's frontage group (some callers referenced `.frontage`)
get frontage() { return venues[0] ? venues[0].frontage : null; },
get posterCount() { return posters.length; },
get posterDraws() { return posterDraws; },
};
}
// ── per-poster canvas: E's poster art with the band name overprinted in its nameZone. ──
// Flat printed-poster fallback drawn first (house law: right with zero JPEGs); the art is drawn over
// it when the same-origin image decodes (canvas stays untainted), then the name is re-stamped on top.
function posterTexture(assetBase, skin, band) {
const r2 = (v) => Math.round(v * 100) / 100;
const r4 = (v) => Math.round(v * 10000) / 10000;
// ── a warm radial streetlamp-pool sprite (procedural, no fetch): bright warm centre → transparent edge.
function poolTexture() {
const S = 128;
const c = document.createElement('canvas'); c.width = S; c.height = S;
const x = c.getContext('2d');
const g = x.createRadialGradient(S / 2, S / 2, 2, S / 2, S / 2, S / 2);
g.addColorStop(0.00, 'rgba(255,226,170,0.85)');
g.addColorStop(0.45, 'rgba(255,205,130,0.35)');
g.addColorStop(1.00, 'rgba(255,190,110,0.0)');
x.fillStyle = g; x.fillRect(0, 0, S, S);
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
// ── per-gig canvas: E's poster art with the band name overprinted in its nameZone. ──
// Flat printed-poster fallback drawn first (house law: right with zero JPEGs); the art is drawn over it
// when the same-origin image decodes (canvas stays untainted), then the name is re-stamped on top. Under
// ?noassets we skip the fetch entirely (zero network) and keep the flat poster.
function posterTexture(assetBase, skin, band, noassets) {
const W = 240, H = 342;
const c = document.createElement('canvas'); c.width = W; c.height = H;
const x = c.getContext('2d');
@ -112,13 +278,15 @@ function posterTexture(assetBase, skin, band) {
x.fillStyle = '#cbb98f'; x.fillRect(8, 8, W - 16, H - 16); // aged paper
x.fillStyle = '#7a2a24'; x.fillRect(8, H * 0.62, W - 16, 3);
x.fillStyle = '#1c1712'; x.font = 'bold 13px Arial'; x.textAlign = 'center';
x.fillText('LIVE · THE ROYAL', W / 2, H * 0.72);
x.fillText('LIVE · TONIGHT', W / 2, H * 0.72);
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
drawBandName(x, W, H, band);
const img = new Image();
img.onload = () => { x.drawImage(img, 0, 0, W, H); drawBandName(x, W, H, band); tex.needsUpdate = true; };
img.onerror = () => {}; // fallback poster already drawn
img.src = `${assetBase}poster-${skin}.jpg`;
if (!noassets) {
const img = new Image();
img.onload = () => { x.drawImage(img, 0, 0, W, H); drawBandName(x, W, H, band); tex.needsUpdate = true; };
img.onerror = () => {}; // fallback poster already drawn
img.src = `${assetBase}poster-${skin}.jpg`;
}
return tex;
}