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 { html, nothing, type TemplateResult } from 'lit';
import { live } from 'lit/directives/live.js';
import { cloneMap, mapSize, selectionValue, type BlackSiteMap, type Selection } from './model';
function field(container: HTMLElement, key: string): HTMLInputElement | HTMLSelectElement {
const control = container.querySelector<HTMLInputElement | HTMLSelectElement>(
`[data-field="${key}"]`,
);
if (!control) throw new Error('Choose an object to edit first.');
return control;
}
function number(container: HTMLElement, key: string): number {
const control = field(container, key) as HTMLInputElement;
if (
!control.checkValidity() ||
!control.value.trim() ||
!Number.isFinite(control.valueAsNumber)
)
throw new Error(
`Check ${control.labels?.[0]?.textContent?.trim() ?? 'the selected value'}.`,
);
return control.valueAsNumber;
}
type Field = { key: string; label: string; min?: number; max?: number; step?: string };
const numberField = ({ key, label, min = 0, max = 255, step = '1' }: Field, value: string) =>
html`<label
>${label}<input
data-field=${key}
type="number"
min=${min}
max=${max}
step=${step}
.value=${live(value)}
/></label>`;
const checkboxField = (key: string, label: string, checked: boolean) =>
html`<label
>${label}<input data-field=${key} type="checkbox" .checked=${live(checked)}
/></label>`;
const rectLayers = [
'containers',
'water',
'paved',
'roads',
'walls',
'pipes',
'crates',
'wrecks',
'sandbags',
'interiorWalls',
];
/** Plain settings for the selected object; read back with `readObjectControls`. */
export function objectControls(
map: BlackSiteMap,
selection?: Selection,
): TemplateResult | typeof nothing {
if (!selection) return nothing;
const value = selectionValue(map, selection);
if (!value) return nothing;
const coords = Array.isArray(value) ? value : value.r;
const rect = !Array.isArray(value) || rectLayers.includes(selection.layer);
const [width, height] = mapSize(map);
const min = selection.layer === 'buildings' ? 3 : 1;
const span = (from: number, to: number) =>
String(coords.length >= 4 ? Number(coords[to]) - Number(coords[from]) + 1 : 1);
return html`<div class="row">
${numberField({ key: 'x', label: 'Across', max: width - 1 }, String(coords[0]))}
${numberField({ key: 'y', label: 'Down', max: height - 1 }, String(coords[1]))}
</div>
${
rect
? html`<div class="row">
${numberField({ key: 'width', label: 'Width (tiles)', min, max: width }, span(0, 2))}
${numberField({ key: 'height', label: 'Height (tiles)', min, max: height }, span(1, 3))}
</div>`
: nothing
}
${
selection.layer === 'containers'
? html`<label
>Container color<input
data-field="color"
type="color"
.value=${live(String(coords[4]))}
/></label>`
: nothing
}
${
selection.layer === 'enemies'
? checkboxField('patrol', 'Patrolling guard', coords[2] === 'p')
: nothing
}
${
selection.layer === 'caches'
? html`<label
>Supplies<select data-field="cache" .value=${live(String(coords[2] ?? ''))}>
<option value="">Standard supplies</option>
<option value="start">Starting equipment</option>
<option value="weapons">Weapons</option>
</select></label
>`
: nothing
}
${
Array.isArray(value)
? nothing
: html`<p>
${value.doors?.length ?? 0} doors · ${value.walls?.length ?? 0} interior
walls. Moving the building moves these with it.
</p>`
}`;
}
export function readObjectControls(
container: HTMLElement,
map: BlackSiteMap,
selection: Selection,
): BlackSiteMap {
const copy = cloneMap(map);
const value = selectionValue(copy, selection);
if (!value) throw new Error('Choose an object to edit first.');
const coords = Array.isArray(value) ? value : value.r;
const x = number(container, 'x'),
y = number(container, 'y');
const dx = x - Number(coords[0]),
dy = y - Number(coords[1]);
const dimensions = container.querySelector('[data-field="width"]');
if (!Array.isArray(value)) {
value.r = [x, y, x + number(container, 'width') - 1, y + number(container, 'height') - 1];
const shift = (area: number[]) => {
area[0] += dx;
area[1] += dy;
if (area.length === 4) {
area[2] += dx;
area[3] += dy;
}
};
value.doors?.forEach(shift);
value.walls?.forEach(shift);
} else {
let next: unknown[] = dimensions
? [x, y, x + number(container, 'width') - 1, y + number(container, 'height') - 1]
: [x, y];
// Keep single-tile areas compact when their dimensions have not changed.
if (dimensions && coords.length === 2 && next[0] === next[2] && next[1] === next[3])
next = [x, y];
if (selection.layer === 'containers') next.push(field(container, 'color').value);
if (
selection.layer === 'enemies' &&
(field(container, 'patrol') as HTMLInputElement).checked
)
next.push('p');
if (selection.layer === 'caches' && field(container, 'cache').value)
next.push(field(container, 'cache').value);
coords.splice(0, coords.length, ...(next as never[]));
}
return copy;
}
const coverTypes = [
['b', 'Barrels'],
['c', 'Crates'],
['T', 'Trees'],
['h', 'Sandbags'],
] as const;
/** Play-time generation settings; read back with `readDeploymentControls`. */
export function deploymentControls(map: BlackSiteMap): TemplateResult {
const clutter = map.theme.clutter ?? ['b', 'c'];
return html`<p>
These additions appear when the map is played. The editor shows the layout you placed.
</p>
${checkboxField('expand', 'Add more open ground', map.generate?.expand ?? false)}
${numberField(
{ key: 'buildings', label: 'Extra buildings (up to)', max: 64 },
String(map.generate?.buildings ?? 0),
)}
${numberField(
{ key: 'cover', label: 'Extra cover groups (up to)', max: 400 },
String(map.generate?.cover ?? 0),
)}
<p>Cover mix · higher numbers make an item more common</p>
${coverTypes.map(([key, label]) =>
numberField(
{ key: `cover-${key}`, label, max: 1000 },
String(clutter.filter((item) => item === key).length),
),
)}
${numberField(
{ key: 'enemies', label: 'Extra guards (%)', max: 200, step: 'any' },
String((map.spawns?.extraEnemies ?? 0) * 100),
)}
${numberField(
{ key: 'caches', label: 'Extra indoor supplies', max: 20 },
String(map.spawns?.extraCaches ?? 0),
)}
<label
>Layout variation (optional)<input
data-field="seed"
type="number"
min="0"
max="4294967295"
step="1"
placeholder="Automatic"
.value=${live(map.generate?.seed === undefined ? '' : String(map.generate.seed))}
/></label>`;
}
export function readDeploymentControls(container: HTMLElement, map: BlackSiteMap): BlackSiteMap {
const copy = cloneMap(map);
const generate = {
...copy.generate,
expand: (field(container, 'expand') as HTMLInputElement).checked,
buildings: number(container, 'buildings'),
cover: number(container, 'cover'),
};
const seed = field(container, 'seed');
if (seed.value.trim()) generate.seed = number(container, 'seed');
else delete generate.seed;
copy.generate = generate;
copy.spawns = {
...copy.spawns,
extraEnemies: number(container, 'enemies') / 100,
extraCaches: number(container, 'caches'),
};
const clutter: NonNullable<BlackSiteMap['theme']['clutter']> = [];
for (const [key] of coverTypes)
for (let count = number(container, `cover-${key}`); count > 0; count--) clutter.push(key);
if (!clutter.length) throw new Error('Choose at least one kind of extra cover.');
copy.theme.clutter = clutter;
return copy;
}
