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:
parent
82b711f962
commit
46127c7e95
@ -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) | **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):
|
||||
|
||||
@ -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) ──
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user