Ledger #4. Verified fresh Chromium seed 20261990. No new rng, goldens untouched. - Town-wide pools (venue.js): extended the R13 instanced-pool pass from venue frontages to EVERY streetlight + bus shelter. New townPoolSpecs(plan) replicates furniture.js's lamp cadence off the plan street graph (lamp every 18m from s=8, alternating sides, off=halfRoad+0.7) and reuses the exported busShelterStops(plan) -- read-only, furniture.js untouched (imported one function). All pools ride the ONE existing InstancedMesh -> still ~1 draw: 381 instances / 1 draw / ~760 tris. Night-driven; fog:true now so distant pools fade into night fog (pools aren't chunk-streamed) instead of floating as dots. Gig-gated -> ?classic=1 is pool-free = v2 parity by construction (no always-on furniture change, the v3.0 blocker). Deterministic cadence, no seeded draws (release law). - Default-boot re-baseline: measured the default content (gigs + weather=rain + winmap + tram all live), settled. Busiest venue block (pub, NIGHT, rain) ~157 scene / ~170 total draws, 66k tris; venue_night 122, queue_night 121, district_posters/street_noon 185 (worst tour shot), market_square fresh 97/52k. ALL <=300 draws / <=200k tris. Flip adds ~3 draws (rain 1 + tram 2; winmap 0), pools add ~0 (instanced). Daytime dense-citizen ceiling (~275, Lane D, live-walk) unchanged (pools night-only). - Bookmark spot-check: all 19 resolve, un-letterboxed, 0 errors under the default boot; rain+tram+winmap compose cleanly in old frames (street_noon rainy 177/114k, nothing blocked). No pose fixes. Ran before F's flip landed (out of the stated order): pools are gig-gated so they work under ?gigs now; re-baseline uses explicit all-flags URLs = post-flip default content. F re-confirms at close. -> Lane F (B->F handshake): default-boot busiest block ~170 total draws / 66k tris; worst street view 185 / 131k; densest daytime 97 / 52k. All under budget. Town-wide pools ~1 draw. Pool sprite is procedural (E has nothing to do). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
350 lines
20 KiB
JavaScript
350 lines
20 KiB
JavaScript
// PROCITY Lane B — venue.js (round 13, v3.0-beta THE DISTRICT, ?gigs=1)
|
||
// The street-side presentation of the town's 2–4 venues (pub / band_room / rsl). Round 13 generalises
|
||
// the R12 one-pub slice to the whole district and adds the night-readability polish:
|
||
// (1) town-wide gig POSTERS — E's poster skins at A's plan.posters positions, band name overprinted,
|
||
// instanced per gig so the whole town is a handful of draws;
|
||
// (2) a lit FRONTAGE per venue — warm marquee + bulb row over the door, ramped per-venue by F's gig
|
||
// state; window-light spill so the shopfront lifts out of the dark;
|
||
// (3) STREETLAMP POOLS on each venue block — warm pools of light on the footpath so the Friday-night
|
||
// walk reads (the money-shot polish). One instanced mesh, night-driven;
|
||
// (4) a QUEUE ZONE marker per venue, publishing `venue.queueZone = {x,z,ry,len}` for Lane D's line.
|
||
//
|
||
// 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, and nothing here is on the always-on
|
||
// night path. Asset law: a missing poster JPEG (or ?noassets) falls back to a flat printed poster and
|
||
// fetches nothing. Deterministic from citySeed. B owns the street side of the gig seam.
|
||
//
|
||
// Orientation: a venue's facade/door is on the lot's local +Z (buildings.js canon: front = +Z), so the
|
||
// frontage, pools and queue seat on (sin ry, cos ry) — the STREET side. A's plan.posters carry their own
|
||
// ry under A's poster convention (front = local −Z); posters are rendered at A's (x,z,ry) verbatim.
|
||
|
||
import * as THREE from 'three';
|
||
import { busShelterStops } from './furniture.js'; // read-only: shelter (tram-stop) positions for pools
|
||
|
||
const POSTER_W = 1.05, POSTER_H = 1.5; // ~A2 bill on a pole/wall
|
||
const POSTER_SKINS = ['grunge', 'retro', 'screenprint', 'xerox']; // E's poster pack (assets/gen/poster-*.jpg)
|
||
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 params = (() => { try { return new URLSearchParams(location.search); } catch { return new URLSearchParams(); } })();
|
||
const NOASSETS = params.get('noassets') != null && params.get('noassets') !== '0'; // house law: zero fetch
|
||
|
||
const gigs = plan.gigs || [];
|
||
const posters = plan.posters || [];
|
||
const lotById = new Map((plan.lots || []).map((l) => [l.id, l]));
|
||
const gigById = new Map(gigs.map((g) => [g.gigId, g]));
|
||
// tonight = night 0 (matches gig_state.js / A's poster selection). Primary venue = the alpha machine's
|
||
// gigs[0] venue, so a legacy single-string update() still lights the right frontage mid-round.
|
||
const primaryId = gigs.length ? gigs[0].venueShopId : null;
|
||
|
||
const venueShops = (plan.shops || []).filter((s) => s.venue);
|
||
// R12 fallback (no A gig-layer venue flag): treat the pub as the sole venue.
|
||
if (!venueShops.length) {
|
||
const pub = (plan.shops || []).find((s) => s.type === 'pub');
|
||
if (pub) venueShops.push(pub);
|
||
}
|
||
|
||
// ── town-wide posters, instanced per gig (one draw + one texture per advertised gig) ──────────────
|
||
// A given gig's poster looks the same wherever it's pasted, so we group A's poster positions by gigId,
|
||
// pick ONE seeded skin per gig, and render each group as a single InstancedMesh. Town-wide the whole
|
||
// poster layer is ≤ (tonight's playing venues) draws instead of one mesh + one canvas per bill.
|
||
let posterDraws = 0;
|
||
const posterMats = []; // ramped emissive at night (R15 ledger #1 completion) so posters read after dark
|
||
{
|
||
const byGig = new Map();
|
||
for (const p of posters) {
|
||
const gid = p.gigId != null ? p.gigId : -1;
|
||
if (!byGig.has(gid)) byGig.set(gid, []);
|
||
byGig.get(gid).push(p);
|
||
}
|
||
const _m = new THREE.Matrix4(), _pos = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3(1, 1, 1), _up = new THREE.Vector3(0, 1, 0);
|
||
for (const [gid, list] of byGig) {
|
||
const gig = gigById.get(gid) || gigs[0] || null;
|
||
const band = gig ? gig.bandName : 'LIVE TONIGHT';
|
||
const skin = POSTER_SKINS[(((gid | 0) + (plan.citySeed >>> 0)) % POSTER_SKINS.length + POSTER_SKINS.length) % POSTER_SKINS.length];
|
||
const tex = posterTexture(assetBase, skin, band, NOASSETS); disposables.push(tex);
|
||
// emissiveMap = the poster art, ramped 0 (day) → ~0.55 (night) in update(): posters are otherwise
|
||
// unlit MeshStandard and render pure black at NIGHT (measured [4,0,1]), so the queue_night hero and
|
||
// district_posters showed no readable bill after A's +Z flip. Day stays byte-identical (emissive 0).
|
||
const mat = new THREE.MeshStandardMaterial({ map: tex, emissive: new THREE.Color(0xffffff), emissiveMap: tex, emissiveIntensity: 0, roughness: 0.92, metalness: 0, side: THREE.DoubleSide });
|
||
posterMats.push(mat);
|
||
const geo = new THREE.PlaneGeometry(POSTER_W, POSTER_H);
|
||
disposables.push(mat, geo);
|
||
const inst = new THREE.InstancedMesh(geo, mat, list.length);
|
||
inst.name = 'venue-posters';
|
||
for (let i = 0; i < list.length; i++) {
|
||
const p = list[i];
|
||
// A's poster ry is under the poster convention (front = local −Z ⇒ world facing (−sin,−cos)); a
|
||
// PlaneGeometry faces +Z, so rotate ry+π to point its printed face along A's outward normal.
|
||
_q.setFromAxisAngle(_up, (p.ry || 0) + Math.PI);
|
||
_pos.set(p.x, 2.0, p.z);
|
||
inst.setMatrixAt(i, _m.compose(_pos, _q, _s));
|
||
}
|
||
inst.instanceMatrix.needsUpdate = true;
|
||
inst.receiveShadow = true;
|
||
group.add(inst);
|
||
posterDraws++;
|
||
}
|
||
}
|
||
|
||
// ── per-venue frontage + window-spill + queue zone ────────────────────────────────────────────────
|
||
const venues = [];
|
||
const poolSpecs = []; // {x,z,diam} collected across all venues → one instanced pool mesh
|
||
for (const shop of venueShops) {
|
||
const lot = lotById.get(shop.lot);
|
||
if (!lot) continue;
|
||
const ry = lot.ry || 0;
|
||
const fx = Math.sin(ry), fz = Math.cos(ry); // facade OUTWARD normal (local +Z → street side)
|
||
const rx = Math.cos(ry), rz = -Math.sin(ry); // frontage tangent ("right" along the shopfront)
|
||
const w = lot.w || 8, d = lot.d || 10;
|
||
const zf = d / 2 + 0.06; // just proud of the facade plane
|
||
const cx = lot.x + fx * zf, cz = lot.z + fz * zf; // shopfront centre, on the street
|
||
|
||
const frontage = new THREE.Group(); frontage.name = 'venue-frontage';
|
||
|
||
// warm marquee slab above the entrance
|
||
const marqueeMat = new THREE.MeshStandardMaterial({ color: 0x241608, emissive: new THREE.Color(0xffc978), emissiveIntensity: 0 });
|
||
const mw = Math.min(w * 0.92, 6.5);
|
||
const mgeo = new THREE.BoxGeometry(mw, 0.55, 0.35);
|
||
const marquee = new THREE.Mesh(mgeo, marqueeMat);
|
||
marquee.position.set(cx, 3.5, cz); marquee.rotation.y = ry;
|
||
disposables.push(marqueeMat, mgeo);
|
||
|
||
// 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 });
|
||
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 _bm = new THREE.Matrix4();
|
||
for (let i = 0; i < nb; i++) {
|
||
const t = (i / (nb - 1) - 0.5) * mw;
|
||
_bm.makeTranslation(cx + rx * t, 3.25, cz + rz * t);
|
||
bulbs.setMatrixAt(i, _bm);
|
||
}
|
||
bulbs.instanceMatrix.needsUpdate = true;
|
||
disposables.push(bulbMat, bgeo);
|
||
|
||
// window-light spill — a soft warm wash on the ground-floor shopfront so it lifts out of the dark.
|
||
// Additive so it reads as light, not paint; opacity ramps with the gig (and a faint night baseline).
|
||
const glowMat = new THREE.MeshBasicMaterial({ color: 0xffca82, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, fog: false, toneMapped: false });
|
||
const glowGeo = new THREE.PlaneGeometry(w * 0.94, 2.4);
|
||
const glow = new THREE.Mesh(glowGeo, glowMat);
|
||
glow.position.set(cx + fx * 0.05, 1.35, cz + fz * 0.05); glow.rotation.y = ry; // faces the street (+Z)
|
||
disposables.push(glowMat, glowGeo);
|
||
|
||
frontage.add(marquee, bulbs, glow);
|
||
group.add(frontage);
|
||
|
||
// streetlamp pools on this venue's block — one bright pool at the door, a couple spaced along the
|
||
// frontage footpath (pushed out onto the verge so they pool on the footpath, not under the awning).
|
||
const footOut = 1.9;
|
||
poolSpecs.push({ x: cx + fx * 0.6, z: cz + fz * 0.6, diam: 5.0 }); // door pool (bright, wide)
|
||
const along = Math.min(w * 0.42, 3.2);
|
||
poolSpecs.push({ x: cx + rx * along + fx * footOut, z: cz + rz * along + fz * footOut, diam: 3.6 });
|
||
poolSpecs.push({ x: cx - rx * along + fx * footOut, z: cz - rz * along + fz * footOut, diam: 3.6 });
|
||
|
||
// queue zone (Lane D): the line forms on the footpath, off to one side of the door so it doesn't
|
||
// block the entrance, and runs ALONG the frontage away from the door. Published for D's poses.
|
||
const qHeadX = cx + rx * (mw * 0.5 + 0.6) + fx * 1.1; // just past the marquee edge, out on the footpath
|
||
const qHeadZ = cz + rz * (mw * 0.5 + 0.6) + fz * 1.1;
|
||
const qRy = Math.atan2(rx, rz); // heading the queue EXTENDS (along +right); face door = −this
|
||
const queueZone = { x: r2(qHeadX), z: r2(qHeadZ), ry: r4(qRy), len: 5.0 };
|
||
|
||
// a subtle painted marker on the ground (chalk-pale dashes) so the zone reads even before D's line —
|
||
// primitive, cheap, night-agnostic. Collected into the pool pass? No — keep it a thin flat quad strip.
|
||
const markMat = new THREE.MeshBasicMaterial({ color: 0xbfb9a6, transparent: true, opacity: 0.16, depthWrite: false, side: THREE.DoubleSide, toneMapped: false });
|
||
const markGeo = new THREE.PlaneGeometry(0.5, queueZone.len);
|
||
markGeo.rotateX(-Math.PI / 2);
|
||
const mark = new THREE.Mesh(markGeo, markMat);
|
||
mark.position.set(qHeadX + rx * (queueZone.len / 2), 0.021, qHeadZ + rz * (queueZone.len / 2));
|
||
mark.rotation.y = qRy;
|
||
disposables.push(markMat, markGeo);
|
||
group.add(mark);
|
||
|
||
venues.push({
|
||
venueShopId: shop.id, kind: shop.venueKind || shop.type, genreKey: shop.genreKey || null,
|
||
lot, frontage, marqueeMat, bulbMat, glowMat, queueZone,
|
||
_glowCur: 0, _emisCur: 0,
|
||
});
|
||
}
|
||
|
||
// ── town-wide streetlamp pools (R16 ledger #4): a warm pool under every streetlight + bus shelter,
|
||
// not just venue frontages. Positions are replicated from the plan's street graph using furniture.js's
|
||
// exact lamp cadence (READ-ONLY — we don't touch furniture.js), so a pool lands under each real lamp.
|
||
// Gig-gated by construction (venue.js only exists under the gig layer ⇒ ?classic stays pool-free); one
|
||
// instanced mesh ⇒ still ~1 draw town-wide. Night-driven via the same opacity ramp as the venue pools.
|
||
for (const s of townPoolSpecs(plan)) poolSpecs.push(s);
|
||
|
||
// ── one instanced streetlamp-pool mesh for the whole town (night-driven) ──────────────────────────
|
||
let poolMesh = null, poolMat = null;
|
||
if (poolSpecs.length) {
|
||
const poolTex = poolTexture(); disposables.push(poolTex);
|
||
poolMat = new THREE.MeshBasicMaterial({ map: poolTex, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, fog: true, toneMapped: false });
|
||
const pg = new THREE.PlaneGeometry(1, 1); pg.rotateX(-Math.PI / 2); // lies flat, normal +Y
|
||
disposables.push(poolMat, pg);
|
||
poolMesh = new THREE.InstancedMesh(pg, poolMat, poolSpecs.length);
|
||
poolMesh.name = 'venue-pools';
|
||
const _pm = new THREE.Matrix4(), _pp = new THREE.Vector3(), _pq = new THREE.Quaternion(), _ps = new THREE.Vector3();
|
||
for (let i = 0; i < poolSpecs.length; i++) {
|
||
const s = poolSpecs[i];
|
||
_pp.set(s.x, 0.02, s.z); _ps.set(s.diam, 1, s.diam);
|
||
poolMesh.setMatrixAt(i, _pm.compose(_pp, _pq, _ps));
|
||
}
|
||
poolMesh.instanceMatrix.needsUpdate = true;
|
||
group.add(poolMesh);
|
||
}
|
||
|
||
scene.add(group);
|
||
|
||
// ── per-venue state → frontage glow. F passes its per-venue map `gigState.byVenue`
|
||
// ({ [venueShopId]: 'quiet'|'doors'|'on'|'done' }); a Map or a { byVenue } wrapper are also accepted.
|
||
// (R14: the R12 alpha bare-string arm is retired — F wires the map since R13.) Idempotent; the shell
|
||
// calls it every street frame, so pool/glow ramps and the night baseline lerp here. ────────────────
|
||
function stateGetter(gigStates) {
|
||
if (gigStates == null) return () => 'quiet';
|
||
const map = gigStates.byVenue || gigStates; // F passes gigState.byVenue (per-venue map)
|
||
return (id) => {
|
||
const v = map instanceof Map ? map.get(id) : map[id];
|
||
if (v == null) return 'quiet';
|
||
return typeof v === 'string' ? v : (v.state || 'quiet');
|
||
};
|
||
}
|
||
function levelFor(s) { return s === 'on' ? 1 : s === 'doors' ? 0.55 : 0; }
|
||
|
||
function update(gigStates) {
|
||
const get = stateGetter(gigStates);
|
||
const night = !!(typeof window !== 'undefined' && window.PROCITY?.lighting?.getClock?.().night);
|
||
for (const v of venues) {
|
||
const lvl = levelFor(get(v.venueShopId));
|
||
// marquee/bulbs emissive follow the gig (they only matter visually near night anyway)
|
||
const emisT = lvl * 1.7;
|
||
v._emisCur += (emisT - v._emisCur) * 0.25;
|
||
if (Math.abs(emisT - v._emisCur) < 0.004) v._emisCur = emisT;
|
||
v.marqueeMat.emissiveIntensity = v._emisCur;
|
||
v.bulbMat.emissiveIntensity = v._emisCur * 1.15;
|
||
// window spill: a faint night baseline (someone's in) + the gig glow, off entirely by day
|
||
const glowT = night ? 0.14 + lvl * 0.4 : 0;
|
||
v._glowCur += (glowT - v._glowCur) * 0.15;
|
||
if (Math.abs(glowT - v._glowCur) < 0.004) v._glowCur = glowT;
|
||
v.glowMat.opacity = v._glowCur;
|
||
}
|
||
// streetlamp pools: on at night (streetlamps don't care about gigs), off by day
|
||
if (poolMat) {
|
||
const poolT = night ? 0.5 : 0;
|
||
poolMat.opacity += (poolT - poolMat.opacity) * 0.1;
|
||
if (Math.abs(poolT - poolMat.opacity) < 0.004) poolMat.opacity = poolT;
|
||
if (poolMesh) poolMesh.visible = poolMat.opacity > 0.01;
|
||
}
|
||
// posters self-light at night so the bill reads (unlit MeshStandard → black after dark). Day = 0,
|
||
// so daylight tour shots stay byte-identical; the ramp lifts every poster (frontage + spine) at night.
|
||
const posterT = night ? 0.55 : 0;
|
||
for (let i = 0; i < posterMats.length; i++) {
|
||
const pm = posterMats[i];
|
||
pm.emissiveIntensity += (posterT - pm.emissiveIntensity) * 0.1;
|
||
if (Math.abs(posterT - pm.emissiveIntensity) < 0.004) pm.emissiveIntensity = posterT;
|
||
}
|
||
}
|
||
update(null); // seed: no gig states yet → every venue quiet (frontage dark, pools follow night)
|
||
|
||
function dispose() {
|
||
for (const d of disposables) d.dispose && d.dispose();
|
||
group.traverse((o) => { if (o.isInstancedMesh) o.dispose && o.dispose(); });
|
||
scene.remove(group);
|
||
}
|
||
|
||
const queueZones = {};
|
||
for (const v of venues) queueZones[v.venueShopId] = v.queueZone;
|
||
|
||
return {
|
||
group, venues, update, dispose,
|
||
venueShopId: primaryId || (venues[0] && venues[0].venueShopId) || null, // alpha compat
|
||
venueShopIds: venues.map((v) => v.venueShopId),
|
||
queueZones,
|
||
queueZoneFor(id) { const v = venues.find((x) => x.venueShopId === id); return v ? v.queueZone : null; },
|
||
// R12 compat: the primary venue's frontage group (some callers referenced `.frontage`)
|
||
get frontage() { return venues[0] ? venues[0].frontage : null; },
|
||
get posterCount() { return posters.length; },
|
||
get posterDraws() { return posterDraws; },
|
||
};
|
||
}
|
||
|
||
const r2 = (v) => Math.round(v * 100) / 100;
|
||
const r4 = (v) => Math.round(v * 10000) / 10000;
|
||
|
||
// ── town-wide pool positions: one under every streetlight + bus shelter, derived from the plan's street
|
||
// graph. This MIRRORS furniture.js's streetlight rule (a lamp every 18 m from s=8, alternating sides, at
|
||
// off = halfRoad+0.7) so pools sit under the real lamps without importing furniture internals. Pure
|
||
// function of the plan (no rng — the cadence is deterministic), so it adds no seeded draws (release law).
|
||
function townPoolSpecs(plan) {
|
||
const specs = [];
|
||
const nodeById = new Map((plan.streets?.nodes || []).map((n) => [n.id, n]));
|
||
for (const e of (plan.streets?.edges || [])) {
|
||
const a = nodeById.get(e.a), b = nodeById.get(e.b);
|
||
if (!a || !b) continue;
|
||
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
|
||
const ux = dx / len, uz = dz / len, px = -uz, pz = ux;
|
||
const halfRoad = (e.width || 8) / 2;
|
||
let side = 1;
|
||
for (let s = 8; s < len - 4; s += 18) {
|
||
const off = (halfRoad + 0.7) * side;
|
||
specs.push({ x: a.x + ux * s + px * off, z: a.z + uz * s + pz * off, diam: 3.4 });
|
||
side = -side;
|
||
}
|
||
}
|
||
for (const st of busShelterStops(plan)) specs.push({ x: st.x, z: st.z, diam: 3.6 });
|
||
return specs;
|
||
}
|
||
|
||
// ── a warm radial streetlamp-pool sprite (procedural, no fetch): bright warm centre → transparent edge.
|
||
function poolTexture() {
|
||
const S = 128;
|
||
const c = document.createElement('canvas'); c.width = S; c.height = S;
|
||
const x = c.getContext('2d');
|
||
const g = x.createRadialGradient(S / 2, S / 2, 2, S / 2, S / 2, S / 2);
|
||
g.addColorStop(0.00, 'rgba(255,226,170,0.85)');
|
||
g.addColorStop(0.45, 'rgba(255,205,130,0.35)');
|
||
g.addColorStop(1.00, 'rgba(255,190,110,0.0)');
|
||
x.fillStyle = g; x.fillRect(0, 0, S, S);
|
||
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace;
|
||
return tex;
|
||
}
|
||
|
||
// ── per-gig 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. Under
|
||
// ?noassets we skip the fetch entirely (zero network) and keep the flat poster.
|
||
function posterTexture(assetBase, skin, band, noassets) {
|
||
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 · TONIGHT', W / 2, H * 0.72);
|
||
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
|
||
drawBandName(x, W, H, band);
|
||
if (!noassets) {
|
||
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();
|
||
}
|