@jjlmoya/utils-tabletop 1.28.0 → 1.30.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 +1 -1
- package/src/category/index.ts +2 -0
- package/src/entries.ts +4 -0
- package/src/tests/locale_completeness.test.ts +2 -9
- package/src/tests/tool_validation.test.ts +1 -2
- package/src/tool/mus-scoreboard/component.astro +21 -11
- package/src/tool/rpg-settlement-exploration-map-generator/.keep +1 -0
- package/src/tool/rpg-settlement-exploration-map-generator/bibliography.astro +11 -0
- package/src/tool/rpg-settlement-exploration-map-generator/bibliography.ts +7 -0
- package/src/tool/rpg-settlement-exploration-map-generator/component.astro +66 -0
- package/src/tool/rpg-settlement-exploration-map-generator/controller.ts +231 -0
- package/src/tool/rpg-settlement-exploration-map-generator/dom-views.ts +174 -0
- package/src/tool/rpg-settlement-exploration-map-generator/editor.ts +85 -0
- package/src/tool/rpg-settlement-exploration-map-generator/entry.ts +27 -0
- package/src/tool/rpg-settlement-exploration-map-generator/evaluator.ts +8 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/de.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/en.ts +63 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/es.ts +233 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/fr.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/id.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/it.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/ja.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/ko.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/nl.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/pl.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/pt.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/ru.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/sv.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/tr.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/i18n/zh.ts +152 -0
- package/src/tool/rpg-settlement-exploration-map-generator/index.ts +4 -0
- package/src/tool/rpg-settlement-exploration-map-generator/logic.test.ts +65 -0
- package/src/tool/rpg-settlement-exploration-map-generator/logic.ts +288 -0
- package/src/tool/rpg-settlement-exploration-map-generator/rpg-settlement-exploration-map-generator.css +885 -0
- package/src/tool/rpg-settlement-exploration-map-generator/seo.astro +11 -0
- package/src/tool/rpg-settlement-exploration-map-generator/storage.test.ts +12 -0
- package/src/tool/rpg-settlement-exploration-map-generator/storage.ts +24 -0
- package/src/tool/rpg-settlement-exploration-map-generator/ui.ts +81 -0
- package/src/tools.ts +2 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dungeonMapGenerator } from '../tool/dungeon-map-generator/entry';
|
|
|
4
4
|
import { encounterDifficultyCalculator } from '../tool/encounter-difficulty-calculator/entry';
|
|
5
5
|
import { tokenStampStudio } from '../tool/token-stamp-studio/entry';
|
|
6
6
|
import { musScoreboard } from '../tool/mus-scoreboard/entry';
|
|
7
|
+
import { rpgSettlementExplorationMapGenerator } from '../tool/rpg-settlement-exploration-map-generator/entry';
|
|
7
8
|
import type { CategoryLocaleContent, KnownLocale } from '../types';
|
|
8
9
|
|
|
9
10
|
export const tabletopCategory = {
|
|
@@ -15,6 +16,7 @@ export const tabletopCategory = {
|
|
|
15
16
|
encounterDifficultyCalculator,
|
|
16
17
|
tokenStampStudio,
|
|
17
18
|
musScoreboard,
|
|
19
|
+
rpgSettlementExplorationMapGenerator,
|
|
18
20
|
],
|
|
19
21
|
i18n: {
|
|
20
22
|
de: () => import('./i18n/de').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -20,6 +20,8 @@ export { scatterDirectionSelector } from './tool/scatter-direction-selector/entr
|
|
|
20
20
|
export type { ScatterSelectorUI, ScatterSelectorLocaleContent } from './tool/scatter-direction-selector/entry';
|
|
21
21
|
export { dungeonMapGenerator } from './tool/dungeon-map-generator/entry';
|
|
22
22
|
export type { DungeonMapGeneratorUI, DungeonMapGeneratorLocaleContent } from './tool/dungeon-map-generator/entry';
|
|
23
|
+
export { rpgSettlementExplorationMapGenerator } from './tool/rpg-settlement-exploration-map-generator/entry';
|
|
24
|
+
export type { SettlementMapUI, SettlementMapLocaleContent } from './tool/rpg-settlement-exploration-map-generator/entry';
|
|
23
25
|
export { tabletopCategory } from './category';
|
|
24
26
|
|
|
25
27
|
import { diceRollerSimulator } from './tool/dice-roller-simulator/entry';
|
|
@@ -37,6 +39,7 @@ import { dungeonMapGenerator } from './tool/dungeon-map-generator/entry';
|
|
|
37
39
|
import { encounterDifficultyCalculator } from './tool/encounter-difficulty-calculator/entry';
|
|
38
40
|
import { tokenStampStudio } from './tool/token-stamp-studio/entry';
|
|
39
41
|
import { musScoreboard } from './tool/mus-scoreboard/entry';
|
|
42
|
+
import { rpgSettlementExplorationMapGenerator } from './tool/rpg-settlement-exploration-map-generator/entry';
|
|
40
43
|
|
|
41
44
|
export const ALL_ENTRIES = [
|
|
42
45
|
diceRollerSimulator,
|
|
@@ -54,5 +57,6 @@ export const ALL_ENTRIES = [
|
|
|
54
57
|
encounterDifficultyCalculator,
|
|
55
58
|
tokenStampStudio,
|
|
56
59
|
musScoreboard,
|
|
60
|
+
rpgSettlementExplorationMapGenerator,
|
|
57
61
|
];
|
|
58
62
|
|
|
@@ -10,11 +10,7 @@ describe('Locale Completeness Validation', () => {
|
|
|
10
10
|
it('bibliography should be defined when faq items exist', async () => {
|
|
11
11
|
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
12
12
|
const content = (await loader?.()) as ToolLocaleContent;
|
|
13
|
-
|
|
14
|
-
expect(
|
|
15
|
-
content.bibliography,
|
|
16
|
-
`Tool "${tool.entry.id}" locale "${locale}" is missing bibliography`,
|
|
17
|
-
).toBeDefined();
|
|
13
|
+
expect(content.bibliography, `Tool "${tool.entry.id}" locale "${locale}" is missing bibliography`).toBeDefined();
|
|
18
14
|
});
|
|
19
15
|
});
|
|
20
16
|
});
|
|
@@ -22,9 +18,6 @@ describe('Locale Completeness Validation', () => {
|
|
|
22
18
|
});
|
|
23
19
|
|
|
24
20
|
it('all tools registered', () => {
|
|
25
|
-
expect(ALL_TOOLS.length).toBe(
|
|
21
|
+
expect(ALL_TOOLS.length).toBe(16);
|
|
26
22
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
23
|
});
|
|
30
|
-
|
|
@@ -5,10 +5,9 @@ import { tabletopCategory } from '../data';
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
7
|
it('should have tools in ALL_TOOLS', () => {
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(16);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
|
|
12
11
|
it('tabletopCategory should be defined', () => {
|
|
13
12
|
expect(tabletopCategory).toBeDefined();
|
|
14
13
|
expect(tabletopCategory.i18n).toBeDefined();
|
|
@@ -2,19 +2,27 @@
|
|
|
2
2
|
import './online-mus-scoreboard.css';
|
|
3
3
|
import { musScoreboard } from './index';
|
|
4
4
|
import type { KnownLocale } from '../../types';
|
|
5
|
+
import type { MusScoreboardUI } from './ui';
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
8
|
+
ui?: MusScoreboardUI;
|
|
7
9
|
locale?: KnownLocale;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (!
|
|
14
|
-
const
|
|
12
|
+
const props = Astro.props as Props;
|
|
13
|
+
let ui = props.ui;
|
|
14
|
+
|
|
15
|
+
if (!ui) {
|
|
16
|
+
const locale = props.locale || 'en';
|
|
17
|
+
const loader = musScoreboard.i18n[locale] || musScoreboard.i18n.en;
|
|
18
|
+
const content = await loader?.();
|
|
19
|
+
ui = content?.ui;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!ui) return null;
|
|
15
23
|
---
|
|
16
24
|
|
|
17
|
-
<div class="mus-scoreboard" data-mus-scoreboard>
|
|
25
|
+
<div class="mus-scoreboard" data-mus-scoreboard data-ui={JSON.stringify(ui)}>
|
|
18
26
|
<div class="mus-board-card">
|
|
19
27
|
<section class="mus-onboarding" aria-labelledby="mus-how-to-use">
|
|
20
28
|
<div class="mus-onboarding-mark" aria-hidden="true">
|
|
@@ -24,7 +32,7 @@ const { ui } = content;
|
|
|
24
32
|
<span></span>
|
|
25
33
|
</div>
|
|
26
34
|
<div>
|
|
27
|
-
<p class="mus-kicker" id="mus-how-to-use">
|
|
35
|
+
<p class="mus-kicker" id="mus-how-to-use">{ui.liveMatch}</p>
|
|
28
36
|
<p class="mus-onboarding-copy">{ui.onboarding}</p>
|
|
29
37
|
</div>
|
|
30
38
|
</section>
|
|
@@ -32,7 +40,7 @@ const { ui } = content;
|
|
|
32
40
|
<form class="mus-config" data-config-form>
|
|
33
41
|
<div class="mus-config-heading">
|
|
34
42
|
<div>
|
|
35
|
-
<p class="mus-kicker">
|
|
43
|
+
<p class="mus-kicker">{ui.matchSettings}</p>
|
|
36
44
|
<h2>{ui.matchSettings}</h2>
|
|
37
45
|
</div>
|
|
38
46
|
<span class="mus-rule-seal" aria-hidden="true">MUS</span>
|
|
@@ -160,10 +168,12 @@ const { ui } = content;
|
|
|
160
168
|
</div>
|
|
161
169
|
</div>
|
|
162
170
|
|
|
163
|
-
<script type="application/json" data-mus-ui set:html={JSON.stringify(ui)}></script>
|
|
164
171
|
<script>
|
|
165
172
|
import { mountMusScoreboard } from './controller';
|
|
173
|
+
|
|
166
174
|
const root = document.querySelector<HTMLElement>('[data-mus-scoreboard]');
|
|
167
|
-
|
|
168
|
-
|
|
175
|
+
if (root) {
|
|
176
|
+
const ui = JSON.parse(root.dataset.ui || '{}');
|
|
177
|
+
mountMusScoreboard(root, ui);
|
|
178
|
+
}
|
|
169
179
|
</script>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keep
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { rpgSettlementExplorationMapGenerator } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
interface Props { locale?: KnownLocale; }
|
|
6
|
+
const { locale = 'en' } = Astro.props as Props;
|
|
7
|
+
const loader = rpgSettlementExplorationMapGenerator.i18n[locale] || rpgSettlementExplorationMapGenerator.i18n.en;
|
|
8
|
+
const content = await loader?.();
|
|
9
|
+
if (!content) return null;
|
|
10
|
+
---
|
|
11
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{ name: 'Hendrikx et al., Procedural Content Generation for Games: A Survey', url: 'https://doi.org/10.1145/2422956.2422957' },
|
|
5
|
+
{ name: 'Smelik et al., A Survey on Procedural Modelling for Virtual Worlds', url: 'https://doi.org/10.1111/cgf.12276' },
|
|
6
|
+
{ name: 'Guzdial et al., Tabletop Roleplaying Games as Procedural Content Generators', url: 'https://doi.org/10.1145/3402942.3409605' },
|
|
7
|
+
];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { buildMapSvg } from './dom-views';
|
|
3
|
+
import { evaluateSettlement } from './evaluator';
|
|
4
|
+
import { DEFAULT_CONFIG, generateSettlement } from './logic';
|
|
5
|
+
import type { SettlementMapUI } from './ui';
|
|
6
|
+
import './rpg-settlement-exploration-map-generator.css';
|
|
7
|
+
interface Props { ui: SettlementMapUI; }
|
|
8
|
+
const { ui } = Astro.props;
|
|
9
|
+
const initialMap = generateSettlement(DEFAULT_CONFIG);
|
|
10
|
+
const initialEvaluation = evaluateSettlement(initialMap, ui);
|
|
11
|
+
const builtInServices = ['tavern', 'smithy', 'temple', 'market', 'stable', 'hall'] as const;
|
|
12
|
+
---
|
|
13
|
+
<div class="rsm" data-settlement-tool>
|
|
14
|
+
<script is:inline type="application/json" data-ui set:html={JSON.stringify(ui)} />
|
|
15
|
+
<section class="rsm-studio" aria-label={ui.mapRegionLabel}>
|
|
16
|
+
<div class="rsm-controls">
|
|
17
|
+
<div class="rsm-intro">{ui.intro}</div>
|
|
18
|
+
<div class="rsm-separator"></div>
|
|
19
|
+
<div class="rsm-field">
|
|
20
|
+
<label for="rsm-seed">{ui.seedLabel}</label>
|
|
21
|
+
<div class="rsm-seed-row"><input id="rsm-seed" name="seed" type="text" value={DEFAULT_CONFIG.seed} maxlength="48" spellcheck="false" /><button type="button" class="rsm-icon-button" data-random-seed aria-label={ui.randomSeed}>New</button></div>
|
|
22
|
+
<small>{ui.seedHint}</small>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="rsm-field">
|
|
25
|
+
<span class="rsm-label">{ui.environmentLabel}</span>
|
|
26
|
+
<div class="rsm-choice-row" data-environments>{(['forest', 'plains', 'coast', 'river', 'mountain'] as const).map((environment) => <button type="button" class:list={{ 'rsm-choice': true, 'is-active': environment === DEFAULT_CONFIG.environment }} data-environment={environment} aria-pressed={environment === DEFAULT_CONFIG.environment}>{ui[environment]}</button>)}</div>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="rsm-field">
|
|
29
|
+
<span class="rsm-label">{ui.styleLabel}</span>
|
|
30
|
+
<small>{ui.styleHint}</small>
|
|
31
|
+
<div class="rsm-choice-row" data-styles>{(['medieval', 'edo', 'sahelian', 'timber', 'stone', 'coastal', 'highland'] as const).map((style) => <button type="button" class:list={{ 'rsm-choice': true, 'is-active': style === DEFAULT_CONFIG.style }} data-style={style} aria-pressed={style === DEFAULT_CONFIG.style}>{ui[`style${style.charAt(0).toUpperCase()}${style.slice(1)}`]}</button>)}</div>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="rsm-field">
|
|
34
|
+
<span class="rsm-label">{ui.sizeLabel}</span>
|
|
35
|
+
<div class="rsm-size-row" data-sizes>
|
|
36
|
+
<button type="button" class="rsm-size-button" data-size="hamlet" aria-pressed="false"><strong>{ui.hamlet}</strong><span>34 x 24</span><small>{ui.hamletHint}</small></button>
|
|
37
|
+
<button type="button" class="rsm-size-button is-active" data-size="village" aria-pressed="true"><strong>{ui.village}</strong><span>42 x 28</span><small>{ui.villageHint}</small></button>
|
|
38
|
+
<button type="button" class="rsm-size-button" data-size="town" aria-pressed="false"><strong>{ui.town}</strong><span>52 x 34</span><small>{ui.townHint}</small></button>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="rsm-field rsm-home-field">
|
|
42
|
+
<label for="rsm-homes">{ui.homesLabel}<output data-homes-output>{DEFAULT_CONFIG.homes}</output></label>
|
|
43
|
+
<input id="rsm-homes" name="homes" type="range" min="3" max="42" value={DEFAULT_CONFIG.homes} data-homes />
|
|
44
|
+
<small>{ui.homesHint}</small>
|
|
45
|
+
</div>
|
|
46
|
+
<fieldset class="rsm-field rsm-services">
|
|
47
|
+
<legend>{ui.servicesLabel}</legend>
|
|
48
|
+
<small>{ui.serviceListHint}</small>
|
|
49
|
+
<div class="rsm-service-row" data-service-list>{builtInServices.map((service) => <div class="rsm-service-entry" data-service-entry={service}><label class="rsm-service-chip"><input type="checkbox" value={service} data-service-option checked={DEFAULT_CONFIG.services.includes(service)} /><span>{ui[`service${service.charAt(0).toUpperCase()}${service.slice(1)}`]}</span></label></div>)}</div>
|
|
50
|
+
<div class="rsm-add-service"><input id="rsm-new-service" type="text" data-new-service aria-label={ui.newServicePlaceholder} placeholder={ui.newServicePlaceholder} maxlength="28" /><button type="button" data-add-service>{ui.addService}</button></div>
|
|
51
|
+
</fieldset>
|
|
52
|
+
<button type="button" class="rsm-generate" data-generate>{ui.generate}</button>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="rsm-map-column">
|
|
55
|
+
<div class="rsm-map-heading"><div><span class="rsm-eyebrow">{ui.mapSummary}</span><strong data-terrain>{initialEvaluation.terrainLabel}</strong></div><span class="rsm-badge" data-badge>{initialEvaluation.badge}</span></div>
|
|
56
|
+
<div class="rsm-map-frame"><div class="rsm-map-host" data-map-host set:html={buildMapSvg(initialMap, ui)}></div><div class="rsm-map-help">{ui.clickHint}</div><div class="rsm-context-menu" data-context-menu hidden role="menu" aria-label={ui.contextMenuLabel}><button type="button" data-context-tool="select" role="menuitem">{ui.contextInspect}</button><button type="button" data-context-tool="building" role="menuitem">{ui.contextBuilding}</button><button type="button" data-context-tool="path" role="menuitem">{ui.contextPath}</button><button type="button" data-context-tool="water" role="menuitem">{ui.contextWater}</button><button type="button" data-context-tool="tree" role="menuitem">{ui.contextTree}</button><button type="button" data-context-tool="erase" role="menuitem">{ui.contextErase}</button></div></div>
|
|
57
|
+
<div class="rsm-stats" aria-live="polite"><div><span>{ui.buildings}</span><strong data-building-count>{initialEvaluation.buildingCount}</strong></div><div><span>{ui.paths}</span><strong data-path-count>{initialEvaluation.pathCount}</strong></div><div><span>{ui.services}</span><strong data-service-count>{initialEvaluation.serviceCount}</strong></div><div><span>{ui.terrain}</span><strong data-water-count>{initialEvaluation.waterCount}</strong></div></div>
|
|
58
|
+
<div class="rsm-actions"><button type="button" class="rsm-action-primary" data-share>{ui.shareLink}</button><button type="button" data-export-png>{ui.exportPng}</button><button type="button" data-export-svg>{ui.exportSvg}</button><details class="rsm-data-menu"><summary>{ui.mapData}</summary><div><button type="button" data-export-json>{ui.exportJson}</button><label><span>{ui.importJson}</span><input type="file" accept="application/json,.json" data-import-json /></label></div></details></div>
|
|
59
|
+
<p class="rsm-status" data-status aria-live="polite"></p>
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
</div>
|
|
63
|
+
<script>
|
|
64
|
+
import { mountSettlementMap } from './controller';
|
|
65
|
+
mountSettlementMap();
|
|
66
|
+
</script>
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { addBuilding, addPath, addTree, addWater, DEFAULT_CONFIG, eraseAt, generateSettlement, homeRange, normalizeConfig, settlementPreset, type EditTool, type ServiceType, type SettlementConfig, type SettlementMap, type SettlementSize } from './logic';
|
|
2
|
+
import { buildMapSvg, downloadFile, svgToDataUrl, updateMapHost } from './dom-views';
|
|
3
|
+
import { bindContextEditor } from './editor';
|
|
4
|
+
import { evaluateSettlement } from './evaluator';
|
|
5
|
+
import { configFromMap, decodeShareState, encodeShareState, isSettlementMap, loadSettlement, saveSettlement, type SettlementState } from './storage';
|
|
6
|
+
import type { SettlementMapUI } from './ui';
|
|
7
|
+
|
|
8
|
+
const BUILT_IN_SERVICES = ['tavern', 'smithy', 'temple', 'market', 'stable', 'hall'];
|
|
9
|
+
const NAME_FIRST = ['Willow', 'Red', 'Cedar', 'Briar', 'Ash', 'Silver', 'Moss', 'Oak', 'Raven', 'Fox', 'Amber', 'Thorn', 'Hollow', 'Maple', 'Stone', 'Meadow'];
|
|
10
|
+
const NAME_SECOND = ['Mere', 'Brook', 'Crossing', 'Hollow', 'Watch', 'Glen', 'Ford', 'Heath', 'Cairn', 'Reach', 'Meadow', 'Vale', 'Ridge', 'Marsh', 'Gate', 'Dell'];
|
|
11
|
+
|
|
12
|
+
function serviceLabel(service: ServiceType, ui: SettlementMapUI): string {
|
|
13
|
+
const labels: Record<string, string> = { tavern: ui.serviceTavern, smithy: ui.serviceSmithy, temple: ui.serviceTemple, market: ui.serviceMarket, stable: ui.serviceStable, hall: ui.serviceHall };
|
|
14
|
+
return labels[service] ?? service;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RuntimeState {
|
|
18
|
+
current: SettlementMap;
|
|
19
|
+
customServices: string[];
|
|
20
|
+
regenerationTimer?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function queryMapFromUrl(): SettlementState | null {
|
|
24
|
+
const params = new URLSearchParams(window.location.search);
|
|
25
|
+
const value = params.get('settlement') ?? params.get('map');
|
|
26
|
+
return value ? decodeShareState(value) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function randomName(): string {
|
|
30
|
+
const values = new Uint32Array(2);
|
|
31
|
+
crypto.getRandomValues(values);
|
|
32
|
+
return `${NAME_FIRST[values[0]! % NAME_FIRST.length]} ${NAME_SECOND[values[1]! % NAME_SECOND.length]}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function selectedServices(root: HTMLElement): ServiceType[] { return [...root.querySelectorAll<HTMLInputElement>('[data-service-option]:checked')].map((input) => input.value); }
|
|
36
|
+
|
|
37
|
+
function activeData<T extends string>(root: HTMLElement, attr: string, fallback: T): T {
|
|
38
|
+
const element = root.querySelector<HTMLElement>(`[data-${attr}].is-active`);
|
|
39
|
+
return (element?.dataset[attr] as T | undefined) ?? fallback;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readConfig(root: HTMLElement): SettlementConfig {
|
|
43
|
+
const seed = root.querySelector<HTMLInputElement>('[name="seed"]')?.value ?? DEFAULT_CONFIG.seed;
|
|
44
|
+
const environment = activeData(root, 'environment', DEFAULT_CONFIG.environment);
|
|
45
|
+
const size = activeData(root, 'size', DEFAULT_CONFIG.size);
|
|
46
|
+
const style = activeData(root, 'style', DEFAULT_CONFIG.style);
|
|
47
|
+
const homes = Number(root.querySelector<HTMLInputElement>('[data-homes]')?.value ?? DEFAULT_CONFIG.homes);
|
|
48
|
+
return normalizeConfig({ seed, environment, size, style, homes, services: selectedServices(root) });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setPressed(root: HTMLElement, selector: string, value: string): void {
|
|
52
|
+
root.querySelectorAll<HTMLElement>(selector).forEach((element) => {
|
|
53
|
+
const active = element.dataset.environment === value || element.dataset.size === value || element.dataset.style === value || element.dataset.tool === value;
|
|
54
|
+
element.classList.toggle('is-active', active);
|
|
55
|
+
element.setAttribute('aria-pressed', String(active));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findServiceEntry(root: HTMLElement, service: ServiceType): HTMLElement | null {
|
|
60
|
+
return [...root.querySelectorAll<HTMLElement>('[data-service-entry]')].find((entry) => entry.dataset.serviceEntry === service) ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ServiceOption {
|
|
64
|
+
service: ServiceType;
|
|
65
|
+
ui: SettlementMapUI;
|
|
66
|
+
checked: boolean;
|
|
67
|
+
onRemove: (service: ServiceType) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function appendServiceOption(root: HTMLElement, option: ServiceOption): void {
|
|
71
|
+
const { service, ui, checked, onRemove } = option;
|
|
72
|
+
const list = root.querySelector<HTMLElement>('[data-service-list]');
|
|
73
|
+
if (!list || findServiceEntry(root, service)) return;
|
|
74
|
+
const entry = document.createElement('div');
|
|
75
|
+
entry.className = 'rsm-service-entry';
|
|
76
|
+
entry.dataset.serviceEntry = service;
|
|
77
|
+
const label = document.createElement('label');
|
|
78
|
+
label.className = 'rsm-service-chip';
|
|
79
|
+
const input = document.createElement('input');
|
|
80
|
+
input.type = 'checkbox';
|
|
81
|
+
input.value = service;
|
|
82
|
+
input.checked = checked;
|
|
83
|
+
input.dataset.serviceOption = '';
|
|
84
|
+
const text = document.createElement('span');
|
|
85
|
+
text.textContent = serviceLabel(service, ui);
|
|
86
|
+
label.append(input, text);
|
|
87
|
+
entry.append(label);
|
|
88
|
+
if (!BUILT_IN_SERVICES.includes(service)) {
|
|
89
|
+
const remove = document.createElement('button');
|
|
90
|
+
remove.type = 'button';
|
|
91
|
+
remove.className = 'rsm-remove-service';
|
|
92
|
+
remove.textContent = 'x';
|
|
93
|
+
remove.setAttribute('aria-label', `${ui.removeService}: ${serviceLabel(service, ui)}`);
|
|
94
|
+
remove.addEventListener('click', () => onRemove(service));
|
|
95
|
+
entry.append(remove);
|
|
96
|
+
}
|
|
97
|
+
list.append(entry);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function syncControls(root: HTMLElement, config: SettlementConfig, ui: SettlementMapUI, onRemove: (service: ServiceType) => void): void {
|
|
101
|
+
const seed = root.querySelector<HTMLInputElement>('[name="seed"]');
|
|
102
|
+
const homes = root.querySelector<HTMLInputElement>('[data-homes]');
|
|
103
|
+
const output = root.querySelector<HTMLOutputElement>('[data-homes-output]');
|
|
104
|
+
if (seed) seed.value = config.seed;
|
|
105
|
+
if (homes) { homes.min = String(homeRange(config.size).min); homes.max = String(homeRange(config.size).max); homes.value = String(config.homes); }
|
|
106
|
+
if (output) output.value = String(config.homes);
|
|
107
|
+
setPressed(root, '[data-environment]', config.environment);
|
|
108
|
+
setPressed(root, '[data-style]', config.style);
|
|
109
|
+
setPressed(root, '[data-size]', config.size);
|
|
110
|
+
config.services.forEach((service) => appendServiceOption(root, { service, ui, checked: true, onRemove }));
|
|
111
|
+
root.querySelectorAll<HTMLInputElement>('[data-service-option]').forEach((input) => { input.checked = config.services.includes(input.value); });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function updateSummary(root: HTMLElement, map: SettlementMap, ui: SettlementMapUI): void {
|
|
115
|
+
const evaluation = evaluateSettlement(map, ui);
|
|
116
|
+
root.querySelector('[data-terrain]')!.textContent = evaluation.terrainLabel;
|
|
117
|
+
root.querySelector('[data-badge]')!.textContent = evaluation.badge;
|
|
118
|
+
root.querySelector('[data-building-count]')!.textContent = String(evaluation.buildingCount);
|
|
119
|
+
root.querySelector('[data-path-count]')!.textContent = String(evaluation.pathCount);
|
|
120
|
+
root.querySelector('[data-service-count]')!.textContent = String(evaluation.serviceCount);
|
|
121
|
+
root.querySelector('[data-water-count]')!.textContent = String(evaluation.waterCount);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function setStatus(root: HTMLElement, message: string): void { root.querySelector('[data-status]')!.textContent = message; }
|
|
125
|
+
function render(root: HTMLElement, map: SettlementMap, ui: SettlementMapUI, customServices: string[]): void { updateMapHost(root.querySelector<HTMLElement>('[data-map-host]')!, map, ui); updateSummary(root, map, ui); saveSettlement(map, customServices); }
|
|
126
|
+
|
|
127
|
+
function applyEdit(map: SettlementMap, tool: EditTool, point: { x: number; y: number }): SettlementMap {
|
|
128
|
+
const actions: Partial<Record<EditTool, () => SettlementMap>> = {
|
|
129
|
+
building: () => addBuilding(map, point),
|
|
130
|
+
path: () => addPath(map, point),
|
|
131
|
+
water: () => addWater(map, point),
|
|
132
|
+
tree: () => addTree(map, point),
|
|
133
|
+
erase: () => eraseAt(map, point),
|
|
134
|
+
};
|
|
135
|
+
return actions[tool]?.() ?? map;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function regenerate(root: HTMLElement, state: RuntimeState, ui: SettlementMapUI): void {
|
|
139
|
+
state.current = generateSettlement(readConfig(root));
|
|
140
|
+
syncControls(root, state.current.config, ui, (service) => removeCustomService(root, state, ui, service));
|
|
141
|
+
render(root, state.current, ui, state.customServices);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function scheduleRegenerate(root: HTMLElement, state: RuntimeState, ui: SettlementMapUI): void {
|
|
145
|
+
if (state.regenerationTimer) window.clearTimeout(state.regenerationTimer);
|
|
146
|
+
state.regenerationTimer = window.setTimeout(() => { delete state.regenerationTimer; regenerate(root, state, ui); }, 180);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function applySizePreset(root: HTMLElement, size: SettlementSize): void {
|
|
150
|
+
const homes = root.querySelector<HTMLInputElement>('[data-homes]');
|
|
151
|
+
if (!homes) return;
|
|
152
|
+
const range = homeRange(size);
|
|
153
|
+
const preset = settlementPreset(size);
|
|
154
|
+
homes.min = String(range.min);
|
|
155
|
+
homes.max = String(range.max);
|
|
156
|
+
homes.value = String(Math.min(range.max, Math.max(range.min, preset.homes)));
|
|
157
|
+
root.querySelector<HTMLOutputElement>('[data-homes-output]')!.value = homes.value;
|
|
158
|
+
root.querySelectorAll<HTMLInputElement>('[data-service-option]').forEach((input) => {
|
|
159
|
+
if (BUILT_IN_SERVICES.includes(input.value)) input.checked = preset.services.includes(input.value);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function addCustomService(root: HTMLElement, state: RuntimeState, ui: SettlementMapUI): void {
|
|
164
|
+
const input = root.querySelector<HTMLInputElement>('[data-new-service]');
|
|
165
|
+
const value = input?.value.trim();
|
|
166
|
+
if (!input || !value || [...root.querySelectorAll<HTMLInputElement>('[data-service-option]')].some((item) => item.value.toLocaleLowerCase() === value.toLocaleLowerCase())) return;
|
|
167
|
+
state.customServices.push(value);
|
|
168
|
+
appendServiceOption(root, { service: value, ui, checked: true, onRemove: (service) => removeCustomService(root, state, ui, service) });
|
|
169
|
+
findServiceEntry(root, value)?.querySelector<HTMLInputElement>('[data-service-option]')?.addEventListener('change', () => scheduleRegenerate(root, state, ui));
|
|
170
|
+
input.value = '';
|
|
171
|
+
saveSettlement(state.current, state.customServices);
|
|
172
|
+
scheduleRegenerate(root, state, ui);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function removeCustomService(root: HTMLElement, state: RuntimeState, ui: SettlementMapUI, service: ServiceType): void {
|
|
176
|
+
if (BUILT_IN_SERVICES.includes(service)) return;
|
|
177
|
+
state.customServices = state.customServices.filter((item) => item !== service);
|
|
178
|
+
findServiceEntry(root, service)?.remove();
|
|
179
|
+
saveSettlement(state.current, state.customServices);
|
|
180
|
+
scheduleRegenerate(root, state, ui);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function attachChoiceEvents(root: HTMLElement, state: RuntimeState, ui: SettlementMapUI): void {
|
|
184
|
+
root.querySelectorAll<HTMLElement>('[data-environment]').forEach((button) => button.addEventListener('click', () => { setPressed(root, '[data-environment]', button.dataset.environment ?? 'forest'); scheduleRegenerate(root, state, ui); }));
|
|
185
|
+
root.querySelectorAll<HTMLElement>('[data-style]').forEach((button) => button.addEventListener('click', () => { setPressed(root, '[data-style]', button.dataset.style ?? 'timber'); scheduleRegenerate(root, state, ui); }));
|
|
186
|
+
root.querySelectorAll<HTMLElement>('[data-size]').forEach((button) => button.addEventListener('click', () => { const size = button.dataset.size as SettlementSize ?? 'village'; setPressed(root, '[data-size]', size); applySizePreset(root, size); scheduleRegenerate(root, state, ui); }));
|
|
187
|
+
root.querySelector<HTMLInputElement>('[data-homes]')?.addEventListener('input', (event) => { root.querySelector<HTMLOutputElement>('[data-homes-output]')!.value = String((event.target as HTMLInputElement).value); scheduleRegenerate(root, state, ui); });
|
|
188
|
+
root.querySelector<HTMLInputElement>('[name="seed"]')?.addEventListener('input', () => scheduleRegenerate(root, state, ui));
|
|
189
|
+
root.querySelector('[data-random-seed]')?.addEventListener('click', () => { root.querySelector<HTMLInputElement>('[name="seed"]')!.value = randomName(); scheduleRegenerate(root, state, ui); });
|
|
190
|
+
root.querySelectorAll<HTMLInputElement>('[data-service-option]').forEach((input) => input.addEventListener('change', () => scheduleRegenerate(root, state, ui)));
|
|
191
|
+
root.querySelector('[data-add-service]')?.addEventListener('click', () => addCustomService(root, state, ui));
|
|
192
|
+
root.querySelector<HTMLInputElement>('[data-new-service]')?.addEventListener('keydown', (event) => { if (event.key === 'Enter') addCustomService(root, state, ui); });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function exportPng(map: SettlementMap, ui: SettlementMapUI): void { const image = new Image(); image.onload = () => { const canvas = document.createElement('canvas'); canvas.width = map.width * 32; canvas.height = map.height * 32; const context = canvas.getContext('2d'); if (!context) return; context.fillStyle = '#f8f0df'; context.fillRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) => { if (blob) downloadFile(blob, 'settlement-exploration-map.png'); }, 'image/png'); }; image.src = svgToDataUrl(buildMapSvg(map, ui)); }
|
|
196
|
+
function exportJson(map: SettlementMap): void { downloadFile(JSON.stringify(map, null, 2), 'settlement-exploration-map.json', 'application/json'); }
|
|
197
|
+
|
|
198
|
+
function attachActionEvents(root: HTMLElement, state: RuntimeState, ui: SettlementMapUI): void {
|
|
199
|
+
root.querySelector('[data-generate]')?.addEventListener('click', () => regenerate(root, state, ui));
|
|
200
|
+
root.querySelector('[data-share]')?.addEventListener('click', () => { const url = new URL(window.location.href); url.searchParams.set('settlement', encodeShareState(state.current, state.customServices)); window.history.replaceState({}, '', url); void navigator.clipboard?.writeText(url.toString()); setStatus(root, ui.linkCopied); });
|
|
201
|
+
root.querySelector('[data-export-svg]')?.addEventListener('click', () => downloadFile(buildMapSvg(state.current, ui), 'settlement-exploration-map.svg', 'image/svg+xml'));
|
|
202
|
+
root.querySelector('[data-export-png]')?.addEventListener('click', () => exportPng(state.current, ui));
|
|
203
|
+
root.querySelector('[data-export-json]')?.addEventListener('click', () => exportJson(state.current));
|
|
204
|
+
root.querySelector<HTMLInputElement>('[data-import-json]')?.addEventListener('change', (event) => { void importJson(root, state, ui, event.target as HTMLInputElement); });
|
|
205
|
+
}
|
|
206
|
+
async function importJson(root: HTMLElement, state: RuntimeState, ui: SettlementMapUI, input: HTMLInputElement): Promise<void> { const file = input.files?.[0]; if (!file) return; try { const parsed: unknown = JSON.parse(await file.text()); if (!isSettlementMap(parsed)) throw new Error('invalid'); state.current = parsed; state.customServices = [...new Set([...state.customServices, ...parsed.config.services.filter((service) => !BUILT_IN_SERVICES.includes(service))])]; state.customServices.forEach((service) => appendServiceOption(root, { service, ui, checked: parsed.config.services.includes(service), onRemove: (item) => removeCustomService(root, state, ui, item) })); syncControls(root, configFromMap(parsed), ui, (service) => removeCustomService(root, state, ui, service)); render(root, state.current, ui, state.customServices); } catch { setStatus(root, ui.importError); } finally { input.value = ''; } }
|
|
207
|
+
function runtimeStateFromStorage(): RuntimeState { const loaded = queryMapFromUrl() ?? loadSettlement(); const initialMap = loaded?.map ?? generateSettlement(DEFAULT_CONFIG); const customServices = [...new Set([...(loaded?.customServices ?? []), ...initialMap.config.services.filter((service) => !BUILT_IN_SERVICES.includes(service))])]; return { current: initialMap, customServices }; }
|
|
208
|
+
function prepareControls(root: HTMLElement, state: RuntimeState, ui: SettlementMapUI): void {
|
|
209
|
+
state.customServices.forEach((service) => appendServiceOption(root, { service, ui, checked: state.current.config.services.includes(service), onRemove: (item) => removeCustomService(root, state, ui, item) }));
|
|
210
|
+
syncControls(root, state.current.config, ui, (service) => removeCustomService(root, state, ui, service));
|
|
211
|
+
render(root, state.current, ui, state.customServices);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function bindEditor(root: HTMLElement, state: RuntimeState, ui: SettlementMapUI): void {
|
|
215
|
+
bindContextEditor(root, (point, tool) => {
|
|
216
|
+
state.current = applyEdit(state.current, tool, point);
|
|
217
|
+
render(root, state.current, ui, state.customServices);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function mountSettlementMap(): void {
|
|
222
|
+
const root = document.querySelector<HTMLElement>('[data-settlement-tool]');
|
|
223
|
+
if (!root || root.dataset.mounted === 'true') return;
|
|
224
|
+
root.dataset.mounted = 'true';
|
|
225
|
+
const ui = JSON.parse(root.querySelector<HTMLScriptElement>('[data-ui]')?.textContent ?? '{}') as SettlementMapUI;
|
|
226
|
+
const state = runtimeStateFromStorage();
|
|
227
|
+
prepareControls(root, state, ui);
|
|
228
|
+
attachChoiceEvents(root, state, ui);
|
|
229
|
+
bindEditor(root, state, ui);
|
|
230
|
+
attachActionEvents(root, state, ui);
|
|
231
|
+
}
|