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