SOURCE / PINNED RELEASE
Made of little things.
Black Site Map Editor
- Release
- 32a57733b7e9…
- Author-recorded commit
- 3f54890f542f…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/black-site-map-editor
Archive hash verified: 7a4b1abbac7db5b0…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { validateMap as validateContract } from './vendor/validate-map.mjs';
import town from './vendor/town.json';
import docks from './vendor/docks.json';
import refinery from './vendor/refinery.json';
import desert from './vendor/desert.json';
import snow from './vendor/snow.json';
import outpost from './vendor/outpost.json';
export type Point = [number, number];
export type Rect = [number, number, number, number];
export type Area = Point | Rect;
export interface Building {
r: Rect;
doors?: Point[];
walls?: Area[];
}
export interface BlackSiteMap {
$schema?: string;
format: 'black-site-map';
version: 1;
id: string;
name: string;
desc?: string;
size?: Point;
theme: {
ground: [number, number, number];
road: [number, number, number];
style: 'grass' | 'concrete' | 'sand' | 'snow';
markings?: boolean;
clutter?: ('b' | 'c' | 'T' | 'h')[];
};
generate?: {
expand?: boolean;
buildings?: number;
cover?: number;
seed?: number;
};
spawns?: {
extraEnemies?: number;
extraCaches?: number;
};
start: Point;
exfil: Point;
water?: Area[];
paved?: Area[];
roads?: Area[];
walls?: Area[];
buildings?: Building[];
containers?: [number, number, number, number, string][];
tanks?: Point[];
pipes?: Area[];
crates?: Area[];
wrecks?: Area[];
sandbags?: Area[];
enemies?: (Point | [number, number, 'p'])[];
caches?: (Point | [number, number, 'start' | 'weapons'])[];
}
export const AREA_LAYERS = [
'water',
'paved',
'roads',
'walls',
'pipes',
'crates',
'wrecks',
'sandbags',
] as const;
export type Layer =
| (typeof AREA_LAYERS)[number]
| 'buildings'
| 'containers'
| 'tanks'
| 'enemies'
| 'caches'
| 'start'
| 'exfil'
| 'doors'
| 'interiorWalls';
export interface Selection {
layer: Layer;
index: number;
buildingIndex?: number;
}
export const cloneMap = <T>(map: T): T => JSON.parse(JSON.stringify(map)) as T;
export const validateMap = (map: unknown): string[] => validateContract(map);
export const mapSize = (map: BlackSiteMap): Point => map.size ?? [64, 44];
export const builtInTemplates: BlackSiteMap[] = [outpost, town, docks, refinery, desert, snow].map(
(m) => {
const copy = cloneMap(m) as BlackSiteMap;
delete copy.generate;
delete copy.$schema;
return copy;
},
);
export function createMap(id = 'new-map', name = 'NEW MAP'): BlackSiteMap {
return {
format: 'black-site-map',
version: 1,
id,
name,
desc: '',
size: [40, 30],
theme: { ground: [78, 73, 60], road: [44, 45, 47], style: 'grass' },
start: [3, 26],
exfil: [35, 4],
caches: [[4, 26, 'start']],
};
}
export function areaRect(area: readonly (number | string)[]): Rect {
return [
Number(area[0]),
Number(area[1]),
Number(area.length >= 4 ? area[2] : area[0]),
Number(area.length >= 4 ? area[3] : area[1]),
];
}
export const orderedRect = (a: Point, b: Point): Rect => [
Math.min(a[0], b[0]),
Math.min(a[1], b[1]),
Math.max(a[0], b[0]),
Math.max(a[1], b[1]),
];
export function selectionValue(
map: BlackSiteMap,
s: Selection,
):
| Area
| Building
| [number, number, number, number, string]
| [number, number, string]
| undefined {
if (s.layer === 'start' || s.layer === 'exfil') return map[s.layer];
if (s.layer === 'doors' || s.layer === 'interiorWalls') {
const b = map.buildings?.[s.buildingIndex ?? -1];
return (s.layer === 'doors' ? b?.doors : b?.walls)?.[s.index];
}
return map[s.layer]?.[s.index];
}
export function selectionRect(map: BlackSiteMap, s: Selection): Rect | undefined {
const v = selectionValue(map, s);
if (!v) return;
if (!Array.isArray(v)) return v.r;
if (s.layer === 'tanks') return [v[0], v[1], v[0] + 2, v[1] + 2];
return areaRect(v);
}
function selectedList(map: BlackSiteMap, s: Selection): unknown[] | undefined {
if (s.layer === 'start' || s.layer === 'exfil') return;
if (s.layer === 'doors' || s.layer === 'interiorWalls') {
const b = map.buildings?.[s.buildingIndex ?? -1];
return s.layer === 'doors' ? b?.doors : b?.walls;
}
return map[s.layer];
}
export function deleteSelection(map: BlackSiteMap, s: Selection): BlackSiteMap {
const copy = cloneMap(map);
selectedList(copy, s)?.splice(s.index, 1);
return copy;
}
export function moveSelection(
map: BlackSiteMap,
s: Selection,
dx: number,
dy: number,
): BlackSiteMap {
const copy = cloneMap(map);
const value = selectionValue(copy, s);
if (!value) return copy;
const shift = (a: number[]) => {
a[0] += dx;
a[1] += dy;
if (a.length >= 4) {
a[2] += dx;
a[3] += dy;
}
};
if (Array.isArray(value)) shift(value as number[]);
else {
shift(value.r);
value.doors?.forEach(shift);
value.walls?.forEach(shift);
}
const errors = validateMap(copy);
if (errors.length) throw new Error(errors.join('\n'));
return copy;
}
export interface PlaceOptions {
color?: string;
patrol?: boolean;
cacheKind?: 'start' | 'weapons';
buildingIndex?: number;
}
export function place(
map: BlackSiteMap,
layer: Layer,
from: Point,
to: Point = from,
options: PlaceOptions = {},
): {
map: BlackSiteMap;
selection: Selection;
} {
const copy = cloneMap(map);
const r = orderedRect(from, to);
let value: unknown = r;
const s: Selection = { layer, index: 0 };
if (layer === 'start' || layer === 'exfil') {
copy[layer] = [...from];
} else if (layer === 'doors' || layer === 'interiorWalls') {
const i =
options.buildingIndex ??
copy.buildings?.findIndex(
(b) =>
from[0] >= b.r[0] &&
from[0] <= b.r[2] &&
from[1] >= b.r[1] &&
from[1] <= b.r[3],
);
const b = copy.buildings?.[i ?? -1];
if (!b) throw new Error('Select a building first.');
s.buildingIndex = i;
const list = layer === 'doors' ? (b.doors ??= []) : (b.walls ??= []);
s.index = list.length;
list.push(layer === 'doors' ? [...from] : r);
} else {
if (layer === 'buildings') {
if (r[2] - r[0] < 2 || r[3] - r[1] < 2)
throw new Error('Buildings must be at least 3 × 3 tiles.');
value = { r, doors: [[r[0] + 1, r[3]]], walls: [] };
} else if (layer === 'containers') value = [...r, options.color ?? '#2f5a7a'];
else if (layer === 'tanks') value = [...from];
else if (layer === 'enemies') value = options.patrol ? [...from, 'p'] : [...from];
else if (layer === 'caches')
value = options.cacheKind ? [...from, options.cacheKind] : [...from];
const list = (copy[layer] ??= []) as unknown[];
s.index = list.length;
list.push(value);
}
const errors = validateMap(copy);
if (errors.length) throw new Error(errors.join('\n'));
return { map: copy, selection: s };
}
export function hitTest(
map: BlackSiteMap,
x: number,
y: number,
onlyLayer?: Layer,
): Selection | undefined {
const layers: Layer[] = onlyLayer
? [onlyLayer]
: [
'start',
'exfil',
'enemies',
'caches',
'sandbags',
'wrecks',
'crates',
'pipes',
'tanks',
'containers',
'walls',
'doors',
'interiorWalls',
'buildings',
'roads',
'paved',
'water',
];
for (const layer of layers) {
if (layer === 'doors' || layer === 'interiorWalls') {
for (let b = (map.buildings?.length ?? 0) - 1; b >= 0; b--) {
const list = layer === 'doors' ? map.buildings![b].doors : map.buildings![b].walls;
for (let i = (list?.length ?? 0) - 1; i >= 0; i--) {
const s = { layer, index: i, buildingIndex: b };
const r = selectionRect(map, s)!;
if (x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) return s;
}
}
continue;
}
const n = layer === 'start' || layer === 'exfil' ? 1 : (map[layer]?.length ?? 0);
for (let i = n - 1; i >= 0; i--) {
const s = { layer, index: i };
const r = selectionRect(map, s)!;
if (x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) return s;
}
}
}
export const BLOCKING_TILES = '#wKopcvhbT';
export interface Grid {
w: number;
h: number;
grid: string[][];
floor: string[][];
start: Point;
exfil: Point;
enemies: (Point | [number, number, 'p'])[];
caches: (Point | [number, number, 'start' | 'weapons'])[];
}
export function buildGrid(map: BlackSiteMap): Grid {
const [w, h] = mapSize(map);
const grid = Array.from({ length: h }, () => Array<string>(w).fill('.'));
const paint = (a: readonly (number | string)[], tile: string) => {
const r = areaRect(a);
for (let y = r[1]; y <= r[3]; y++)
for (let x = r[0]; x <= r[2]; x++) if (grid[y]?.[x] !== undefined) grid[y][x] = tile;
};
for (const [layer, tile] of [
['water', 'w'],
['paved', '='],
['roads', '='],
] as const)
map[layer]?.forEach((a) => paint(a, tile));
for (const b of map.buildings ?? []) {
paint(b.r, '#');
paint([b.r[0] + 1, b.r[1] + 1, b.r[2] - 1, b.r[3] - 1], ',');
b.walls?.forEach((a) => paint(a, '#'));
b.doors?.forEach((a) => paint(a, ','));
}
map.walls?.forEach((a) => paint(a, '#'));
paint([0, 0, w - 1, 0], '#');
paint([0, h - 1, w - 1, h - 1], '#');
paint([0, 0, 0, h - 1], '#');
paint([w - 1, 0, w - 1, h - 1], '#');
const floor = grid.map((row) => [...row]);
map.containers?.forEach((a) => paint(a, 'K'));
map.tanks?.forEach(([x, y]) => paint([x, y, x + 2, y + 2], 'o'));
for (const [layer, tile] of [
['pipes', 'p'],
['crates', 'c'],
['wrecks', 'v'],
['sandbags', 'h'],
] as const)
map[layer]?.forEach((a) => paint(a, tile));
const nudge = <T extends readonly [number, number, ...unknown[]]>(p: T): T => {
const open = (x: number, y: number) =>
grid[y]?.[x] !== undefined && !BLOCKING_TILES.includes(grid[y][x]);
if (open(p[0], p[1])) return [...p] as unknown as T;
for (let r = 1; r < 6; r++)
for (let dy = -r; dy <= r; dy++)
for (let dx = -r; dx <= r; dx++)
if (open(p[0] + dx, p[1] + dy))
return [p[0] + dx, p[1] + dy, ...p.slice(2)] as unknown as T;
return [...p] as unknown as T;
};
return {
w,
h,
grid,
floor,
start: nudge(map.start),
exfil: nudge(map.exfil),
enemies: (map.enemies ?? []).map(nudge),
caches: (map.caches ?? []).map(nudge),
};
}
export function validatePlayable(map: BlackSiteMap): string[] {
const errors = validateMap(map);
if (errors.length) return errors;
const g = buildGrid(map);
const blocked = (p: Point) =>
g.grid[p[1]]?.[p[0]] === undefined || BLOCKING_TILES.includes(g.grid[p[1]][p[0]]);
for (const [layer, units] of [
['start', [g.start]],
['exfil', [g.exfil]],
['enemies', g.enemies],
['caches', g.caches],
] as const)
units.forEach((p, i) => {
if (blocked([p[0], p[1]]))
errors.push(
`${layer}${layer === 'start' || layer === 'exfil' ? '' : `[${i}]`}: remains on a blocked tile after game relocation`,
);
});
if (errors.length) return errors;
const seen = new Set<number>();
const queue: Point[] = [g.start];
for (let i = 0; i < queue.length; i++) {
const [x, y] = queue[i];
if (g.grid[y]?.[x] === undefined || BLOCKING_TILES.includes(g.grid[y][x])) continue;
const key = y * g.w + x;
if (seen.has(key)) continue;
seen.add(key);
if (x === g.exfil[0] && y === g.exfil[1]) return [];
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
])
queue.push([x + dx, y + dy]);
}
return ['exfil: no walkable route from insertion to extraction'];
}
export function resizeMap(map: BlackSiteMap, width: number, height: number): BlackSiteMap {
const copy = cloneMap(map);
copy.size = [width, height];
const errors = validateMap(copy);
if (errors.length)
throw new Error(
`Resize would clip authored objects. Move or delete them first.\n${errors.join('\n')}`,
);
return copy;
}
export class MapHistory {
private past: BlackSiteMap[] = [];
private future: BlackSiteMap[] = [];
constructor(public current: BlackSiteMap) {
this.current = cloneMap(current);
}
get canUndo() {
return this.past.length > 0;
}
get canRedo() {
return this.future.length > 0;
}
commit(map: BlackSiteMap) {
if (JSON.stringify(map) === JSON.stringify(this.current)) return;
this.past.push(cloneMap(this.current));
if (this.past.length > 100) this.past.shift();
this.current = cloneMap(map);
this.future = [];
}
undo() {
const m = this.past.pop();
if (m) {
this.future.push(this.current);
this.current = m;
}
return this.current;
}
redo() {
const m = this.future.pop();
if (m) {
this.past.push(this.current);
this.current = m;
}
return this.current;
}
}
