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:
parent
888335d633
commit
f0a0a99207
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
136
web/js/world/venue.js
Normal 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();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user