diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index 49593a4..80fc822 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -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-`. 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) | **96–113** | 19–32k | ≤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 176–198 (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): diff --git a/web/js/world/audio.js b/web/js/world/audio.js index 874c606..28f9488 100644 --- a/web/js/world/audio.js +++ b/web/js/world/audio.js @@ -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-) + 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-), 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) ── diff --git a/web/js/world/dbg.js b/web/js/world/dbg.js index 3b58f2a..68f260e 100644 --- a/web/js/world/dbg.js +++ b/web/js/world/dbg.js @@ -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 = { diff --git a/web/js/world/venue.js b/web/js/world/venue.js index d4cd12d..56b4e45 100644 --- a/web/js/world/venue.js +++ b/web/js/world/venue.js @@ -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 2–4 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; }