Back to Black Site Map Editor
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.

src/model.ts
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;
    }
}