PROCITY/web/js/interiors/layout.js
m3ultra 8577c48594 Lane C: keeper stand pose on the counter contract (re-land on main)
buildInterior now returns room.counter = { mesh, pose, stand } and tags the counter
interactable with userData.keeperStand. stand = {x,z,ry} where a shopkeeper stands
(service side, facing the customer, rig-front = local -Z per keepers.js), clamped
in-bounds. Deterministic; purely additive.

Note: commit 4235060 carries this message but actually contains Lane A's round-2
content (shared-index race, flagged in 3e98f05). This commit lands the real Lane C
change — identical content to stray branch lane-c/keeper-stand (85438b5), which is
now redundant. Verified: 36-build sweep + 50-room soak green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:27:58 +10:00

392 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)) };
}
// ── 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);
// 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);
} 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);
// 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,
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) {
// treasures / cases beside the till, on the goods side of the counter
const side = counter.ry === 0 ? { x: counter.x + (r() < 0.5 ? -1.3 : 1.3), z: counter.z } : { x: counter.x, z: counter.z + (r() < 0.5 ? -1.3 : 1.3) };
const ry = counter.ry;
const [hw, hd] = halfExtents(f.footprint.w, f.footprint.d, ry);
const rect = rectCells(grid, side.x, side.z, hw, hd);
if (!areaFree(grid, rect)) return false;
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;
}
// ── 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;