PROCITY/web/js/world/venue.js
m3ultra eee60e05a5 Lane B (Streetscape) round 14: v3.0 release — alpha-alias retirement + tour audit + queue_night hero (?gigs=1)
Release round: no new systems, no rng, goldens frozen (0x3fa36874 / gig 0x1f636349 both unmoved).
Files: audio.js, venue.js, dbg.js (+ LANE_B_NOTES). Verified fresh Chromium, seed 20261990.

- Alpha-alias readers dropped (debt #1, B first): audio.js venueGigState() loses the alias hop
  (g.venueShopId===id -> g.state); byVenue + clock fallback only. venue.js stateGetter() loses the
  bare-string arm (F passes gigState.byVenue since R13, confirmed index.html:379); seed = update(null).
  Verified live: byVenue {116:on,404:on,490:quiet} at NIGHT drives the marquee with no string arm;
  audio resolves gig-<genreKey> per venue with no alias hop. F is UNBLOCKED to delete the getters.
- Bookmark/tour audit under ?gigs=1: all 19 bookmarks resolve, frame un-letterboxed, 0 page errors,
  <=300 draws. District venue-conversions hijacked none (night_neon->Video Regal, dig_real->Retro
  Groove). No pose fixes needed. (Orphan-chunk teleport inflates tris across sequential shots -- fresh
  counts <=153k; tram_stop's real ~293k is close-range Lane D GLB ped rigs, framing fine, not a venue block.)
- queue_night bookmark added (dbg.js): John's release hero -- close 3/4 on the pub door at seg 5
  ('on'), biased to the queue side; new poseForVenueQueue() recomputes venue.js frontage geometry.
  Frames marquee + window glow + streetlamp pools + the queue (spawned live by the shell loop; F's
  gig_shot.py drives it live -- verified by manually spawning D's VenueQueue: 2 punters at the door).
- Poster-repetition call (debt #4): FINE, no ask to E -- 2 gigs tonight => 2 distinct designs
  (screenprint/Screaming Utes x7, xerox/The Dead Ringers x7); variety scales with venues playing.
- Town-wide streetlamp pools: v3.1 candidate per Fable ruling (venue-scoped for v3.0; no code).

-> Lane F: cross-lane alias sweep is CLEAN (no A/C/D/E reads it); only your own 20 tool lines in
   flags_check.py + gig_shot.py remain -- migrate to *Of(id)/venueShopIds[0] when you delete the
   gig_state.js:141-151 getters + the CITY_SPEC alias line (with A).
-> Lane A (now gates John's hero-shot poster): buildPosters item 1 still seats each venue's own
   frontage poster on local -Z (back wall, 15.9m from the +Z street facade). Flip to +sin,+cos and it
   seats by the door -- in the existing queue_night frame, no pose change. Per "don't touch world
   code," B did not work around it. Re-flagged from R13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:43:22 +10:00

305 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, 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']; // 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 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;
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);
}
// ── 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
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(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 });
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 _bm = new THREE.Matrix4();
for (let i = 0; i < nb; i++) {
const t = (i / (nb - 1) - 0.5) * mw;
_bm.makeTranslation(cx + rx * t, 3.25, cz + rz * t);
bulbs.setMatrixAt(i, _bm);
}
bulbs.instanceMatrix.needsUpdate = true;
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,
});
}
// ── 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);
// ── per-venue state → frontage glow. F passes its per-venue map `gigState.byVenue`
// ({ [venueShopId]: 'quiet'|'doors'|'on'|'done' }); a Map or a { byVenue } wrapper are also accepted.
// (R14: the R12 alpha bare-string arm is retired — F wires the map since R13.) 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';
const map = gigStates.byVenue || gigStates; // F passes gigState.byVenue (per-venue map)
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(null); // seed: no gig states yet → every venue quiet (frontage dark, pools follow night)
function dispose() {
for (const d of disposables) d.dispose && d.dispose();
group.traverse((o) => { if (o.isInstancedMesh) o.dispose && o.dispose(); });
scene.remove(group);
}
const queueZones = {};
for (const v of venues) queueZones[v.venueShopId] = v.queueZone;
return {
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; },
};
}
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');
x.fillStyle = '#141019'; x.fillRect(0, 0, W, H); // dark backing
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 · TONIGHT', W / 2, H * 0.72);
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
drawBandName(x, W, H, band);
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;
}
function drawBandName(x, W, H, band) {
const [nx, ny, nw, nh] = NAME_ZONE;
const zx = nx * W, zy = ny * H, zw = nw * W, zh = nh * H;
const label = String(band).toUpperCase();
x.save();
x.textAlign = 'center'; x.textBaseline = 'middle';
let fs = zh * 0.72;
do { x.font = `bold ${fs}px Arial, sans-serif`; fs -= 2; } while (x.measureText(label).width > zw - 6 && fs > 8);
x.fillStyle = 'rgba(0,0,0,0.55)'; x.fillText(label, zx + zw / 2 + 1.2, zy + zh / 2 + 1.2); // shadow
x.fillStyle = '#f7ecc8'; x.fillText(label, zx + zw / 2, zy + zh / 2);
x.restore();
}