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>
392 lines
20 KiB
JavaScript
392 lines
20 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)) };
|
||
}
|
||
|
||
// ── 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;
|