#!/usr/bin/env node
* Brooklyn-99 office map generator (TV-show offices Phase 2 — engine/structure).
*
* Authors src/renderer/src/assets/maps/brooklyn99.tmj — a precinct bullpen:
* an open floor of detective desks, Captain Holt's glass office in the back
* corner (desk-ceo), a briefing room (boardroom zone), and a break room
* (cafeteria zone) with the coffee economy. PLACEHOLDER ART: it reuses the
* office tileset gids (floor/wall/desk/monitor) so the STRUCTURE + switch flow
* are provable; Pam's license-clean B99 tileset + cast likenesses drop into the
* theme.tilesets / theme.cast seams later. Re-run after a layout tweak:
* node tools/gen-b99-map.cjs
*
* The desk "stamp" (monitor block + surface + collision, seat forced walkable)
* is copied verbatim from the office map's pc-desk so DeskScreen lights up and
* the cup spot lands exactly as in the office. A flood-fill validator asserts
* every seat / coffee stand / spawn is reachable from the entrance before write.
*/
const fs = require('fs');
const path = require('path');
const W = 36, H = 24, TS = 16;
const OUT = path.join(__dirname, '..', 'src', 'renderer', 'src', 'assets', 'maps', 'brooklyn99.tmj');
const WALL_GID = 570;
const floorGid = (x, y) =>
(y % 2 === 0) ? (x % 2 === 0 ? 800 : 799) : (x % 2 === 0 ? 784 : 783);
const DESK_STAMP = [
['below', -1, -1, 2], ['below', 0, -1, 3], ['below', 1, -1, 4],
['below', 0, 0, 289], ['below', 0, 1, 305],
['above', 0, -2, 365], ['above', 1, -2, 366],
['above', 0, -1, 381], ['above', 1, -1, 382],
['above', -1, 0, 18], ['above', 0, 0, 19], ['above', 1, 0, 20],
['coll', 0, -2, 1], ['coll', 1, -2, 1],
['coll', -1, -1, 1], ['coll', 0, -1, 1], ['coll', 1, -1, 1],
['coll', -1, 0, 1], ['coll', 1, 0, 1],
];
const mk = () => new Array(W * H).fill(0);
const L = { floor: mk(), walls: mk(), below: mk(), above: mk(), coll: mk() };
const idx = (x, y) => y * W + x;
const inb = (x, y) => x >= 0 && y >= 0 && x < W && y < H;
const set = (layer, x, y, gid) => { if (inb(x, y)) L[layer][idx(x, y)] = gid; };
const wall = (x, y) => { set('walls', x, y, WALL_GID); set('coll', x, y, 1); };
const door = (x, y) => { set('walls', x, y, 0); set('coll', x, y, 0); };
for (let y = 1; y < H - 1; y++)
for (let x = 1; x < W - 1; x++) set('floor', x, y, floorGid(x, y));
for (let x = 0; x < W; x++) { wall(x, 0); wall(x, H - 1); }
for (let y = 0; y < H; y++) { wall(0, y); wall(W - 1, y); }
const ENTRANCE = { x: 17, y: 22 };
door(17, H - 1); door(18, H - 1);
set('floor', 17, H - 1, floorGid(17, H - 1));
set('floor', 18, H - 1, floorGid(18, H - 1));
for (let y = 1; y <= 8; y++) wall(11, y);
for (let x = 1; x <= 11; x++) wall(x, 8);
door(5, 8); door(6, 8);
for (let y = 1; y <= 8; y++) wall(27, y);
for (let x = 27; x <= 34; x++) wall(x, 8);
door(30, 8); door(31, 8);
for (let y = 9; y <= 22; y++) wall(27, y);
door(27, 11); door(27, 12);
const SEATS = {
'desk-ceo': { x: 31, y: 5 },
'pc-1': { x: 5, y: 13 }, 'pc-2': { x: 10, y: 13 }, 'pc-3': { x: 15, y: 13 }, 'pc-4': { x: 20, y: 13 },
'pc-5': { x: 5, y: 19 }, 'pc-6': { x: 10, y: 19 }, 'pc-7': { x: 15, y: 19 }, 'pc-8': { x: 20, y: 19 },
};
for (const s of Object.values(SEATS)) {
for (const [layer, dx, dy, gid] of DESK_STAMP) set(layer, s.x + dx, s.y + dy, gid);
set('coll', s.x, s.y, 0);
}
const CAFE = {
'cafe-seat-1': { x: 29, y: 14 }, 'cafe-seat-2': { x: 29, y: 16 },
'cafe-seat-3': { x: 32, y: 14 }, 'cafe-seat-4': { x: 32, y: 16 },
'cafe-stand-coffee': { x: 30, y: 20 }, 'cafe-stand-vending': { x: 33, y: 12 },
};
const COFFEE = {
trayTile: { x: 33, y: 18 }, trayStand: { x: 33, y: 19 },
machineStand: { x: 30, y: 21 }, sinkTile: { x: 31, y: 18 }, sinkStand: { x: 31, y: 19 },
};
const spawnObjs = [];
let oid = 1;
const addSpawn = (name, t) => spawnObjs.push({ id: oid++, name, point: true, x: t.x * TS, y: t.y * TS, width: 0, height: 0, rotation: 0, type: '', visible: true });
for (const [name, t] of Object.entries(SEATS)) addSpawn(name, t);
for (const [name, t] of Object.entries(CAFE)) addSpawn(name, t);
addSpawn('entrance', ENTRANCE);
const zoneObjs = [];
const addZone = (name, x, y, w, h) => zoneObjs.push({ id: oid++, name, x: x * TS, y: y * TS, width: w * TS, height: h * TS, rotation: 0, type: '', visible: true });
addZone('boardroom', 2, 2, 8, 5);
addZone('cafeteria', 28, 10, 7, 12);
addZone('holding', 1, 18, 4, 4);
const walk = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => L.coll[idx(x, y)] === 0));
const forceWalk = (t) => { if (inb(t.x, t.y)) walk[t.y][t.x] = true; };
Object.values(SEATS).forEach(forceWalk);
Object.values(CAFE).forEach(forceWalk);
Object.values(COFFEE).forEach(forceWalk);
forceWalk(ENTRANCE);
const seen = Array.from({ length: H }, () => Array(W).fill(false));
const q = [[ENTRANCE.x, ENTRANCE.y]]; seen[ENTRANCE.y][ENTRANCE.x] = true;
while (q.length) {
const [x, y] = q.shift();
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
if (inb(nx, ny) && !seen[ny][nx] && walk[ny][nx]) { seen[ny][nx] = true; q.push([nx, ny]); }
}
}
const targets = [
...Object.entries(SEATS).map(([n, t]) => [n, t]),
...Object.entries(CAFE).map(([n, t]) => [n, t]),
...Object.entries(COFFEE).map(([n, t]) => [`coffee:${n}`, t]),
['entrance', ENTRANCE],
];
const unreachable = targets.filter(([, t]) => !(inb(t.x, t.y) && seen[t.y][t.x]));
const noApproach = Object.entries(SEATS).filter(([, s]) =>
![[s.x, s.y + 1], [s.x, s.y - 1], [s.x + 1, s.y], [s.x - 1, s.y]]
.some(([ax, ay]) => inb(ax, ay) && walk[ay][ax] && seen[ay] && seen[ay][ax]));
if (unreachable.length || noApproach.length) {
console.error('VALIDATION FAILED');
if (unreachable.length) console.error(' unreachable:', unreachable.map(([n]) => n).join(', '));
if (noApproach.length) console.error(' no walkable approach:', noApproach.map(([n]) => n).join(', '));
process.exit(1);
}
const tileLayer = (name, data, id) => ({ id, name, type: 'tilelayer', data, width: W, height: H, x: 0, y: 0, opacity: 1, visible: true });
const map = {
compressionlevel: -1, infinite: false, orientation: 'orthogonal', renderorder: 'right-down',
width: W, height: H, tilewidth: TS, tileheight: TS, nextlayerid: 99, nextobjectid: oid, version: '1.10', tiledversion: '1.10.2', type: 'map',
tilesets: [
{ firstgid: 1, columns: 16, image: '../tilesets/office-tileset.png', imageheight: 512, imagewidth: 256, margin: 0, name: 'office-tileset', spacing: 0, tilecount: 512, tileheight: 16, tilewidth: 16 },
{ firstgid: 513, source: 'A5 Office Floors & Walls.tsx' },
{ firstgid: 1025, source: 'interiors.tsx' },
],
layers: [
tileLayer('floor', L.floor, 1),
tileLayer('walls', L.walls, 2),
tileLayer('furniture-below', L.below, 3),
tileLayer('furniture-above', L.above, 4),
tileLayer('collision', L.coll, 5),
{ id: 6, name: 'spawn-points', type: 'objectgroup', objects: spawnObjs, draworder: 'topdown', opacity: 1, visible: true, x: 0, y: 0 },
{ id: 7, name: 'zones', type: 'objectgroup', objects: zoneObjs, draworder: 'topdown', opacity: 1, visible: true, x: 0, y: 0 },
],
};
fs.writeFileSync(OUT, JSON.stringify(map));
const seats = Object.keys(SEATS).length, cafe = Object.keys(CAFE).length;
console.log(`OK wrote ${path.relative(path.join(__dirname, '..'), OUT)} — ${W}x${H}, ${seats} desks, ${cafe} café spawns, zones: boardroom/cafeteria/holding`);
console.log(' validation: all seats/stands/spawns reachable from entrance ✓');