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/map-renderer.ts
import { buildGrid, selectionRect } from './model';
import type { BlackSiteMap, Grid, Point, Rect, Selection } from './model';
export const MAP_COLORS = {
    wall: '#575a50',
    wallEdge: '#252920',
    water: '#28464d',
    floor: '#716452',
    crate: '#786943',
    sandbag: '#847856',
    pipe: '#686f66',
    wreck: '#514943',
    grid: 'rgba(212,211,168,.13)',
    selection: '#e4c466',
    insertion: '#8ea66f',
    enemy: '#c07860',
    cache: '#e0bd6a',
    exfil: '#88ad98',
};
export interface RenderOptions {
    tileSize: number;
    grid?: Grid;
    selection?: Selection;
    hover?: Point;
    showGrid?: boolean;
    dragRect?: Rect;
    viewport?: Rect;
}
export function renderMap(
    ctx: CanvasRenderingContext2D,
    map: BlackSiteMap,
    options: RenderOptions,
): void {
    const g = options.grid ?? buildGrid(map),
        t = options.tileSize;
    ctx.save();
    const transform = ctx.getTransform();
    const view = options.viewport ?? [
        Math.floor(-transform.e / transform.a / t),
        Math.floor(-transform.f / transform.d / t),
        Math.floor((ctx.canvas.width - transform.e) / transform.a / t),
        Math.floor((ctx.canvas.height - transform.f) / transform.d / t),
    ];
    const x0 = Math.max(0, view[0]),
        y0 = Math.max(0, view[1]),
        x1 = Math.min(g.w - 1, view[2]),
        y1 = Math.min(g.h - 1, view[3]);
    if (x1 < x0 || y1 < y0) {
        ctx.restore();
        return;
    }
    ctx.beginPath();
    ctx.rect(x0 * t, y0 * t, (x1 - x0 + 1) * t, (y1 - y0 + 1) * t);
    ctx.clip();
    ctx.clearRect(x0 * t, y0 * t, (x1 - x0 + 1) * t, (y1 - y0 + 1) * t);
    const rgb = (a: number[]) => `rgb(${a.join(',')})`;
    const colors: Record<string, string> = {
        '.': rgb(map.theme.ground),
        '=': rgb(map.theme.road),
        ',': MAP_COLORS.floor,
        '#': MAP_COLORS.wall,
        w: MAP_COLORS.water,
        K: '#365771',
        o: '#65716a',
        p: MAP_COLORS.pipe,
        c: MAP_COLORS.crate,
        v: MAP_COLORS.wreck,
        h: MAP_COLORS.sandbag,
    };
    // Paint container bases first; final tile painting masks any later cover layer.
    for (const c of map.containers ?? []) {
        ctx.fillStyle = c[4];
        ctx.fillRect(c[0] * t, c[1] * t, (c[2] - c[0] + 1) * t, (c[3] - c[1] + 1) * t);
    }
    for (let y = y0; y <= y1; y++)
        for (let x = x0; x <= x1; x++) {
            const ch = g.grid[y][x],
                px = x * t,
                py = y * t;
            if (ch !== 'K') {
                ctx.fillStyle = colors[ch] ?? colors['.'];
                ctx.fillRect(px, py, t, t);
            }
            if (ch === '.') {
                const noise = ((x * 17 + y * 37) % 11) / 120;
                ctx.fillStyle = `rgba(18,20,15,${noise})`;
                ctx.fillRect(px, py, t, t);
                if (t >= 10) {
                    ctx.fillStyle = 'rgba(189,181,139,.12)';
                    ctx.fillRect(
                        px + t * 0.2,
                        py + t * 0.65,
                        Math.max(1, t * 0.06),
                        Math.max(1, t * 0.06),
                    );
                }
            } else if (ch === '#') {
                ctx.fillStyle = 'rgba(0,0,0,.25)';
                ctx.fillRect(px, py + t * 0.75, t, t * 0.25);
                ctx.strokeStyle = MAP_COLORS.wallEdge;
                ctx.lineWidth = Math.max(1, t * 0.07);
                ctx.strokeRect(px + 1, py + 1, t - 2, t - 2);
            } else if (ch === ',') {
                ctx.strokeStyle = 'rgba(30,26,19,.17)';
                ctx.lineWidth = 1;
                ctx.beginPath();
                ctx.moveTo(px, py + t * 0.5);
                ctx.lineTo(px + t, py + t * 0.5);
                ctx.stroke();
            } else if (ch === 'w') {
                ctx.strokeStyle = 'rgba(116,150,145,.18)';
                ctx.beginPath();
                ctx.moveTo(px + t * 0.12, py + t * 0.55);
                ctx.lineTo(px + t * 0.65, py + t * 0.55);
                ctx.stroke();
            } else if ('chpvK'.includes(ch)) {
                ctx.strokeStyle = 'rgba(15,18,14,.65)';
                ctx.lineWidth = Math.max(1, t * 0.08);
                ctx.strokeRect(px + t * 0.13, py + t * 0.15, t * 0.74, t * 0.7);
                ctx.strokeStyle = 'rgba(194,181,141,.26)';
                ctx.beginPath();
                ctx.moveTo(px + t * 0.2, py + t * 0.2);
                ctx.lineTo(px + t * 0.8, py + t * 0.8);
                ctx.stroke();
            } else if (ch === 'o') {
                ctx.strokeStyle = '#26372f';
                ctx.lineWidth = Math.max(1, t * 0.08);
                ctx.beginPath();
                ctx.arc(px + t / 2, py + t / 2, t * 0.35, 0, Math.PI * 2);
                ctx.stroke();
            }
        }
    // Lane markings only exist on final road tiles, so walls and cover obscure them.
    if (map.theme.markings !== false) {
        ctx.strokeStyle = 'rgba(204,190,125,.35)';
        ctx.lineWidth = Math.max(1, t * 0.08);
        for (const road of map.roads ?? []) {
            const r = road.length === 4 ? road : [road[0], road[1], road[0], road[1]];
            const horizontal = r[2] - r[0] >= r[3] - r[1];
            const lane = (horizontal ? r[1] + r[3] + 1 : r[0] + r[2] + 1) / 2;
            const start = Math.max(horizontal ? x0 : y0, horizontal ? r[0] : r[1]);
            const end = Math.min(horizontal ? x1 : y1, horizontal ? r[2] : r[3]);
            for (let n = start; n <= end; n++) {
                const tx = horizontal ? n : Math.floor(lane),
                    ty = horizontal ? Math.floor(lane) : n;
                if (g.grid[ty]?.[tx] !== '=') continue;
                ctx.beginPath();
                if (horizontal) {
                    ctx.moveTo((n + 0.15) * t, lane * t);
                    ctx.lineTo((n + 0.75) * t, lane * t);
                } else {
                    ctx.moveTo(lane * t, (n + 0.15) * t);
                    ctx.lineTo(lane * t, (n + 0.75) * t);
                }
                ctx.stroke();
            }
        }
    }
    if (options.showGrid) {
        ctx.strokeStyle = MAP_COLORS.grid;
        ctx.lineWidth = 1;
        ctx.beginPath();
        for (let x = x0; x <= x1 + 1; x++) {
            ctx.moveTo(x * t, 0);
            ctx.lineTo(x * t, g.h * t);
        }
        for (let y = y0; y <= y1 + 1; y++) {
            ctx.moveTo(0, y * t);
            ctx.lineTo(g.w * t, y * t);
        }
        ctx.stroke();
    }
    const marker = (p: Point, color: string, label: string) => {
        if (p[0] < x0 || p[0] > x1 || p[1] < y0 || p[1] > y1) return;
        const x = (p[0] + 0.5) * t,
            y = (p[1] + 0.5) * t;
        ctx.fillStyle = '#171e17';
        ctx.strokeStyle = color;
        ctx.lineWidth = Math.max(1.5, t * 0.09);
        ctx.beginPath();
        ctx.arc(x, y, t * 0.38, 0, Math.PI * 2);
        ctx.fill();
        ctx.stroke();
        if (t >= 13) {
            ctx.fillStyle = color;
            ctx.font = `bold ${Math.max(9, t * 0.5)}px monospace`;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText(label, x, y);
        }
    };
    for (const p of map.caches ?? [])
        marker([p[0], p[1]], MAP_COLORS.cache, p[2] === 'weapons' ? 'W' : 'C');
    for (const p of map.enemies ?? [])
        marker([p[0], p[1]], MAP_COLORS.enemy, p[2] === 'p' ? 'P' : 'E');
    marker(map.start, MAP_COLORS.insertion, 'I');
    marker(map.exfil, MAP_COLORS.exfil, 'H');
    const outline = (r: Rect, color: string) => {
        ctx.strokeStyle = color;
        ctx.lineWidth = 2;
        ctx.strokeRect(
            r[0] * t + 1,
            r[1] * t + 1,
            (r[2] - r[0] + 1) * t - 2,
            (r[3] - r[1] + 1) * t - 2,
        );
    };
    if (options.selection) {
        const r = selectionRect(map, options.selection);
        if (r) outline(r, MAP_COLORS.selection);
    }
    if (options.hover) outline([...options.hover, ...options.hover], 'rgba(234,230,196,.7)');
    if (options.dragRect) {
        ctx.fillStyle = 'rgba(228,196,102,.18)';
        const r = options.dragRect;
        ctx.fillRect(r[0] * t, r[1] * t, (r[2] - r[0] + 1) * t, (r[3] - r[1] + 1) * t);
        outline(r, MAP_COLORS.selection);
    }
    ctx.restore();
}