PROCITY/web/js/world/venue.js
m3ultra a0b578477c Lane B round 15: verify hero after A's poster flip + night poster readability (?gigs=1)
Verified A's ledger-#1 flip (35da83c): the pub's own-frontage poster moved from 15.9m behind the
building to 0.09m from the door on the +Z street face, facing the reader -- no queue_night pose
change needed (R14 prediction held). Gig golden 0x1f636349->0x4f4a549d consumed cleanly.

Fix (John-sanctioned exception to the R14 freeze): posters were unlit MeshStandardMaterial and
rendered pure black at NIGHT (measured frontage poster centre [183,71,59] day -> [4,0,1] night), so
queue_night + district_posters showed no readable bill even after A's flip. venue.js now gives posters
emissiveMap = the poster art, emissiveIntensity ramped in update() by night (0 day -> 0.55 night, same
lerp as glow/pools). Self-lights the bill after dark; daylight byte-identical (verified midday spine
poster pixel-unchanged). No new rng, no plan change, goldens frozen. ?noassets fallback self-lights too.

Verified fresh Chromium seed 20261990: queue_night (seg5, live queue) frames marquee + window glow +
streetlamp pools + a readable red poster by the door + queue punters (90 draws/29k tris);
district_posters night now shows a poster (was black, 180/138k); daylight unregressed.

-> Lane F (B->F handshake): HERO VERIFIED. Shoot queue_night at seg 5 (NIGHT) -- poster now reads there
   and night is needed for pools+glow; drive the queue live (rAF throttled in automation). Poster
   night-emissive rides the shell's per-frame venuePresentation.update(gigState.byVenue).

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

318 lines
18 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 posterMats = []; // ramped emissive at night (R15 ledger #1 completion) so posters read after dark
{
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);
// emissiveMap = the poster art, ramped 0 (day) → ~0.55 (night) in update(): posters are otherwise
// unlit MeshStandard and render pure black at NIGHT (measured [4,0,1]), so the queue_night hero and
// district_posters showed no readable bill after A's +Z flip. Day stays byte-identical (emissive 0).
const mat = new THREE.MeshStandardMaterial({ map: tex, emissive: new THREE.Color(0xffffff), emissiveMap: tex, emissiveIntensity: 0, roughness: 0.92, metalness: 0, side: THREE.DoubleSide });
posterMats.push(mat);
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;
}
// posters self-light at night so the bill reads (unlit MeshStandard → black after dark). Day = 0,
// so daylight tour shots stay byte-identical; the ramp lifts every poster (frontage + spine) at night.
const posterT = night ? 0.55 : 0;
for (let i = 0; i < posterMats.length; i++) {
const pm = posterMats[i];
pm.emissiveIntensity += (posterT - pm.emissiveIntensity) * 0.1;
if (Math.abs(posterT - pm.emissiveIntensity) < 0.004) pm.emissiveIntensity = posterT;
}
}
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();
}