C exposes room.stage.backline[] — 2 seeded up-stage amp slots {x,z,ry,y} (pure data) — and
drops its primitive ampStacks from placeStage. Lane D plants E's guitar_amp GLB at backline[0]
(D's primitive fallback under ?noassets): one unified backline, GLB when present.
- backline[0] = (stage.x + stage.w*0.36, deckY, stage.z - stage.d*0.22) == D's R15-verified amp
pose exactly, so plumbing D's plant to backline[0] moves nothing on screen. backline[1] = spare
stage-left mirror. ry=pi (audience-facing), y=deckY.
- Verified fresh (seed 20261990): slots up-stage, on-deck, clear of all 4 bandPoses (nearest-bass
0.87 pub / 0.70 band_room / 0.92 rsl) and all watchPoints (>=1.95 m); determinism byte-identical;
0 leftover ampStack groups; drawSweep venues 122/120/131 (was 126/124/135, -~4 draws each) <=350.
- Determinism/golden-safe: dropping the amp loop only drops draws on the 'stage' sub-stream (nothing
downstream reads it); placement snapshot excludes ampStacks; interiors aren't hashed; ?classic has
no venues. Disjoint from F's flip. LANE_C_PUB §3 amended (v3.1 marker).
C->D handshake: backline shape live. Verify with D once the GLB is wired at backline[0].
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
592 lines
33 KiB
JavaScript
592 lines
33 KiB
JavaScript
// PROCITY Lane C — the placer. Turns a recipe + room shell into a believable, non-overlapping,
|
||
// walkable arrangement of fittings + wall decor, seeded from shop.seed.
|
||
//
|
||
// Guarantees (LANE_C acceptance):
|
||
// • nothing overlaps — every fitting reserves cells in an occupancy grid before placing
|
||
// • wall art/signs don't clash — the thriftgod shuffled wall-slot system draws from one pool
|
||
// • door → counter is walkable — flood-fill verifies connectivity; blockers are pulled until it is
|
||
// • density varies by shop — count per fitting scales by recipe.clutter × area × seed
|
||
//
|
||
// Coordinates match shell.js: +Z = door/street side, −Z = back. Origin = room centre, floor y=0.
|
||
|
||
import { buildFitting } from './fittings.js';
|
||
import * as stock from './stock.js';
|
||
import { frange, shuffle } from './context.js';
|
||
|
||
const CELL = 0.4; // occupancy grid resolution (m)
|
||
const PAD = 0.12; // clearance padding around each fitting (m)
|
||
|
||
// ── grid helpers ─────────────────────────────────────────────────────────────────────
|
||
function makeGrid(W, D) {
|
||
const cols = Math.max(3, Math.round(W / CELL));
|
||
const rows = Math.max(3, Math.round(D / CELL));
|
||
const cw = W / cols, cd = D / rows;
|
||
const occ = new Uint8Array(cols * rows); // 0 free, 1 fitting, 2 wall(border)
|
||
const reserved = new Uint8Array(cols * rows); // walkable but no placement (door corridor, till front)
|
||
for (let cx = 0; cx < cols; cx++) for (let cz = 0; cz < rows; cz++)
|
||
if (cx === 0 || cz === 0 || cx === cols - 1 || cz === rows - 1) occ[cz * cols + cx] = 2;
|
||
const cellOf = (x, z) => [
|
||
Math.min(cols - 1, Math.max(0, Math.floor((x + W / 2) / cw))),
|
||
Math.min(rows - 1, Math.max(0, Math.floor((z + D / 2) / cd))),
|
||
];
|
||
return { cols, rows, cw, cd, W, D, occ, reserved, cellOf };
|
||
}
|
||
|
||
// Rotated-AABB half extents of a footprint at yaw ry.
|
||
function halfExtents(fw, fd, ry) {
|
||
const c = Math.abs(Math.cos(ry)), s = Math.abs(Math.sin(ry));
|
||
return [(c * fw + s * fd) / 2 + PAD, (s * fw + c * fd) / 2 + PAD];
|
||
}
|
||
function rectCells(grid, x, z, hw, hd) {
|
||
const a = grid.cellOf(x - hw, z - hd), b = grid.cellOf(x + hw, z + hd);
|
||
return { cx0: a[0], cz0: a[1], cx1: b[0], cz1: b[1] };
|
||
}
|
||
// Is the padded footprint clear? (wall cells value 2 are allowed — shelves hug walls.)
|
||
function areaFree(grid, rect) {
|
||
for (let cz = rect.cz0; cz <= rect.cz1; cz++)
|
||
for (let cx = rect.cx0; cx <= rect.cx1; cx++) {
|
||
const v = grid.occ[cz * grid.cols + cx];
|
||
if (v === 1) return false;
|
||
if (grid.reserved[cz * grid.cols + cx]) return false;
|
||
}
|
||
return true;
|
||
}
|
||
function stampRect(grid, rect) {
|
||
for (let cz = rect.cz0; cz <= rect.cz1; cz++)
|
||
for (let cx = rect.cx0; cx <= rect.cx1; cx++)
|
||
if (grid.occ[cz * grid.cols + cx] === 0) grid.occ[cz * grid.cols + cx] = 1;
|
||
}
|
||
function rebuildOcc(grid, placed) {
|
||
for (let i = 0; i < grid.occ.length; i++) if (grid.occ[i] === 1) grid.occ[i] = 0;
|
||
// Skip noStamp (wall-mounted) fittings — placeAtWall never stamped them at placement, so re-stamping
|
||
// here would inject phantom floor obstacles beside a wall the player can actually walk past, and
|
||
// could trigger needless fitting-pulls or a carve when the path was fine.
|
||
for (const p of placed) if (!p.noStamp) stampRect(grid, p.rect);
|
||
}
|
||
function reserveRect(grid, x0, z0, x1, z1) {
|
||
const a = grid.cellOf(x0, z0), b = grid.cellOf(x1, z1);
|
||
for (let cz = a[1]; cz <= b[1]; cz++) for (let cx = a[0]; cx <= b[0]; cx++)
|
||
grid.reserved[cz * grid.cols + cx] = 1;
|
||
}
|
||
|
||
// 4-connected flood fill over walkable cells (occ==0). Returns Set of reachable indices.
|
||
function floodFill(grid, startIdx) {
|
||
const seen = new Uint8Array(grid.cols * grid.rows);
|
||
const stack = [startIdx]; seen[startIdx] = 1;
|
||
const out = new Set([startIdx]);
|
||
while (stack.length) {
|
||
const i = stack.pop(), cx = i % grid.cols, cz = (i / grid.cols) | 0;
|
||
for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||
const nx = cx + dx, nz = cz + dz;
|
||
if (nx < 0 || nz < 0 || nx >= grid.cols || nz >= grid.rows) continue;
|
||
const ni = nz * grid.cols + nx;
|
||
if (seen[ni] || grid.occ[ni] !== 0) continue;
|
||
seen[ni] = 1; out.add(ni); stack.push(ni);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── counter placement per recipe.counterPos ──────────────────────────────────────────
|
||
function placeCounter(ctx, recipe, W, D, r) {
|
||
const pos = recipe.counterPos || 'corner';
|
||
const cw = Math.min(2.2, Math.max(1.4, W * 0.42));
|
||
let x, z, ry, front;
|
||
if (pos === 'door') { // milk bar: faces the door, front-left
|
||
x = -W * 0.12; z = D / 2 - 1.8; ry = 0; front = { x, z: z + 0.9 };
|
||
} else if (pos === 'forward') { // pawn/stall: counter up front, goods behind
|
||
x = -W * 0.05; z = D / 2 - 2.4; ry = 0; front = { x, z: z + 0.95 };
|
||
} else if (pos === 'back') { // along the back wall, facing in
|
||
x = -W * 0.1; z = -D / 2 + 0.7; ry = Math.PI; front = { x, z: z + 0.95 };
|
||
} else { // 'corner' — back-east, facing into the room
|
||
x = W / 2 - 0.55; z = -D / 4; ry = -Math.PI / 2; front = { x: x - 0.95, z };
|
||
}
|
||
const f = buildFitting('counter', ctx, { w: cw }, r);
|
||
f.group.position.set(x, 0, z); f.group.rotation.y = ry;
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
|
||
const stand = keeperStand(x, z, front, W, D);
|
||
return { fitting: f, x, z, ry, hw, hd, front, stand, priority: 9, kind: 'counter' };
|
||
}
|
||
|
||
// The pose a shopkeeper (Lane D) stands at: behind the bench on the service side (opposite the
|
||
// customer-facing `front`), facing the customer. Clamped inside the room so a corner/back-wall
|
||
// counter keeper never clips through a wall. `ry` uses the rig-front = local −Z convention that
|
||
// keepers.js expects (spawn's ry is "the counter's outward facing"). Deterministic — a pure
|
||
// function of the (already-seeded) counter pose.
|
||
function keeperStand(cx, cz, front, W, D) {
|
||
let dx = front.x - cx, dz = front.z - cz; // counter → customer
|
||
const len = Math.hypot(dx, dz) || 1; dx /= len; dz /= len;
|
||
const off = 0.5; // stand this far back from the bench centre
|
||
let x = cx - dx * off, z = cz - dz * off; // opposite the customer side (behind the bench)
|
||
const mx = W / 2 - 0.45, mz = D / 2 - 0.45; // keep clear of the walls
|
||
x = Math.max(-mx, Math.min(mx, x));
|
||
z = Math.max(-mz, Math.min(mz, z));
|
||
return { x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)) };
|
||
}
|
||
|
||
// ── browse points (round 9): where Lane D stands browser rigs ─────────────────────────
|
||
// 1–3 seeded floor poses, aisle-adjacent, in front of a browsable fitting, facing the goods. Pure
|
||
// data (no meshes, no draws) — mirrors counter.stand so keepers.js/fleet clones drop straight in.
|
||
// Every point is walkable (occ-free, not reserved), reachable from spawn (same flood-fill as
|
||
// door→counter), ≥MIN_SEP from each other AND the keeper stand. Deterministic per shop.seed.
|
||
const MIN_SEP = 0.6; // min spacing between rigs (m)
|
||
const STAND_GAP = 0.55; // how far in front of a fitting a browser stands (m)
|
||
function placeBrowsePoints(grid, placed, counterStand, spawnCell, r) {
|
||
// reachable floor cells from spawn (adjust off an occupied spawn cell like reaches() does)
|
||
let start = spawnCell;
|
||
if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
|
||
const reach = floodFill(grid, start);
|
||
|
||
// browsable = surviving floor fittings (not the counter, not wall-mounted). Prefer ones that actually
|
||
// carry interactable stock (bins/cases have `places`) so browsers cluster at the goods, then bigger
|
||
// fittings, then a seeded tiebreak — all pure functions of seed/geometry (determinism holds).
|
||
const cand = placed
|
||
.filter(p => !p.removed && !p.noStamp && p.kind !== 'counter')
|
||
.map(p => ({ p, jitter: r() })) // draw seeded keys up front so ordering is seed-stable
|
||
.sort((a, b) => {
|
||
const ap = (a.p.fitting.places || []).length ? 1 : 0, bp = (b.p.fitting.places || []).length ? 1 : 0;
|
||
if (ap !== bp) return bp - ap;
|
||
const aa = a.p.hw * a.p.hd, ba = b.p.hw * b.p.hd;
|
||
if (Math.abs(aa - ba) > 0.05) return ba - aa;
|
||
return a.jitter - b.jitter;
|
||
})
|
||
.map(o => o.p);
|
||
|
||
const pts = [];
|
||
const near = (x, z, list, d) => list.some(q => Math.hypot(q.x - x, q.z - z) < d);
|
||
// Is (x,z) a valid, unclaimed stand? (walkable free floor · reachable · clear of walls/keeper/other rigs)
|
||
const validStand = (x, z) => {
|
||
const [cx, cz] = grid.cellOf(x, z);
|
||
if (cx < 1 || cz < 1 || cx >= grid.cols - 1 || cz >= grid.rows - 1) return false;
|
||
const idx = cz * grid.cols + cx;
|
||
if (grid.occ[idx] !== 0 || grid.reserved[idx]) return false;
|
||
if (!reach.has(idx)) return false;
|
||
if (counterStand && Math.hypot(counterStand.x - x, counterStand.z - z) < MIN_SEP) return false;
|
||
if (near(x, z, pts, MIN_SEP)) return false;
|
||
return true;
|
||
};
|
||
const push = (x, z, p) => {
|
||
const dx = p.x - x, dz = p.z - z; // stand → fitting; face it (counter.stand ry convention)
|
||
pts.push({ x: round(x), z: round(z), ry: round(Math.atan2(-dx, -dz)), atKind: p.kind, slotIndex: pts.length });
|
||
};
|
||
// Stand candidates around a fitting: 4 sides at two gaps + 4 diagonals, tried toward-room-centre
|
||
// first (the open aisle) so a browser faces OUT of a wall-hugging shelf, never into the wall. Richer
|
||
// than a single ring so a valid cell survives even in a tight/pokey room where one side is blocked.
|
||
const standsAround = (p) => {
|
||
const tX = p.x > 0 ? -1 : 1, gW = p.hw + STAND_GAP, gW2 = p.hw + STAND_GAP + 0.35, gD = p.hd + STAND_GAP;
|
||
return [
|
||
{ x: p.x + tX * gW, z: p.z }, { x: p.x, z: p.z + gD },
|
||
{ x: p.x + tX * gW, z: p.z + 0.5 }, { x: p.x + tX * gW, z: p.z - 0.5 },
|
||
{ x: p.x + tX * gW2, z: p.z }, { x: p.x, z: p.z - gD },
|
||
{ x: p.x - tX * gW, z: p.z }, { x: p.x - tX * gW, z: p.z + 0.5 },
|
||
];
|
||
};
|
||
for (const p of cand) {
|
||
if (pts.length >= 3) break;
|
||
for (const s of standsAround(p)) {
|
||
if (!validStand(s.x, s.z)) continue;
|
||
push(s.x, s.z, p); break; // one point per fitting
|
||
}
|
||
}
|
||
// Fallback: a room with browsable goods but no point yet (all fitting sides boxed in — tight pokey/
|
||
// mismatched archetypes) still deserves ≥1 browser. Scan the reachable free floor for the cell nearest
|
||
// a browsable fitting and stand there, facing that fitting. Deterministic (grid + cand order are seeded).
|
||
if (pts.length === 0 && cand.length) {
|
||
let best = null;
|
||
for (const idx of reach) {
|
||
if (grid.occ[idx] !== 0 || grid.reserved[idx]) continue;
|
||
const cx = idx % grid.cols, cz = (idx / grid.cols) | 0;
|
||
const x = (cx + 0.5) * grid.cw - grid.W / 2, z = (cz + 0.5) * grid.cd - grid.D / 2;
|
||
if (counterStand && Math.hypot(counterStand.x - x, counterStand.z - z) < MIN_SEP) continue;
|
||
for (const p of cand) {
|
||
const d = Math.hypot(p.x - x, p.z - z);
|
||
if (d < 0.5) continue; // not on top of the fitting
|
||
if (!best || d < best.d) best = { x, z, p, d };
|
||
}
|
||
}
|
||
if (best) push(best.x, best.z, best.p);
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
// ── VENUE stage (v3, ?gigs) ───────────────────────────────────────────────────────────
|
||
// Places a low stage centred on the back wall + PA cabinets flanking it on the floor (both into `placed` at
|
||
// priority 8 so the door→counter path loop never pulls them). Returns the stage frame the crowd faces, the
|
||
// 4 band poses on the deck, and `backline[]` — up-stage amp slots (v3.1, ledger #5) where Lane D plants E's
|
||
// guitar_amp GLB. C no longer plants its own primitive amps: one unified backline, GLB when present, D's
|
||
// primitive fallback under ?noassets. Pure pose data; Lane D drops the rigs + amp in.
|
||
function placeStage(ctx, grid, W, D, roomGroup, r, spec = {}) {
|
||
const wf = spec.deckWidthFrac || 0.58, deckH = spec.deckH || 0.32; // per-kind (round 13): band_room
|
||
const sw = Math.min(W - 1.4, Math.max(3.0, W * wf)), sd = spec.deckDepth || 2.0; // barely a riser, rsl bigger
|
||
const cx = 0, cz = -D / 2 + sd / 2 + 0.2; // back wall, centred
|
||
const f = buildFitting('stage', ctx, { w: sw, d: sd, h: deckH }, r);
|
||
f.group.position.set(cx, 0, cz); roomGroup.add(f.group);
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, 0);
|
||
const rect = rectCells(grid, cx, cz, hw, hd); stampRect(grid, rect);
|
||
const out = [{ fitting: f, x: cx, z: cz, ry: 0, hw, hd, rect, priority: 8, kind: 'stage' }];
|
||
|
||
for (const sgn of [-1, 1]) { // PA cabinets at the stage front corners, pulled
|
||
const px = sgn * (sw / 2 - 0.3), pz = cz + sd / 2 + 0.35; // in + just off the deck lip
|
||
const [phw, phd] = halfExtents(0.6, 0.55, 0); // paSpeaker footprint (fixed)
|
||
const prect = rectCells(grid, px, pz, phw, phd);
|
||
if (!areaFree(grid, prect)) continue; // never stamp over the counter's service strip / a wall
|
||
const pf = buildFitting('paSpeaker', ctx, {}, r);
|
||
pf.group.position.set(px, 0, pz); roomGroup.add(pf.group);
|
||
stampRect(grid, prect);
|
||
out.push({ fitting: pf, x: px, z: pz, ry: 0, hw: phw, hd: phd, rect: prect, priority: 8, kind: 'prop' });
|
||
}
|
||
|
||
const deckY = f.deckY || deckH; // deck top surface (band + backline stand height)
|
||
|
||
// 4-PIECE BAND (round 13, debt #3): front-line trio across the deck lip + a drummer up-stage on the
|
||
// riser. ry=π faces the audience (+Z) under the rig-front=−Z house convention (see LANE_C_PUB.md). Each
|
||
// pose carries its own stand height `y` — the front line on the deck (deckY), the drummer on the drum
|
||
// riser (deckY+0.16, the fittings.js stage riser). D lifts each member to pose.y (was: stage.deckY).
|
||
const riserY = round(deckY + 0.16); // drum riser top surface (fittings.js: 0.16 m box)
|
||
const front = [-sw * 0.28, 0, sw * 0.28].map((bx, i) => ({
|
||
x: round(cx + bx), z: round(cz + sd * 0.12), ry: round(Math.PI), role: ['guitar', 'vocal', 'bass'][i], y: round(deckY),
|
||
}));
|
||
const drums = { x: round(cx), z: round(cz - sd / 4), ry: round(Math.PI), role: 'drums', y: riserY, seated: true };
|
||
const bandPoses = [...front, drums];
|
||
|
||
// BACKLINE (v3.1, ledger #5): up-stage amp/spare-cab slots — the unified backline that replaces C's old
|
||
// primitive ampStacks. Pure data (no meshes): Lane D plants E's guitar_amp GLB here (primitive fallback
|
||
// under ?noassets). Up-stage of the front line, flanking the drum riser, clear of every bandPose AND all
|
||
// watchPoints (which sit downstage of frontZ), on-deck. slot[0] = stage-right (primary), slot[1] = spare
|
||
// stage-left. ry=π faces the audience (+Z), same −Z convention as the band; y = deckY. Deterministic.
|
||
// slot[0] == D's R15-verified guitar_amp pose (stage.w·0.36, deckY, stage.z − stage.d·0.22, 0.87 m bass
|
||
// clearance), so plumbing D's plant to backline[0] moves nothing on screen.
|
||
const backline = [sw * 0.36, -sw * 0.36].map(bx => ({
|
||
x: round(cx + bx), z: round(cz - sd * 0.22), ry: round(Math.PI), y: round(deckY),
|
||
}));
|
||
return { placed: out, stage: { x: cx, z: cz, w: sw, d: sd, deckY, riserY, frontZ: round(cz + sd / 2), bandPoses, backline } };
|
||
}
|
||
|
||
// ── WATCH POINTS (venue) — seeded audience floor poses facing the stage ──
|
||
// Same pure-data pattern as browse points: walkable free cells, reachable from spawn, clear of the keeper
|
||
// stand + browse points + stage, spaced for a crowd. `dance` seeded ~1/3 true (John: "a bit of both").
|
||
// `watchMax` = the crowd cap per kind (round 13): pub/band_room 8, RSL 12 (the crowd-cap stress case).
|
||
// The room fills what geometry allows up to the cap; the count IS the crowd cap (F asserts crowd ≤ it).
|
||
function placeWatchPoints(grid, stage, spawnCell, counterStand, browsePoints, r, watchMax = 8) {
|
||
let start = spawnCell;
|
||
if (grid.occ[start] !== 0) { const s = nearestFree(grid, start); if (s < 0) return []; start = s; }
|
||
const reach = floodFill(grid, start);
|
||
const avoid = [counterStand, ...(browsePoints || [])].filter(Boolean);
|
||
const near = (x, z, list, d) => list.some(q => Math.hypot(q.x - x, q.z - z) < d);
|
||
const cands = [];
|
||
for (let z = stage.frontZ + 0.6; z <= grid.D / 2 - 1.6; z += 0.7) // between stage lip and the door zone
|
||
for (let x = -grid.W / 2 + 0.8; x <= grid.W / 2 - 0.8; x += 0.8) cands.push({ x, z });
|
||
for (const c of cands) c.key = r(); // seed the visiting order up front (determinism)
|
||
cands.sort((a, b) => a.key - b.key);
|
||
const pts = [];
|
||
const valid = (x, z) => {
|
||
const [cx, cz] = grid.cellOf(x, z);
|
||
if (cx < 1 || cz < 1 || cx >= grid.cols - 1 || cz >= grid.rows - 1) return false;
|
||
const idx = cz * grid.cols + cx;
|
||
if (grid.occ[idx] !== 0 || grid.reserved[idx] || !reach.has(idx)) return false;
|
||
if (near(x, z, avoid, MIN_SEP) || near(x, z, pts, 0.7)) return false;
|
||
return true;
|
||
};
|
||
for (const c of cands) {
|
||
if (pts.length >= watchMax) break;
|
||
if (!valid(c.x, c.z)) continue;
|
||
const dx = stage.x - c.x, dz = stage.z - c.z; // face the stage centre (counter.stand ry convention)
|
||
pts.push({ x: round(c.x), z: round(c.z), ry: round(Math.atan2(-dx, -dz)), dance: r() < 0.34, slotIndex: pts.length });
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
// ── wall-slot system (shuffled pool → art + signs + wall fittings never overlap) ──────
|
||
function makeWallSlots(W, D, r) {
|
||
const slots = [];
|
||
const nZ = Math.max(2, Math.floor((D - 3.2) / 1.7));
|
||
for (let i = 0; i < nZ; i++) {
|
||
const z = -D / 2 + 1.6 + i * ((D - 3.4) / Math.max(1, nZ - 1));
|
||
slots.push({ wall: 'west', x: -W / 2, z, ry: Math.PI / 2, used: false });
|
||
slots.push({ wall: 'east', x: W / 2, z, ry: -Math.PI / 2, used: false });
|
||
}
|
||
const nX = Math.max(1, Math.floor((W - 3.0) / 1.7));
|
||
for (let i = 0; i < nX; i++) {
|
||
const x = -W / 2 + 1.5 + i * ((W - 3.0) / Math.max(1, nX - 1));
|
||
slots.push({ wall: 'north', x, z: -D / 2, ry: 0, used: false });
|
||
}
|
||
shuffle(r, slots);
|
||
return slots;
|
||
}
|
||
|
||
// ── free-standing candidate positions per zone ────────────────────────────────────────
|
||
function zoneCandidates(zone, W, D, doorCx, r) {
|
||
const out = [];
|
||
if (zone === 'window') {
|
||
const z = D / 2 - 1.4;
|
||
for (let x = -W / 2 + 1.1; x <= doorCx - 1.2; x += 1.3) out.push({ x, z, ry: Math.PI });
|
||
} else if (zone === 'aisle') {
|
||
for (let z = D / 2 - 3.0; z >= -D / 2 + 1.6; z -= 1.7)
|
||
for (let x = -W / 2 + 1.3; x <= W / 2 - 1.3; x += 1.9) out.push({ x, z, ry: 0 });
|
||
} else if (zone === 'centre') {
|
||
for (let z = D / 2 - 3.5; z >= -D / 2 + 2.2; z -= 2.0)
|
||
for (let x = -W / 4; x <= W / 4; x += 1.8) out.push({ x, z, ry: 0 });
|
||
}
|
||
return shuffle(r, out);
|
||
}
|
||
|
||
// ── main entry ────────────────────────────────────────────────────────────────────────
|
||
export function layout(ctx, { recipe, dims, shell, adapter, shop }) {
|
||
const { W, D } = dims;
|
||
const doorCx = shell.door.x;
|
||
const grid = makeGrid(W, D);
|
||
const rCount = ctx.stream('lay-count'), rPlace = ctx.stream('lay-place'), rWall = ctx.stream('lay-wall');
|
||
|
||
// Reserve the door corridor (walkable, no fittings) and the spawn cell.
|
||
reserveRect(grid, doorCx - 0.7, D / 2 - 2.0, doorCx + 0.7, D / 2 - 0.4);
|
||
|
||
const placed = [];
|
||
const roomGroup = shell.group;
|
||
|
||
// 1) COUNTER first (always present, reachability target).
|
||
const counter = placeCounter(ctx, recipe, W, D, rCount);
|
||
counter.rect = rectCells(grid, counter.x, counter.z, counter.hw, counter.hd);
|
||
stampRect(grid, counter.rect);
|
||
placed.push(counter);
|
||
// Surface the keeper stand pose on the counter's interactable so Lane D (keepers.js) can spawn a
|
||
// shopkeeper behind it without guessing which side is the service side. Also exposed top-level as
|
||
// room.counter.stand (see interiors.js). The counter is never path-pulled, so this always survives.
|
||
counter.fitting.group.userData.keeperStand = counter.stand;
|
||
// reserve the strip in front of the till so nothing blocks service
|
||
reserveRect(grid, counter.front.x - 0.5, counter.front.z - 0.5, counter.front.x + 0.5, counter.front.z + 0.5);
|
||
|
||
// 1b) VENUE STAGE (pub, round 12) — placed BEFORE the fitting mix so its footprint is reserved. Only
|
||
// for recipe.venue; other rooms never touch the 'stage'/'watch' streams → flags-off byte-identical.
|
||
let stageInfo = null, watchPoints = [];
|
||
if (recipe.venue) {
|
||
const st = placeStage(ctx, grid, W, D, roomGroup, ctx.stream('stage'), recipe.venueSpec);
|
||
stageInfo = st.stage;
|
||
for (const sp of st.placed) placed.push(sp);
|
||
}
|
||
|
||
// 2) FITTINGS from the recipe mix (skip the counter entry — already placed).
|
||
const area = W * D;
|
||
const wallSlots = makeWallSlots(W, D, rWall);
|
||
for (const spec of recipe.fittings) {
|
||
if (spec.kind === 'counter') continue;
|
||
// count scales with clutter × area, clamped to [min,max]
|
||
const density = recipe.clutter * (area / 70);
|
||
let n = Math.round(frange(rCount, spec.min, spec.max + 0.99) * Math.min(1.3, density));
|
||
n = Math.max(spec.min, Math.min(spec.max, n));
|
||
for (let i = 0; i < n; i++) {
|
||
const f = buildFitting(spec.kind, ctx, {}, ctx.stream(`fit-${spec.kind}-${i}`));
|
||
let ok = false;
|
||
if (spec.zone === 'wall' || f.wallMounted) {
|
||
ok = placeAtWall(ctx, grid, f, wallSlots, rPlace, placed, spec);
|
||
} else if (spec.zone === 'counter') {
|
||
ok = placeNearCounter(ctx, grid, f, counter, rPlace, placed, spec, W, D, doorCx);
|
||
} else {
|
||
ok = placeFree(ctx, grid, f, spec.zone, W, D, doorCx, rPlace, placed, spec);
|
||
}
|
||
if (!ok) { /* no room — density self-caps; drop the fitting (its resources free at room end) */ }
|
||
}
|
||
}
|
||
|
||
// 3) PATH GUARANTEE: door → counter. Pull low-priority blockers until reachable; carve if we must.
|
||
const spawnCell = idxOf(grid, shell.spawn.x, shell.spawn.z);
|
||
const targetCell = idxOf(grid, counter.front.x, counter.front.z);
|
||
let pathOK = reaches(grid, spawnCell, targetCell);
|
||
let carved = false;
|
||
const removable = () => placed
|
||
.filter(p => p.priority < 8 && p.kind !== 'counter' && !p.noStamp) // wall-mounted don't block the floor
|
||
.sort((a, b) => a.priority - b.priority);
|
||
while (!pathOK) {
|
||
const pool = removable();
|
||
if (!pool.length) break;
|
||
const victim = pool[0];
|
||
placed.splice(placed.indexOf(victim), 1);
|
||
if (victim.fitting.group.parent) victim.fitting.group.parent.remove(victim.fitting.group);
|
||
victim.removed = true;
|
||
rebuildOcc(grid, placed);
|
||
pathOK = reaches(grid, spawnCell, targetCell);
|
||
}
|
||
if (!pathOK) { carveCorridor(grid, spawnCell, targetCell); carved = true; pathOK = reaches(grid, spawnCell, targetCell); }
|
||
|
||
// 4) add SURVIVORS to the room + fill them with visual stock. Collect `places` here (NOT at
|
||
// placement time) so interactables belong only to fittings that actually made it into the room —
|
||
// a fitting pulled by the path loop must not leave a phantom interactable in the returned list.
|
||
const places = [...(shell.places || [])];
|
||
for (const p of placed) {
|
||
if (p.removed) continue;
|
||
if (!p.fitting.group.parent) roomGroup.add(p.fitting.group);
|
||
stock.fill(ctx, p.fitting, { shop, recipe, stockKind: recipe.stockKind, adapter }, ctx.stream(`stk-${p.priority}-${p.x.toFixed(2)}-${p.z.toFixed(2)}`));
|
||
places.push(...(p.fitting.places || []));
|
||
}
|
||
|
||
// 5) WALL DECOR — art frames + inside signage on leftover wall slots (shuffled, never overlap).
|
||
const decor = placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, rWall);
|
||
|
||
// 6) BROWSE POINTS (round 9) — 1–3 seeded floor poses for Lane D's browser rigs, in front of the
|
||
// goods, reachable + non-blocking. Pure data (no geometry). Computed AFTER the path guarantee so
|
||
// every point sits on a survivor's open side and on a cell reachable from the door.
|
||
const browsePoints = placeBrowsePoints(grid, placed, counter.stand, spawnCell, ctx.stream('browse'));
|
||
|
||
// 6b) WATCH POINTS (venue, round 12) — audience floor poses facing the stage, computed after the path
|
||
// guarantee + browse points (so they avoid both), on cells reachable from the door.
|
||
if (stageInfo) watchPoints = placeWatchPoints(grid, stageInfo, spawnCell, counter.stand, browsePoints, ctx.stream('watch'), (recipe.venueSpec && recipe.venueSpec.watchMax) || 8);
|
||
|
||
// placement summary for the determinism deep-equal test
|
||
const placement = placed.filter(p => !p.removed).map(p => ({
|
||
kind: p.kind || 'fitting', kindName: p.fitting.group.userData.kind,
|
||
x: round(p.x), z: round(p.z), ry: round(p.ry), fw: round(p.fitting.footprint.w), fd: round(p.fitting.footprint.d),
|
||
})).concat(decor.summary);
|
||
|
||
return {
|
||
placed: placed.filter(p => !p.removed),
|
||
places, decor: decor.meshes, grid, pathOK, carved,
|
||
spawnCell, targetCell, placement, browsePoints,
|
||
watchPoints, // venue: audience poses {x,z,ry,dance} facing the stage, cap by kind (pub/band_room 8, rsl 12); else []
|
||
stage: stageInfo, // venue: { x,z,w,d,deckY,frontZ,bandPoses } (else null)
|
||
counter: {
|
||
mesh: counter.fitting.group,
|
||
pose: { x: round(counter.x), z: round(counter.z), ry: round(counter.ry) }, // the bench itself
|
||
stand: counter.stand, // where the keeper stands
|
||
},
|
||
};
|
||
}
|
||
|
||
// ── placement strategies ──────────────────────────────────────────────────────────────
|
||
function placeFree(ctx, grid, f, zone, W, D, doorCx, r, placed, spec) {
|
||
const cands = zoneCandidates(zone, W, D, doorCx, r);
|
||
for (const c of cands) {
|
||
const ry = c.ry + (r() - 0.5) * 0.4;
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
|
||
const rect = rectCells(grid, c.x, c.z, hw, hd);
|
||
if (!areaFree(grid, rect)) continue;
|
||
f.group.position.set(c.x, 0, c.z); f.group.rotation.y = ry;
|
||
stampRect(grid, rect);
|
||
placed.push({ fitting: f, x: c.x, z: c.z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind });
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function placeAtWall(ctx, grid, f, wallSlots, r, placed, spec) {
|
||
const backOff = f.wallMounted ? 0.06 : f.footprint.d / 2 + 0.05;
|
||
for (const slot of wallSlots) {
|
||
if (slot.used) continue;
|
||
const ry = slot.ry;
|
||
let x = slot.x, z = slot.z;
|
||
if (slot.wall === 'west') x += backOff; else if (slot.wall === 'east') x -= backOff; else z += backOff;
|
||
const y = f.wallMounted ? (f.mountY || 1.4) : 0;
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
|
||
const rect = rectCells(grid, x, z, hw, hd);
|
||
if (!f.wallMounted && !areaFree(grid, rect)) continue;
|
||
f.group.position.set(x, y, z); f.group.rotation.y = ry;
|
||
if (!f.wallMounted) stampRect(grid, rect);
|
||
slot.used = true;
|
||
// noStamp: wall-mounted fittings hang above head height and don't block the floor — keep rebuildOcc
|
||
// consistent with this placement (see rebuildOcc).
|
||
placed.push({ fitting: f, x, z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind, noStamp: f.wallMounted });
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function placeNearCounter(ctx, grid, f, counter, r, placed, spec, W, D, doorCx) {
|
||
// cases beside the till, on the goods side of the counter — try BOTH sides + a couple offsets, then
|
||
// fall back to a free-standing aisle spot so a min≥1 fitting (e.g. a glass case) reliably lands.
|
||
const ry = counter.ry;
|
||
const cands = counter.ry === 0
|
||
? [{ x: counter.x - 1.4, z: counter.z }, { x: counter.x + 1.4, z: counter.z }, { x: counter.x - 1.4, z: counter.z - 0.7 }, { x: counter.x + 1.4, z: counter.z - 0.7 }]
|
||
: [{ x: counter.x, z: counter.z - 1.4 }, { x: counter.x, z: counter.z + 1.4 }, { x: counter.x - 0.7, z: counter.z - 1.4 }, { x: counter.x - 0.7, z: counter.z + 1.4 }];
|
||
shuffle(r, cands);
|
||
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
|
||
for (const side of cands) {
|
||
const rect = rectCells(grid, side.x, side.z, hw, hd);
|
||
if (!areaFree(grid, rect)) continue;
|
||
f.group.position.set(side.x, 0, side.z); f.group.rotation.y = ry;
|
||
stampRect(grid, rect);
|
||
placed.push({ fitting: f, x: side.x, z: side.z, ry, hw, hd, rect, priority: spec.priority, kind: spec.kind });
|
||
return true;
|
||
}
|
||
// counter is boxed in — put it in the aisle instead (still front-of-room-ish)
|
||
return placeFree(ctx, grid, f, 'aisle', W, D, doorCx, r, placed, spec);
|
||
}
|
||
|
||
// ── wall decor (art + signage) ─────────────────────────────────────────────────────────
|
||
function placeWallDecor(ctx, roomGroup, recipe, wallSlots, W, D, r) {
|
||
const meshes = [], summary = [];
|
||
const free = wallSlots.filter(s => !s.used);
|
||
const nArt = Math.min(free.length, 2 + (r() * 3 | 0));
|
||
const nSign = Math.min(Math.max(0, free.length - nArt), 1 + (r() * 2 | 0));
|
||
let fi = 0;
|
||
for (let i = 0; i < nArt && fi < free.length; i++, fi++) {
|
||
const slot = free[fi];
|
||
const sz = 0.7 + r() * 0.5;
|
||
const artCol = new ctx.THREE.Color().setHSL(r(), 0.32, 0.42);
|
||
const frame = ctx.box(sz + 0.08, sz + 0.08, 0.04, ctx.mat('#4a3a28', 0.6), 0, 0, 0, roomGroup);
|
||
const canvas = ctx.plane(sz, sz, ctx.mat(artCol, 0.75), roomGroup);
|
||
// inward normal per wall so the canvas sits a hair in front of its frame (no z-fight)
|
||
const nx = slot.wall === 'west' ? 1 : slot.wall === 'east' ? -1 : 0;
|
||
const nz = slot.wall === 'north' ? 1 : 0;
|
||
const y = 1.5 + r() * 0.3, tilt = (r() - 0.5) * 0.05;
|
||
const baseX = slot.wall === 'west' ? -W / 2 + 0.04 : slot.wall === 'east' ? W / 2 - 0.04 : slot.x;
|
||
const baseZ = slot.wall === 'north' ? -D / 2 + 0.04 : slot.z;
|
||
frame.position.set(baseX, y, baseZ); frame.rotation.y = slot.ry; frame.rotation.z = tilt;
|
||
canvas.position.set(baseX + nx * 0.03, y, baseZ + nz * 0.03); canvas.rotation.y = slot.ry; canvas.rotation.z = tilt;
|
||
meshes.push(frame, canvas);
|
||
summary.push({ kind: 'art', wall: slot.wall, x: round(slot.x), z: round(slot.z) });
|
||
}
|
||
const signs = recipe.signFlavour || [];
|
||
for (let i = 0; i < nSign && fi < free.length; i++, fi++) {
|
||
const slot = free[fi];
|
||
const txt = signs[(r() * signs.length) | 0] || 'SALE';
|
||
const mesh = makeSignPlane(ctx, txt);
|
||
const off = 0.05;
|
||
const x = slot.wall === 'west' ? -W / 2 + off : slot.wall === 'east' ? W / 2 - off : slot.x;
|
||
const z = slot.wall === 'north' ? -D / 2 + off : slot.z;
|
||
mesh.position.set(x, 2.05, z); mesh.rotation.y = slot.ry;
|
||
roomGroup.add(mesh);
|
||
meshes.push(mesh);
|
||
summary.push({ kind: 'sign', wall: slot.wall, text: txt, x: round(slot.x), z: round(slot.z) });
|
||
}
|
||
return { meshes, summary };
|
||
}
|
||
|
||
function makeSignPlane(ctx, text) {
|
||
const cv = document.createElement('canvas'); cv.width = 256; cv.height = 64;
|
||
const x = cv.getContext('2d');
|
||
x.fillStyle = '#f5f2ea'; x.fillRect(0, 0, 256, 64);
|
||
x.strokeStyle = '#333'; x.lineWidth = 3; x.strokeRect(3, 3, 250, 58);
|
||
x.fillStyle = '#c1121f'; x.font = 'bold 30px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
|
||
x.fillText(String(text).slice(0, 18), 128, 34);
|
||
const tex = ctx.canvasTexture(cv);
|
||
const m = ctx.mat('#ffffff', 0.7); m.map = tex; m.needsUpdate = true;
|
||
const mesh = ctx.plane(1.3, 0.32, m);
|
||
return mesh;
|
||
}
|
||
|
||
// ── small utils ─────────────────────────────────────────────────────────────────────
|
||
function idxOf(grid, x, z) { const [cx, cz] = grid.cellOf(x, z); return cz * grid.cols + cx; }
|
||
function reaches(grid, startIdx, targetIdx) {
|
||
if (grid.occ[startIdx] !== 0) { const s = nearestFree(grid, startIdx); if (s < 0) return false; startIdx = s; }
|
||
if (grid.occ[targetIdx] !== 0) { const t = nearestFree(grid, targetIdx); if (t < 0) return false; targetIdx = t; }
|
||
return floodFill(grid, startIdx).has(targetIdx);
|
||
}
|
||
function nearestFree(grid, idx) {
|
||
const cx0 = idx % grid.cols, cz0 = (idx / grid.cols) | 0;
|
||
for (let rad = 1; rad < 6; rad++)
|
||
for (let dz = -rad; dz <= rad; dz++) for (let dx = -rad; dx <= rad; dx++) {
|
||
const nx = cx0 + dx, nz = cz0 + dz;
|
||
if (nx < 1 || nz < 1 || nx >= grid.cols - 1 || nz >= grid.rows - 1) continue;
|
||
if (grid.occ[nz * grid.cols + nx] === 0) return nz * grid.cols + nx;
|
||
}
|
||
return -1;
|
||
}
|
||
// Clear a Manhattan corridor between two cells (last-resort guarantee).
|
||
function carveCorridor(grid, startIdx, targetIdx) {
|
||
let cx = startIdx % grid.cols, cz = (startIdx / grid.cols) | 0;
|
||
const tx = targetIdx % grid.cols, tz = (targetIdx / grid.cols) | 0;
|
||
const clear = (x, z) => { if (x > 0 && z > 0 && x < grid.cols - 1 && z < grid.rows - 1) grid.occ[z * grid.cols + x] = 0; };
|
||
while (cz !== tz) { clear(cx, cz); cz += Math.sign(tz - cz); }
|
||
while (cx !== tx) { clear(cx, cz); cx += Math.sign(tx - cx); }
|
||
clear(tx, tz);
|
||
}
|
||
const round = (v) => Math.round(v * 1000) / 1000;
|