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>
This commit is contained in:
m3ultra 2026-07-15 20:15:40 +10:00
parent 888335d633
commit f0a0a99207
4 changed files with 225 additions and 1 deletions

View File

@ -89,6 +89,26 @@ use-if-ready furniture path (fail-soft; `?noassets` → primitives, 0 network).
(same as the ped rigs).** File: `furniture.js`.
## Round 12 (v3.0-alpha, VENUES & GIGS, `?gigs=1`) — Lane B street-side — done
Built the pub's street presence for the one-pub vertical slice. Dependencies (A venue+`plan.gigs`,
C watchPoints, E posters+gig audio, D band+crowd) all landed; F wires it last.
- **`web/js/world/venue.js`** (new, B-owned; shell/F constructs under `?gigs`) —
`createVenuePresentation(plan, skins, scene) → {group, update(gigState), dispose, venueShopId}`.
**Posters:** E's 4 poster skins at each `plan.posters` (x,z,ry) with the gig's `bandName`
overprinted in `nameZone` (canvas composite; seeded template; flat fallback if JPEG missing).
Verified "THE WOMBATTS" over the band-photo art. **Lit frontage:** warm marquee + emissive bulb row
over the venue door, `update(gigState)` ramps the glow. Verified: "The Thornbury Hotel" lit at night.
- **Muffled-gig spill** (extended `audio.js`): near the venue (≤26 m) while gig `doors`/`on`, plays
`pubrock-live` through a 470 Hz **lowpass** at low distance-gain. Reads `window.PROCITY.gigs` (F)
with a clock+`plan.gigs` fallback. Verified: gain ~0.14 muffled at night, fades off far/day.
- **Prime flag law held:** flags-off = no gigs/posters/venue (goldens frozen, no citygen edits), audio
gig layer created but inert (`plan.gigs` absent → skipped). Byte-identical boot.
- **Stretch deferred:** streetlamp-pool night-readability on the venue block (touches shared furniture
rendering) — flagged as next polish. **Note to A:** main-street poster positions land mid-road on
intersection nodes — a verge/pole offset would seat them better.
- Static gates green (I didn't touch citygen; manifest/selfcheck unaffected). Files: `web/js/world/
venue.js` (new) + `web/js/world/audio.js` (spill extension) + docs. **F wiring documented in LANE_B_NOTES.**
## Round 11 (AUDIO) — Lane B WebAudio engine — done
Built **`web/js/world/audio.js`** (B-owned) + a guarded create-call in the shell. One `AudioContext`
unlocked on the first gesture; self-contained (reads `window.PROCITY`, self-ticks); consumes Lane E's

View File

@ -358,6 +358,39 @@ lazy-load (≤25 MB pack, 8.7 MB shipped); spill/music selection is a pure funct
fades them out. **→ Lane C: `room.audio = { musicKey, toneKey }` on `buildInterior` is the right shape.**
- `state``{ ready, muted, mode, manifest, loaded, layers, nearestSpill }` diagnostics for smokes.
## Venue / gigs — the pub from the street (round 12, v3.0-alpha, `?gigs=1`)
Two B-owned pieces for the one-pub vertical slice. **Prime flag law:** both are inert/absent without
the gig layer, so flags-off boot is byte-identical (goldens untouched — no citygen edits).
- **`web/js/world/venue.js`** (new, like weather.js/tram.js — the **shell/Lane F constructs it under
`?gigs`**). `createVenuePresentation(plan, skins, scene) → { group, frontage, update(gigState),
dispose, venueShopId, posterCount }`.
- **Posters** — renders E's `skins.poster.{grunge,retro,screenprint,xerox}` at every `plan.posters`
position `(x,z,ry)` with the gig's `bandName` overprinted in the skin's `nameZone` (canvas
composite, like a shop sign). Seeded template per poster. Flat printed-poster fallback if the JPEG
404s (house law). Verified: "THE WOMBATTS" over the band-photo art. *(Note to A: main-street poster
positions land mid-road on intersection nodes — a verge/pole offset would seat them better.)*
- **Lit frontage** — a warm marquee slab + emissive bulb row over the venue door; `update(gigState)`
ramps the glow (`'on'`→full, `'doors'`→warm-up, else dark). F drives it from the gig state machine.
- **Muffled-gig spill** (in `audio.js`, already shell-wired) — extends the R11 door-spill: near the
venue (≤26 m) while a gig is at `doors`/`on`, the `pubrock-live` bed plays through a **470 Hz
lowpass** (thump through the bricks) at low distance-gain. Gig state = `window.PROCITY.gigs` (Lane F)
with a clock+`plan.gigs` fallback so it works before F lands. Inert without the gig layer.
### For Lane F (wiring this into the shell)
```js
// under ?gigs (plan already has gigs/posters/venue via generatePlanFor(seed, src, {gigs:true, customBands})):
const venue = createVenuePresentation(plan, skins, scene); // posters + frontage
// in the street loop, drive the frontage from your state machine:
venue.update(PROCITY.gigs.state); // 'quiet'|'doors'|'on'|'done'
```
The muffled spill needs **no wiring**`audio.js` reads `plan.gigs` + `window.PROCITY.gigs` itself.
Set `window.PROCITY.gigs = { state }` (or `{ on: bool }`) and B's spill + fallback both follow it.
**Stretch deferred:** streetlamp-pool night-readability on the venue block (touches shared furniture
rendering + wants a budget-safe scoped decal) — flagged as the next polish, not landed this round.
## Controls
WASD move · shift run · mouse look · click a door · `[` `]` step time-of-day · `T` pause clock ·
`M` map · `P` screenshot · Esc release pointer.

View File

@ -102,6 +102,13 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
spill: layer(buses.music), tram: layer(buses.tram),
iMusic: layer(buses.music), iTone: layer(buses.ambience),
};
// gig spill (round 12): the venue's live bed leaking THROUGH THE WALL — low-pass filtered (muffled
// bass/thump through the bricks) so it reads distinct from an open shop-door spill, and reaching
// further down the street. B owns the street side of the gig audio seam.
const gigLowpass = ctx.createBiquadFilter();
gigLowpass.type = 'lowpass'; gigLowpass.frequency.value = 470; gigLowpass.Q.value = 0.6;
gigLowpass.connect(buses.music);
L.gigSpill = layer(gigLowpass);
state.layers = L;
async function persistBed(l, key, entry, target, fade = 1.4) {
@ -183,7 +190,24 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
(PROCITY.plan?.shops || []).forEach((s) => shopById.set(s.id, s));
const _cam = { x: 0, z: 0 };
const v = { lx: null, lz: null, stepAcc: 0, spillT: 0, tramX: null, tramZ: null, tramRang: false };
const STRIDE = 0.72, SPILL_R = 9, TRAM_R = 55, TRAM_BELL_R = 22;
const STRIDE = 0.72, SPILL_R = 9, TRAM_R = 55, TRAM_BELL_R = 22, GIG_SPILL_R = 26;
// the pub venue (round 12), if the gig layer is present (?gigs); its lot carries the coords.
const venueShop = (PROCITY.plan?.shops || []).find((s) => s.venue) || null;
const venueLot = venueShop && (PROCITY.plan?.lots || []).find((l) => l.id === venueShop.lot);
// gig state for the muffled spill: prefer Lane F's window.PROCITY.gigs state machine, else derive
// from plan.gigs + the clock. Returns null with no gig layer (flags-off ⇒ no gig spill, byte-identical).
function gigStateNow() {
const g = PROCITY.gigs;
if (g && typeof g.state === 'string') return g.state;
if (g && typeof g.on === 'boolean') return g.on ? 'on' : 'quiet';
const gigs = PROCITY.plan?.gigs; if (!gigs || !gigs.length) return null;
const clk = PROCITY.lighting?.getClock?.(); if (!clk) return 'quiet';
const s = gigs[0].startSeg ?? 5, e = gigs[0].endSeg ?? 5;
if (clk.seg >= s && clk.seg <= e) return 'on';
if (clk.seg === s - 1) return 'doors';
return 'quiet';
}
function update(dt) {
if (!state.ready || state.muted) return;
@ -241,6 +265,17 @@ export function createAudioEngine(PROCITY, { noassets = false, mute = false } =
ramp(L.spill.g.gain, 0, 0.4);
}
// ── muffled gig spill through the venue wall (round 12) — low-pass bed, reaches further than a
// door spill; only while the gig layer is on (?gigs) and a gig is at doors/on. Byte-identical off. ──
if (street && venueLot && P.plan?.gigs?.length) {
const gs = gigStateNow(), on = gs === 'on' || gs === 'doors';
const dist = Math.hypot(venueLot.x - _cam.x, venueLot.z - _cam.z);
const gg = (on && dist < GIG_SPILL_R)
? (AUDIO?.music?.['pubrock-live']?.gain ?? 0.6) * 0.55 * ((GIG_SPILL_R - dist) / GIG_SPILL_R) : 0;
state.gig = gs;
swapBed(L.gigSpill, gg > 0 ? 'pubrock-live' : null, gg > 0 ? AUDIO?.music?.['pubrock-live'] : null, gg, 0.9);
} else if (L.gigSpill.key) { ramp(L.gigSpill.g.gain, 0, 0.6); }
// ── tram rumble (distance-gain) + bell (rings once as it settles at a stop nearby) ──
const tramG = P.scene?.getObjectByName?.('tram');
if (street && tramG) {

136
web/js/world/venue.js Normal file
View File

@ -0,0 +1,136 @@
// 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();
}