diff --git a/B-progress.md b/B-progress.md index 0c9db14..0510c92 100644 --- a/B-progress.md +++ b/B-progress.md @@ -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 diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index 81558f1..49593a4 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -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. diff --git a/web/js/world/audio.js b/web/js/world/audio.js index b0e7e46..874c606 100644 --- a/web/js/world/audio.js +++ b/web/js/world/audio.js @@ -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) { diff --git a/web/js/world/venue.js b/web/js/world/venue.js new file mode 100644 index 0000000..d4cd12d --- /dev/null +++ b/web/js/world/venue.js @@ -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(); +}