@jjlmoya/utils-tabletop 1.32.0 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/category/i18n/de.ts +1 -1
- package/src/category/i18n/es.ts +1 -1
- package/src/category/i18n/fr.ts +1 -1
- package/src/category/i18n/id.ts +1 -1
- package/src/category/i18n/it.ts +1 -1
- package/src/category/i18n/nl.ts +1 -1
- package/src/category/i18n/pl.ts +1 -1
- package/src/category/i18n/pt.ts +1 -1
- package/src/category/i18n/ru.ts +1 -1
- package/src/category/i18n/sv.ts +1 -1
- package/src/category/i18n/tr.ts +1 -1
- package/src/category/index.ts +25 -25
- package/src/components/PreviewNavSidebar.astro +1 -1
- package/src/components/PreviewToolbar.astro +1 -1
- package/src/entries.ts +43 -43
- package/src/env.d.ts +1 -1
- package/src/index.ts +24 -24
- package/src/layouts/PreviewLayout.astro +93 -93
- package/src/pages/[locale]/[slug].astro +143 -143
- package/src/pages/index.astro +1 -1
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +74 -0
- package/src/tests/i18n_coverage.test.ts +36 -36
- package/src/tests/mocks/astro_mock.js +1 -1
- package/src/tests/no_em_dash.test.ts +47 -47
- package/src/tests/no_en_dash.test.ts +70 -70
- package/src/tests/seo_length.test.ts +3 -3
- package/src/tests/seo_parity.test.ts +2 -2
- package/src/tests/seo_translation_completeness.test.ts +18 -12
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/spanish_leakage.test.ts +175 -175
- package/src/tool/dungeon-map-generator/component.astro +135 -135
- package/src/tool/dungeon-map-generator/controller.ts +243 -243
- package/src/tool/dungeon-map-generator/dom-views.ts +144 -144
- package/src/tool/dungeon-map-generator/i18n/de.ts +172 -172
- package/src/tool/dungeon-map-generator/logic.ts +281 -281
- package/src/tool/dungeon-map-generator/random-dungeon-map-generator.css +692 -692
- package/src/tool/dungeon-map-generator/ui.ts +54 -54
- package/src/tool/fantasy-runes-translator/logic.ts +1 -1
- package/src/tool/first-player-selector/i18n/tr.ts +1 -1
- package/src/tool/investigation-board/i18n/ru.ts +209 -209
- package/src/tool/investigation-board/i18n/zh.ts +209 -209
- package/src/tool/lunar-tide-tracker/client.ts +264 -264
- package/src/tool/rpg-initiative-tracker/i18n/de.ts +3 -3
- package/src/tool/rpg-initiative-tracker/i18n/es.ts +1 -1
- package/src/tool/rpg-initiative-tracker/i18n/pl.ts +2 -2
- package/src/tool/rpg-initiative-tracker/i18n/ru.ts +1 -1
- package/src/tool/score-tracker/i18n/pt.ts +1 -1
- package/src/tool/score-tracker/i18n/sv.ts +1 -1
- package/src/tool/score-tracker/i18n/tr.ts +1 -1
- package/src/tool/score-tracker/i18n/zh.ts +1 -1
- package/src/tools.ts +25 -25
|
@@ -1,243 +1,243 @@
|
|
|
1
|
-
import { exportJson, exportPng, exportSvg, renderMap } from './dom-views';
|
|
2
|
-
import {
|
|
3
|
-
applyDungeonEdits,
|
|
4
|
-
decodeDocument,
|
|
5
|
-
editDungeonTile,
|
|
6
|
-
encodeDocument,
|
|
7
|
-
type DungeonDocument,
|
|
8
|
-
type DungeonEdit,
|
|
9
|
-
} from './document';
|
|
10
|
-
import { bindContextEditor } from './editor';
|
|
11
|
-
import {
|
|
12
|
-
DEFAULT_CONFIG,
|
|
13
|
-
decodeConfig,
|
|
14
|
-
generateDungeon,
|
|
15
|
-
normalizeConfig,
|
|
16
|
-
type DungeonConfig,
|
|
17
|
-
type DungeonMap,
|
|
18
|
-
type DungeonStyle,
|
|
19
|
-
} from './logic';
|
|
20
|
-
import { loadDocument, saveDocument } from './storage';
|
|
21
|
-
import type { DungeonMapGeneratorUI } from './ui';
|
|
22
|
-
|
|
23
|
-
interface GeneratorState {
|
|
24
|
-
config: DungeonConfig;
|
|
25
|
-
baseMap: DungeonMap;
|
|
26
|
-
edits: DungeonEdit[];
|
|
27
|
-
map: DungeonMap;
|
|
28
|
-
root: HTMLElement;
|
|
29
|
-
ui: DungeonMapGeneratorUI;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const WORDS = ['ember', 'ivory', 'moss', 'obsidian', 'quiet', 'scarlet', 'silver', 'sunken'];
|
|
33
|
-
const PLACES = ['archive', 'bastion', 'crypt', 'forge', 'gate', 'hollow', 'spire', 'vault'];
|
|
34
|
-
|
|
35
|
-
export function mountDungeonMapGenerators(): void {
|
|
36
|
-
document.querySelectorAll<HTMLElement>('[data-dungeon-generator]').forEach(mountGenerator);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function mountGenerator(root: HTMLElement): void {
|
|
40
|
-
const ui = readUi(root);
|
|
41
|
-
if (!ui) return;
|
|
42
|
-
const document = initialDocument();
|
|
43
|
-
const baseMap = generateDungeon(document.config);
|
|
44
|
-
const state: GeneratorState = {
|
|
45
|
-
config: document.config,
|
|
46
|
-
baseMap,
|
|
47
|
-
edits: document.edits,
|
|
48
|
-
map: applyDungeonEdits(baseMap, document.edits),
|
|
49
|
-
root,
|
|
50
|
-
ui,
|
|
51
|
-
};
|
|
52
|
-
syncControls(state);
|
|
53
|
-
renderMap(state);
|
|
54
|
-
bindControls(state);
|
|
55
|
-
bindEditor(state);
|
|
56
|
-
bindActions(state);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function readUi(root: HTMLElement): DungeonMapGeneratorUI | undefined {
|
|
60
|
-
const data = root.querySelector<HTMLScriptElement>('[data-ui]')?.textContent;
|
|
61
|
-
if (!data) return undefined;
|
|
62
|
-
try {
|
|
63
|
-
return JSON.parse(data) as DungeonMapGeneratorUI;
|
|
64
|
-
} catch {
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function initialDocument(): DungeonDocument {
|
|
70
|
-
const shared = new URLSearchParams(window.location.search).get('dungeon');
|
|
71
|
-
const document = shared ? decodeDocument(shared) : undefined;
|
|
72
|
-
if (document) return document;
|
|
73
|
-
const legacyConfig = shared ? decodeConfig(shared) : undefined;
|
|
74
|
-
return loadDocument() || { version: 1, config: normalizeConfig(legacyConfig || DEFAULT_CONFIG), edits: [] };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function bindControls(state: GeneratorState): void {
|
|
78
|
-
state.root.querySelector('[data-generate]')?.addEventListener('click', () => regenerate(state));
|
|
79
|
-
state.root.querySelector('[data-random-seed]')?.addEventListener('click', () => randomizeSeed(state));
|
|
80
|
-
state.root.querySelectorAll<HTMLInputElement>('[name="style"]').forEach((input) => {
|
|
81
|
-
input.addEventListener('change', () => regenerate(state));
|
|
82
|
-
});
|
|
83
|
-
state.root.querySelectorAll<HTMLButtonElement>('[data-size]').forEach((button) => {
|
|
84
|
-
button.addEventListener('click', () => applySizePreset(state, button));
|
|
85
|
-
});
|
|
86
|
-
state.root.querySelectorAll<HTMLInputElement>('[data-live-value]').forEach((input) => {
|
|
87
|
-
input.addEventListener('input', () => updateOutput(state.root, input));
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function applySizePreset(state: GeneratorState, button: HTMLButtonElement): void {
|
|
92
|
-
setInput(state.root, 'columns', button.dataset.columns || String(DEFAULT_CONFIG.columns));
|
|
93
|
-
setInput(state.root, 'rows', button.dataset.rows || String(DEFAULT_CONFIG.rows));
|
|
94
|
-
setInput(state.root, 'density', button.dataset.density || String(DEFAULT_CONFIG.density));
|
|
95
|
-
state.root.querySelectorAll<HTMLInputElement>('[data-live-value]').forEach((input) => {
|
|
96
|
-
updateOutput(state.root, input);
|
|
97
|
-
});
|
|
98
|
-
regenerate(state);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function bindActions(state: GeneratorState): void {
|
|
102
|
-
state.root.querySelector('[data-copy-link]')?.addEventListener('click', () => void copyLink(state));
|
|
103
|
-
state.root.querySelector('[data-export-svg]')?.addEventListener('click', () => exportSvg(state.map));
|
|
104
|
-
state.root.querySelector('[data-export-png]')?.addEventListener('click', () => void exportPng(state.map));
|
|
105
|
-
state.root.querySelector('[data-export-print]')?.addEventListener('click', () => void exportPng(state.map, true));
|
|
106
|
-
state.root.querySelector('[data-export-json]')?.addEventListener('click', () => exportJson(state.map, state.edits));
|
|
107
|
-
state.root.querySelector<HTMLInputElement>('[data-import-json]')?.addEventListener('change', (event) => {
|
|
108
|
-
void importJson(state, event.currentTarget as HTMLInputElement);
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function bindEditor(state: GeneratorState): void {
|
|
113
|
-
bindContextEditor(state.root, (x, y, tile) => applyCellEdit(state, x, y, tile));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function applyCellEdit(state: GeneratorState, x: number, y: number, tile: 0 | 1 | 2): void {
|
|
117
|
-
state.map = editDungeonTile(state.map, x, y, tile);
|
|
118
|
-
state.edits = updateEdits(state, x, y);
|
|
119
|
-
saveState(state);
|
|
120
|
-
renderMap(state);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function updateEdits(state: GeneratorState, x: number, y: number): DungeonEdit[] {
|
|
124
|
-
const edits = state.edits.filter((edit) => edit.x !== x || edit.y !== y);
|
|
125
|
-
const tile = state.map.tiles[y]?.[x];
|
|
126
|
-
if (tile === undefined || tile === state.baseMap.tiles[y]?.[x]) return edits;
|
|
127
|
-
return [...edits, { x, y, tile }];
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function regenerate(state: GeneratorState): void {
|
|
131
|
-
state.config = readConfig(state.root);
|
|
132
|
-
state.baseMap = generateDungeon(state.config);
|
|
133
|
-
state.map = state.baseMap;
|
|
134
|
-
state.edits = [];
|
|
135
|
-
saveState(state);
|
|
136
|
-
syncSizePresets(state);
|
|
137
|
-
renderMap(state);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function readConfig(root: HTMLElement): DungeonConfig {
|
|
141
|
-
return normalizeConfig({
|
|
142
|
-
columns: numberValue(root, '[name="columns"]') ?? DEFAULT_CONFIG.columns,
|
|
143
|
-
rows: numberValue(root, '[name="rows"]') ?? DEFAULT_CONFIG.rows,
|
|
144
|
-
density: numberValue(root, '[name="density"]') ?? DEFAULT_CONFIG.density,
|
|
145
|
-
seed: root.querySelector<HTMLInputElement>('[name="seed"]')?.value || DEFAULT_CONFIG.seed,
|
|
146
|
-
style: readStyle(root),
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function readStyle(root: HTMLElement): DungeonStyle {
|
|
151
|
-
const value = root.querySelector<HTMLInputElement>('[name="style"]:checked')?.value;
|
|
152
|
-
return value === 'cavern' || value === 'scifi' ? value : 'dungeon';
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function numberValue(root: HTMLElement, selector: string): number | undefined {
|
|
156
|
-
const value = root.querySelector<HTMLInputElement>(selector)?.valueAsNumber;
|
|
157
|
-
return Number.isFinite(value) ? value : undefined;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function syncControls(state: GeneratorState): void {
|
|
161
|
-
setInput(state.root, 'seed', state.config.seed);
|
|
162
|
-
setInput(state.root, 'columns', String(state.config.columns));
|
|
163
|
-
setInput(state.root, 'rows', String(state.config.rows));
|
|
164
|
-
setInput(state.root, 'density', String(state.config.density));
|
|
165
|
-
const style = state.root.querySelector<HTMLInputElement>(`[name="style"][value="${state.config.style}"]`);
|
|
166
|
-
if (style) style.checked = true;
|
|
167
|
-
syncSizePresets(state);
|
|
168
|
-
state.root.querySelectorAll<HTMLInputElement>('[data-live-value]').forEach((input) => updateOutput(state.root, input));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function syncSizePresets(state: GeneratorState): void {
|
|
172
|
-
state.root.querySelectorAll<HTMLButtonElement>('[data-size]').forEach((button) => {
|
|
173
|
-
const matchesColumns = Number(button.dataset.columns) === state.config.columns;
|
|
174
|
-
const matchesRows = Number(button.dataset.rows) === state.config.rows;
|
|
175
|
-
const matchesDensity = Number(button.dataset.density) === state.config.density;
|
|
176
|
-
button.setAttribute('aria-pressed', String(matchesColumns && matchesRows && matchesDensity));
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function setInput(root: HTMLElement, name: string, value: string): void {
|
|
181
|
-
const input = root.querySelector<HTMLInputElement>(`[name="${name}"]`);
|
|
182
|
-
if (input) input.value = value;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function updateOutput(root: HTMLElement, input: HTMLInputElement): void {
|
|
186
|
-
const output = root.querySelector<HTMLOutputElement>(`[data-output="${input.name}"]`);
|
|
187
|
-
if (output) output.value = input.value;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function randomizeSeed(state: GeneratorState): void {
|
|
191
|
-
const values = new Uint32Array(2);
|
|
192
|
-
crypto.getRandomValues(values);
|
|
193
|
-
const seed = `${WORDS[values[0]! % WORDS.length]}-${PLACES[values[1]! % PLACES.length]}`;
|
|
194
|
-
setInput(state.root, 'seed', seed);
|
|
195
|
-
regenerate(state);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function copyLink(state: GeneratorState): Promise<void> {
|
|
199
|
-
const url = new URL(window.location.href);
|
|
200
|
-
url.searchParams.set('dungeon', encodeDocument(documentFromState(state)));
|
|
201
|
-
try {
|
|
202
|
-
await navigator.clipboard.writeText(url.toString());
|
|
203
|
-
showCopyStatus(state);
|
|
204
|
-
} catch {
|
|
205
|
-
window.prompt(state.ui.copyLink, url.toString());
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function showCopyStatus(state: GeneratorState): void {
|
|
210
|
-
const button = state.root.querySelector<HTMLButtonElement>('[data-copy-link]');
|
|
211
|
-
if (!button) return;
|
|
212
|
-
const original = button.textContent || state.ui.copyLink;
|
|
213
|
-
button.textContent = state.ui.linkCopied;
|
|
214
|
-
window.setTimeout(() => { button.textContent = original; }, 1800);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
async function importJson(state: GeneratorState, input: HTMLInputElement): Promise<void> {
|
|
218
|
-
const file = input.files?.[0];
|
|
219
|
-
if (!file) return;
|
|
220
|
-
try {
|
|
221
|
-
const document = decodeDocument(await file.text());
|
|
222
|
-
if (!document) throw new Error('Invalid dungeon document');
|
|
223
|
-
state.config = document.config;
|
|
224
|
-
state.baseMap = generateDungeon(document.config);
|
|
225
|
-
state.edits = document.edits;
|
|
226
|
-
state.map = applyDungeonEdits(state.baseMap, state.edits);
|
|
227
|
-
syncControls(state);
|
|
228
|
-
saveState(state);
|
|
229
|
-
renderMap(state);
|
|
230
|
-
} catch {
|
|
231
|
-
window.alert(state.ui.importError);
|
|
232
|
-
} finally {
|
|
233
|
-
input.value = '';
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function documentFromState(state: GeneratorState): DungeonDocument {
|
|
238
|
-
return { version: 1, config: state.config, edits: state.edits };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function saveState(state: GeneratorState): void {
|
|
242
|
-
saveDocument(documentFromState(state));
|
|
243
|
-
}
|
|
1
|
+
import { exportJson, exportPng, exportSvg, renderMap } from './dom-views';
|
|
2
|
+
import {
|
|
3
|
+
applyDungeonEdits,
|
|
4
|
+
decodeDocument,
|
|
5
|
+
editDungeonTile,
|
|
6
|
+
encodeDocument,
|
|
7
|
+
type DungeonDocument,
|
|
8
|
+
type DungeonEdit,
|
|
9
|
+
} from './document';
|
|
10
|
+
import { bindContextEditor } from './editor';
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_CONFIG,
|
|
13
|
+
decodeConfig,
|
|
14
|
+
generateDungeon,
|
|
15
|
+
normalizeConfig,
|
|
16
|
+
type DungeonConfig,
|
|
17
|
+
type DungeonMap,
|
|
18
|
+
type DungeonStyle,
|
|
19
|
+
} from './logic';
|
|
20
|
+
import { loadDocument, saveDocument } from './storage';
|
|
21
|
+
import type { DungeonMapGeneratorUI } from './ui';
|
|
22
|
+
|
|
23
|
+
interface GeneratorState {
|
|
24
|
+
config: DungeonConfig;
|
|
25
|
+
baseMap: DungeonMap;
|
|
26
|
+
edits: DungeonEdit[];
|
|
27
|
+
map: DungeonMap;
|
|
28
|
+
root: HTMLElement;
|
|
29
|
+
ui: DungeonMapGeneratorUI;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const WORDS = ['ember', 'ivory', 'moss', 'obsidian', 'quiet', 'scarlet', 'silver', 'sunken'];
|
|
33
|
+
const PLACES = ['archive', 'bastion', 'crypt', 'forge', 'gate', 'hollow', 'spire', 'vault'];
|
|
34
|
+
|
|
35
|
+
export function mountDungeonMapGenerators(): void {
|
|
36
|
+
document.querySelectorAll<HTMLElement>('[data-dungeon-generator]').forEach(mountGenerator);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mountGenerator(root: HTMLElement): void {
|
|
40
|
+
const ui = readUi(root);
|
|
41
|
+
if (!ui) return;
|
|
42
|
+
const document = initialDocument();
|
|
43
|
+
const baseMap = generateDungeon(document.config);
|
|
44
|
+
const state: GeneratorState = {
|
|
45
|
+
config: document.config,
|
|
46
|
+
baseMap,
|
|
47
|
+
edits: document.edits,
|
|
48
|
+
map: applyDungeonEdits(baseMap, document.edits),
|
|
49
|
+
root,
|
|
50
|
+
ui,
|
|
51
|
+
};
|
|
52
|
+
syncControls(state);
|
|
53
|
+
renderMap(state);
|
|
54
|
+
bindControls(state);
|
|
55
|
+
bindEditor(state);
|
|
56
|
+
bindActions(state);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readUi(root: HTMLElement): DungeonMapGeneratorUI | undefined {
|
|
60
|
+
const data = root.querySelector<HTMLScriptElement>('[data-ui]')?.textContent;
|
|
61
|
+
if (!data) return undefined;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(data) as DungeonMapGeneratorUI;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function initialDocument(): DungeonDocument {
|
|
70
|
+
const shared = new URLSearchParams(window.location.search).get('dungeon');
|
|
71
|
+
const document = shared ? decodeDocument(shared) : undefined;
|
|
72
|
+
if (document) return document;
|
|
73
|
+
const legacyConfig = shared ? decodeConfig(shared) : undefined;
|
|
74
|
+
return loadDocument() || { version: 1, config: normalizeConfig(legacyConfig || DEFAULT_CONFIG), edits: [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function bindControls(state: GeneratorState): void {
|
|
78
|
+
state.root.querySelector('[data-generate]')?.addEventListener('click', () => regenerate(state));
|
|
79
|
+
state.root.querySelector('[data-random-seed]')?.addEventListener('click', () => randomizeSeed(state));
|
|
80
|
+
state.root.querySelectorAll<HTMLInputElement>('[name="style"]').forEach((input) => {
|
|
81
|
+
input.addEventListener('change', () => regenerate(state));
|
|
82
|
+
});
|
|
83
|
+
state.root.querySelectorAll<HTMLButtonElement>('[data-size]').forEach((button) => {
|
|
84
|
+
button.addEventListener('click', () => applySizePreset(state, button));
|
|
85
|
+
});
|
|
86
|
+
state.root.querySelectorAll<HTMLInputElement>('[data-live-value]').forEach((input) => {
|
|
87
|
+
input.addEventListener('input', () => updateOutput(state.root, input));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function applySizePreset(state: GeneratorState, button: HTMLButtonElement): void {
|
|
92
|
+
setInput(state.root, 'columns', button.dataset.columns || String(DEFAULT_CONFIG.columns));
|
|
93
|
+
setInput(state.root, 'rows', button.dataset.rows || String(DEFAULT_CONFIG.rows));
|
|
94
|
+
setInput(state.root, 'density', button.dataset.density || String(DEFAULT_CONFIG.density));
|
|
95
|
+
state.root.querySelectorAll<HTMLInputElement>('[data-live-value]').forEach((input) => {
|
|
96
|
+
updateOutput(state.root, input);
|
|
97
|
+
});
|
|
98
|
+
regenerate(state);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function bindActions(state: GeneratorState): void {
|
|
102
|
+
state.root.querySelector('[data-copy-link]')?.addEventListener('click', () => void copyLink(state));
|
|
103
|
+
state.root.querySelector('[data-export-svg]')?.addEventListener('click', () => exportSvg(state.map));
|
|
104
|
+
state.root.querySelector('[data-export-png]')?.addEventListener('click', () => void exportPng(state.map));
|
|
105
|
+
state.root.querySelector('[data-export-print]')?.addEventListener('click', () => void exportPng(state.map, true));
|
|
106
|
+
state.root.querySelector('[data-export-json]')?.addEventListener('click', () => exportJson(state.map, state.edits));
|
|
107
|
+
state.root.querySelector<HTMLInputElement>('[data-import-json]')?.addEventListener('change', (event) => {
|
|
108
|
+
void importJson(state, event.currentTarget as HTMLInputElement);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function bindEditor(state: GeneratorState): void {
|
|
113
|
+
bindContextEditor(state.root, (x, y, tile) => applyCellEdit(state, x, y, tile));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function applyCellEdit(state: GeneratorState, x: number, y: number, tile: 0 | 1 | 2): void {
|
|
117
|
+
state.map = editDungeonTile(state.map, x, y, tile);
|
|
118
|
+
state.edits = updateEdits(state, x, y);
|
|
119
|
+
saveState(state);
|
|
120
|
+
renderMap(state);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function updateEdits(state: GeneratorState, x: number, y: number): DungeonEdit[] {
|
|
124
|
+
const edits = state.edits.filter((edit) => edit.x !== x || edit.y !== y);
|
|
125
|
+
const tile = state.map.tiles[y]?.[x];
|
|
126
|
+
if (tile === undefined || tile === state.baseMap.tiles[y]?.[x]) return edits;
|
|
127
|
+
return [...edits, { x, y, tile }];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function regenerate(state: GeneratorState): void {
|
|
131
|
+
state.config = readConfig(state.root);
|
|
132
|
+
state.baseMap = generateDungeon(state.config);
|
|
133
|
+
state.map = state.baseMap;
|
|
134
|
+
state.edits = [];
|
|
135
|
+
saveState(state);
|
|
136
|
+
syncSizePresets(state);
|
|
137
|
+
renderMap(state);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readConfig(root: HTMLElement): DungeonConfig {
|
|
141
|
+
return normalizeConfig({
|
|
142
|
+
columns: numberValue(root, '[name="columns"]') ?? DEFAULT_CONFIG.columns,
|
|
143
|
+
rows: numberValue(root, '[name="rows"]') ?? DEFAULT_CONFIG.rows,
|
|
144
|
+
density: numberValue(root, '[name="density"]') ?? DEFAULT_CONFIG.density,
|
|
145
|
+
seed: root.querySelector<HTMLInputElement>('[name="seed"]')?.value || DEFAULT_CONFIG.seed,
|
|
146
|
+
style: readStyle(root),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readStyle(root: HTMLElement): DungeonStyle {
|
|
151
|
+
const value = root.querySelector<HTMLInputElement>('[name="style"]:checked')?.value;
|
|
152
|
+
return value === 'cavern' || value === 'scifi' ? value : 'dungeon';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function numberValue(root: HTMLElement, selector: string): number | undefined {
|
|
156
|
+
const value = root.querySelector<HTMLInputElement>(selector)?.valueAsNumber;
|
|
157
|
+
return Number.isFinite(value) ? value : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function syncControls(state: GeneratorState): void {
|
|
161
|
+
setInput(state.root, 'seed', state.config.seed);
|
|
162
|
+
setInput(state.root, 'columns', String(state.config.columns));
|
|
163
|
+
setInput(state.root, 'rows', String(state.config.rows));
|
|
164
|
+
setInput(state.root, 'density', String(state.config.density));
|
|
165
|
+
const style = state.root.querySelector<HTMLInputElement>(`[name="style"][value="${state.config.style}"]`);
|
|
166
|
+
if (style) style.checked = true;
|
|
167
|
+
syncSizePresets(state);
|
|
168
|
+
state.root.querySelectorAll<HTMLInputElement>('[data-live-value]').forEach((input) => updateOutput(state.root, input));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function syncSizePresets(state: GeneratorState): void {
|
|
172
|
+
state.root.querySelectorAll<HTMLButtonElement>('[data-size]').forEach((button) => {
|
|
173
|
+
const matchesColumns = Number(button.dataset.columns) === state.config.columns;
|
|
174
|
+
const matchesRows = Number(button.dataset.rows) === state.config.rows;
|
|
175
|
+
const matchesDensity = Number(button.dataset.density) === state.config.density;
|
|
176
|
+
button.setAttribute('aria-pressed', String(matchesColumns && matchesRows && matchesDensity));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function setInput(root: HTMLElement, name: string, value: string): void {
|
|
181
|
+
const input = root.querySelector<HTMLInputElement>(`[name="${name}"]`);
|
|
182
|
+
if (input) input.value = value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function updateOutput(root: HTMLElement, input: HTMLInputElement): void {
|
|
186
|
+
const output = root.querySelector<HTMLOutputElement>(`[data-output="${input.name}"]`);
|
|
187
|
+
if (output) output.value = input.value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function randomizeSeed(state: GeneratorState): void {
|
|
191
|
+
const values = new Uint32Array(2);
|
|
192
|
+
crypto.getRandomValues(values);
|
|
193
|
+
const seed = `${WORDS[values[0]! % WORDS.length]}-${PLACES[values[1]! % PLACES.length]}`;
|
|
194
|
+
setInput(state.root, 'seed', seed);
|
|
195
|
+
regenerate(state);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function copyLink(state: GeneratorState): Promise<void> {
|
|
199
|
+
const url = new URL(window.location.href);
|
|
200
|
+
url.searchParams.set('dungeon', encodeDocument(documentFromState(state)));
|
|
201
|
+
try {
|
|
202
|
+
await navigator.clipboard.writeText(url.toString());
|
|
203
|
+
showCopyStatus(state);
|
|
204
|
+
} catch {
|
|
205
|
+
window.prompt(state.ui.copyLink, url.toString());
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function showCopyStatus(state: GeneratorState): void {
|
|
210
|
+
const button = state.root.querySelector<HTMLButtonElement>('[data-copy-link]');
|
|
211
|
+
if (!button) return;
|
|
212
|
+
const original = button.textContent || state.ui.copyLink;
|
|
213
|
+
button.textContent = state.ui.linkCopied;
|
|
214
|
+
window.setTimeout(() => { button.textContent = original; }, 1800);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function importJson(state: GeneratorState, input: HTMLInputElement): Promise<void> {
|
|
218
|
+
const file = input.files?.[0];
|
|
219
|
+
if (!file) return;
|
|
220
|
+
try {
|
|
221
|
+
const document = decodeDocument(await file.text());
|
|
222
|
+
if (!document) throw new Error('Invalid dungeon document');
|
|
223
|
+
state.config = document.config;
|
|
224
|
+
state.baseMap = generateDungeon(document.config);
|
|
225
|
+
state.edits = document.edits;
|
|
226
|
+
state.map = applyDungeonEdits(state.baseMap, state.edits);
|
|
227
|
+
syncControls(state);
|
|
228
|
+
saveState(state);
|
|
229
|
+
renderMap(state);
|
|
230
|
+
} catch {
|
|
231
|
+
window.alert(state.ui.importError);
|
|
232
|
+
} finally {
|
|
233
|
+
input.value = '';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function documentFromState(state: GeneratorState): DungeonDocument {
|
|
238
|
+
return { version: 1, config: state.config, edits: state.edits };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function saveState(state: GeneratorState): void {
|
|
242
|
+
saveDocument(documentFromState(state));
|
|
243
|
+
}
|