// 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, 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. 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'); 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(); }