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/components/bs-settings-dialog.ts
import { html } from 'lit';
import { live } from 'lit/directives/live.js';
import { editor, guard, type DialogView, type MetadataFields } from '../editor-controller';
import { deploymentControls } from '../editor-controls';
import { mapSize } from '../model';
import { keyHint } from './key-hint';
import { LightElement } from './light-element';

const settingsTabs = [
    ['map', 'Map'],
    ['appearance', 'Appearance'],
    ['gameplay', 'Gameplay'],
    ['files', 'Files'],
    ['sharing', 'Sharing'],
] as const;
type SettingsTab = (typeof settingsTabs)[number][0];

const rgbHex = (value: number[]) =>
    '#' + value.map((n) => n.toString(16).padStart(2, '0')).join('');

export class BsSettingsDialog extends LightElement implements DialogView {
    static properties = { tab: { state: true } };
    declare tab: SettingsTab;
    #jsonFor: unknown;
    #json = '';
    constructor() {
        super();
        this.tab = 'map';
        this.watch((s) => [
            s.map,
            s.base,
            s.busy,
            s.viewer,
            s.remixOf,
            s.content,
            s.hashtags,
            s.publishDetails,
            s.exportFallback,
            s.shortcutsRev,
        ]);
    }
    connectedCallback() {
        super.connectedCallback();
        editor.ui.settings = this;
    }
    get #dialog() {
        return this.querySelector<HTMLDialogElement>('dialog')!;
    }
    get isOpen() {
        return this.#dialog.open;
    }
    show() {
        this.#select('map');
        this.#dialog.showModal();
    }
    close() {
        if (this.isOpen) this.#dialog.close();
    }
    async #select(tab: SettingsTab, focus = false) {
        this.tab = tab;
        await this.updateComplete;
        this.querySelector('.settings-grid')!.scrollTop = 0;
        if (focus) {
            const button = this.querySelector<HTMLElement>(`#settings-tab-${tab}`)!;
            button.focus();
            button.scrollIntoView({ block: 'nearest', inline: 'nearest' });
        }
    }
    #tabKey(event: KeyboardEvent) {
        let index = settingsTabs.findIndex(([key]) => key === this.tab);
        const last = settingsTabs.length - 1;
        if (event.key === 'ArrowRight') index = index === last ? 0 : index + 1;
        else if (event.key === 'ArrowLeft') index = index === 0 ? last : index - 1;
        else if (event.key === 'Home') index = 0;
        else if (event.key === 'End') index = last;
        else return;
        event.preventDefault();
        void this.#select(settingsTabs[index][0], true);
    }
    #metadata(): MetadataFields {
        const value = (id: string) => this.querySelector<HTMLInputElement>(`#${id}`)!.value;
        return {
            id: value('id'),
            name: value('name'),
            desc: value('desc'),
            style: value('style') as MetadataFields['style'],
            ground: value('ground'),
            road: value('road'),
            markings: this.querySelector<HTMLInputElement>('#markings')!.checked,
        };
    }
    #mapJson() {
        // Serialize once per map revision; tests and the hidden JSON editor read this.
        if (this.#jsonFor !== editor.map) {
            this.#jsonFor = editor.map;
            this.#json = JSON.stringify(editor.map, null, 2);
        }
        return this.#json;
    }
    #panel(tab: SettingsTab, content: unknown) {
        return html`<section
            class="settings-panel"
            role="tabpanel"
            id="settings-panel-${tab}"
            aria-labelledby="settings-tab-${tab}"
            ?hidden=${this.tab !== tab}
        >
            ${content}
        </section>`;
    }
    render() {
        const { base, busy, viewer, remixOf, content, hashtags, publishDetails, exportFallback } =
            editor.state;
        const m = editor.map;
        const [w, h] = mapSize(m);
        const changed = guard(() => editor.updateMetadata(this.#metadata()));
        const number = (id: string) =>
            Number(this.querySelector<HTMLInputElement>(`#${id}`)!.value);
        return html`<dialog id="settings" aria-labelledby="settings-title">
            <div class="dialog-head">
                <h2 id="settings-title">MAP SETTINGS</h2>
                <button id="settings-close" @click=${() => this.close()}>Done</button>
            </div>
            <div class="settings-tabs" role="tablist" aria-label="Map settings">
                ${settingsTabs.map(
                    ([tab, label]) =>
                        html`<button
                            type="button"
                            role="tab"
                            id="settings-tab-${tab}"
                            aria-controls="settings-panel-${tab}"
                            aria-selected=${this.tab === tab}
                            tabindex=${this.tab === tab ? 0 : -1}
                            data-settings-tab=${tab}
                            @click=${() => this.#select(tab)}
                            @keydown=${(e: KeyboardEvent) => this.#tabKey(e)}
                        >
                            ${label}
                        </button>`,
                )}
            </div>
            <div class="settings-grid" ?inert=${busy}>
                ${this.#panel(
                    'map',
                    html`<h3>Map details</h3>
                        <form id="metadata" @submit=${(e: Event) => e.preventDefault()}>
                            <label hidden
                                >Map ID<input
                                    id="id"
                                    maxlength="32"
                                    pattern="[a-z0-9][a-z0-9\\-]{0,31}"
                                    required
                                    ?disabled=${Boolean(base)}
                                    .value=${live(m.id)}
                                    @change=${changed}
                            /></label>
                            <label
                                >Map name<input
                                    id="name"
                                    maxlength="32"
                                    required
                                    .value=${live(m.name)}
                                    @change=${changed}
                            /></label>
                            <label
                                >Briefing<textarea
                                    id="desc"
                                    maxlength="200"
                                    rows="2"
                                    .value=${live(m.desc ?? '')}
                                    @change=${changed}
                                ></textarea>
                            </label>
                            <div class="row">
                                <label
                                    >Width<input
                                        id="width"
                                        type="number"
                                        min="16"
                                        max="256"
                                        .value=${live(String(w))}
                                /></label>
                                <label
                                    >Height<input
                                        id="height"
                                        type="number"
                                        min="16"
                                        max="256"
                                        .value=${live(String(h))}
                                /></label>
                            </div>
                            <button
                                id="resize"
                                type="button"
                                @click=${guard(() =>
                                    editor.resize(number('width'), number('height')),
                                )}
                            >
                                Resize grid
                            </button>
                        </form>`,
                )}
                ${this.#panel(
                    'appearance',
                    html`<h3>Look & terrain</h3>
                        <label
                            >Terrain<select
                                id="style"
                                .value=${live(m.theme.style)}
                                @change=${changed}
                            >
                                <option value="grass">Grass</option>
                                <option value="concrete">Concrete</option>
                                <option value="sand">Sand</option>
                                <option value="snow">Snow</option>
                            </select></label
                        >
                        <div class="row">
                            <label
                                >Ground<input
                                    id="ground"
                                    type="color"
                                    .value=${live(rgbHex(m.theme.ground))}
                                    @change=${changed}
                            /></label>
                            <label
                                >Road<input
                                    id="road"
                                    type="color"
                                    .value=${live(rgbHex(m.theme.road))}
                                    @change=${changed}
                            /></label>
                        </div>
                        <label
                            ><input
                                id="markings"
                                type="checkbox"
                                .checked=${live(m.theme.markings !== false)}
                                @change=${changed}
                            />
                            Road markings</label
                        >`,
                )}
                ${this.#panel(
                    'gameplay',
                    html`<h3>Extra scenery & enemies</h3>
                        <p>Add variety when the game starts. These extras appear during play.</p>
                        <div id="deployment-controls">${deploymentControls(m)}</div>
                        <textarea
                            id="advanced-json"
                            hidden
                            .value=${live(
                                JSON.stringify(
                                    {
                                        generate: m.generate ?? {},
                                        spawns: m.spawns ?? {},
                                        clutter: m.theme.clutter ?? ['b', 'c'],
                                    },
                                    null,
                                    2,
                                ),
                            )}
                        ></textarea>
                        <button
                            id="apply-advanced"
                            @click=${guard(() =>
                                editor.applyDeployment(
                                    this.querySelector<HTMLElement>('#deployment-controls')!,
                                ),
                            )}
                        >
                            Apply settings
                        </button>`,
                )}
                ${this.#panel(
                    'files',
                    html`<h3>Map files</h3>
                        <p>Open a saved map file or download a backup of this map.</p>
                        <input
                            id="import-file"
                            class="file-import"
                            type="file"
                            accept="application/json,.json"
                            aria-label="Open map file"
                            @change=${guard(async () => {
                                const input = this.querySelector<HTMLInputElement>('#import-file')!;
                                const file = input.files?.[0];
                                if (!file) return;
                                await editor.importFile(file);
                                input.value = '';
                            })}
                        />
                        <textarea
                            id="json-editor"
                            hidden
                            .value=${live(this.#mapJson())}
                        ></textarea>
                        <button
                            id="apply-json"
                            hidden
                            @click=${guard(() =>
                                editor.applyJson(
                                    this.querySelector<HTMLTextAreaElement>('#json-editor')!.value,
                                ),
                            )}
                        >
                            Apply
                        </button>
                        <button
                            id="export-json"
                            ${keyHint('map.download')}
                            @click=${() => editor.exportJson()}
                        >
                            Download map
                        </button>
                        <p id="export-fallback" class="draft-info">${exportFallback}</p>`,
                )}
                ${this.#panel(
                    'sharing',
                    html`<h3>Sharing & credits</h3>
                        <button
                            id="remix-current"
                            ?disabled=${busy || !viewer || !base}
                            @click=${guard(() => editor.remixCurrent())}
                        >
                            Remix this map
                        </button>
                        <label
                            >Notes / credits<textarea
                                id="content"
                                rows="3"
                                maxlength="4000"
                                .value=${live(content)}
                                @change=${(e: Event) =>
                                    editor.setContent((e.target as HTMLTextAreaElement).value)}
                            ></textarea>
                        </label>
                        <label
                            >Tags (comma separated)<input
                                id="hashtags"
                                maxlength="200"
                                .value=${live(hashtags.join(', '))}
                                @change=${(e: Event) =>
                                    editor.setHashtags((e.target as HTMLInputElement).value)}
                        /></label>
                        <div id="lineage" class="draft-info">
                            ${
                                remixOf || base?.metadata.lineage
                                    ? 'Remix of an existing map. Original creator credits are preserved.'
                                    : base
                                      ? 'Your published map. Changes update this map.'
                                      : 'New original map'
                            }
                        </div>
                        <pre id="publish-details" hidden>${publishDetails}</pre>
                        <button id="export-event" hidden @click=${() => editor.exportEvent()}>
                            Export record
                        </button>`,
                )}
            </div>
        </dialog>`;
    }
}
customElements.define('bs-settings-dialog', BsSettingsDialog);