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/editor-controls.ts
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;
}