PROCITY/web/js/world/venue.js
m3ultra f0a0a99207 Lane B (Streetscape): round-12 v3.0-alpha — venue frontage + posters + muffled-gig spill (?gigs=1)
- web/js/world/venue.js (new): createVenuePresentation — renders E's poster skins at plan.posters with band-name overprint (nameZone, seeded template, flat fallback if JPEG missing); lit pub marquee + bulb row over the door, update(gigState) ramps the glow. Constructed by the shell/F under ?gigs (like weather/tram).
- web/js/world/audio.js: muffled-gig spill — near the venue (<=26m) while a gig is at doors/on, pubrock-live plays through a 470Hz lowpass at low distance-gain. Reads window.PROCITY.gigs (F) with a plan.gigs+clock fallback.
- Prime flag law: inert/absent without the gig layer; goldens frozen (no citygen edits, selfcheck 3074/3074); byte-identical flags-off boot.
- Verified live on a gig plan: THE WOMBATTS posters over band-photo art, The Thornbury Hotel lit at night, spill gain ~0.14. F wiring documented in LANE_B_NOTES; streetlamp-pool stretch deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:15:40 +10:00

137 lines
6.9 KiB
JavaScript

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