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 { 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();
}
