// 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. // // 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. 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) export function createVenuePresentation(plan, skins, scene, { assetBase = 'assets/gen/' } = {}) { const group = new THREE.Group(); group.name = 'venue-presentation'; const disposables = []; 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); // ── 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); } // ── 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; // 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 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; // 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(); 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)); } bulbs.instanceMatrix.needsUpdate = true; frontage.add(marquee, bulbs); disposables.push(marqueeMat, mgeo, bulbMat, bgeo); } group.add(frontage); 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; } 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); } return { group, frontage, update, dispose, venueShopId: venueShop ? venueShop.id : null, get posterCount() { return posters.length; }, }; } // ── 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 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 · THE ROYAL', 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`; 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(); }