@jjlmoya/utils-tabletop 1.29.0 → 1.31.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/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();
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Building, Point, SettlementMap, ServiceType } from './logic';
|
|
2
|
+
import type { SettlementMapUI } from './ui';
|
|
3
|
+
|
|
4
|
+
function serviceLabel(service: ServiceType, ui: SettlementMapUI): string {
|
|
5
|
+
const labels: Record<string, string> = { tavern: ui.serviceTavern, smithy: ui.serviceSmithy, temple: ui.serviceTemple, market: ui.serviceMarket, stable: ui.serviceStable, hall: ui.serviceHall };
|
|
6
|
+
return labels[service] ?? service;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function escapeSvgText(value: string): string {
|
|
10
|
+
return value.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ?? character);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function roofSvg(building: Building, style: string): string {
|
|
14
|
+
const base = building.y + 0.39;
|
|
15
|
+
const left = building.x - 0.15;
|
|
16
|
+
const right = building.x + building.width + 0.15;
|
|
17
|
+
const peak = building.x + building.width / 2;
|
|
18
|
+
if (style === 'edo') return `<path class="rsm-house-roof" d="M${left} ${base}Q${building.x + 0.55} ${building.y - 0.05} ${peak} ${building.y + 0.08}Q${building.x + building.width - 0.55} ${building.y - 0.05} ${right} ${base}Z" />`;
|
|
19
|
+
if (style === 'sahelian') return `<path class="rsm-house-roof" d="M${building.x + 0.05} ${base}H${building.x + building.width - 0.05}V${building.y + 0.68}H${building.x + 0.05}Z" />`;
|
|
20
|
+
return `<polygon class="rsm-house-roof" points="${left},${base} ${peak},${building.y - (style === 'highland' ? 0.62 : 0.48)} ${right},${base}" />`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildingDetails(building: Building, style: string): string {
|
|
24
|
+
if (style === 'timber') return `<path class="rsm-house-frame" d="M${building.x + 0.38} ${building.y + 0.5}V${building.y + building.height - 0.25}M${building.x + building.width - 0.38} ${building.y + 0.5}V${building.y + building.height - 0.25}M${building.x + 0.25} ${building.y + 0.72}H${building.x + building.width - 0.25}" />`;
|
|
25
|
+
if (style === 'edo') return `<path class="rsm-house-wood-slats" d="M${building.x + 0.42} ${building.y + 0.52}V${building.y + building.height - 0.25}M${building.x + 0.72} ${building.y + 0.52}V${building.y + building.height - 0.25}M${building.x + building.width - 0.72} ${building.y + 0.52}V${building.y + building.height - 0.25}M${building.x + building.width - 0.42} ${building.y + 0.52}V${building.y + building.height - 0.25}" />`;
|
|
26
|
+
if (style === 'stone' || style === 'medieval') return `<path class="rsm-house-masonry" d="M${building.x + 0.25} ${building.y + 1.05}H${building.x + building.width - 0.25}M${building.x + 0.25} ${building.y + 1.42}H${building.x + building.width - 0.25}M${building.x + 0.65} ${building.y + 0.7}V${building.y + 1.05}M${building.x + building.width - 0.65} ${building.y + 1.42}V${building.y + 1.78}" />`;
|
|
27
|
+
if (style === 'coastal') return `<path class="rsm-house-awning" d="M${building.x + 0.2} ${building.y + 0.42}H${building.x + building.width - 0.2}" />`;
|
|
28
|
+
if (style === 'sahelian') return `<path class="rsm-house-sunband" d="M${building.x + 0.25} ${building.y + 0.82}H${building.x + building.width - 0.25}" />`;
|
|
29
|
+
return `<path class="rsm-house-chimney" d="M${building.x + building.width - 0.8} ${building.y - 0.35}V${building.y + 0.2}H${building.x + building.width - 0.42}V${building.y - 0.25}" />`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getSignFontSize(size?: string): number {
|
|
33
|
+
if (size === 'town') return 1.05;
|
|
34
|
+
if (size === 'village') return 0.92;
|
|
35
|
+
return 0.84;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function serviceSignSvg(building: Building, map: SettlementMap, service: string): string {
|
|
39
|
+
if (!service) return '';
|
|
40
|
+
const signFontSize = getSignFontSize(map.config.size);
|
|
41
|
+
const signWidth = Math.min(8, Math.max(2.1, service.length * signFontSize * 0.58 + 0.72));
|
|
42
|
+
const signX = building.x + building.width / 2 - signWidth / 2;
|
|
43
|
+
const signHeight = signFontSize + 0.34;
|
|
44
|
+
const signY = building.y - signHeight - 0.58;
|
|
45
|
+
return `<g class="rsm-service-sign rsm-service-sign-${map.config.size}"><path class="rsm-service-stem" d="M${building.x + building.width / 2} ${signY + signHeight}V${building.y - 0.49}" /><rect class="rsm-service-plaque" x="${signX}" y="${signY}" width="${signWidth}" height="${signHeight}" rx="0.12" /><text class="rsm-building-glyph" x="${building.x + building.width / 2}" y="${signY + signHeight / 2}" font-size="${signFontSize}" dominant-baseline="middle">${service}</text></g>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildingSvg(building: Building, map: SettlementMap, ui: SettlementMapUI): string {
|
|
49
|
+
const style = map.config.style ?? 'medieval';
|
|
50
|
+
const roof = roofSvg(building, style);
|
|
51
|
+
const windowY = building.y + 0.82;
|
|
52
|
+
const leftWindowX = building.x + 0.48;
|
|
53
|
+
const rightWindowX = building.x + building.width - 0.86;
|
|
54
|
+
const doorX = building.x + building.width / 2 - 0.23;
|
|
55
|
+
const doorY = building.y + building.height - 0.9;
|
|
56
|
+
const windows = `<rect class="rsm-house-window" x="${leftWindowX}" y="${windowY}" width="0.38" height="0.42" rx="0.05" />${building.width > 2.5 ? `<rect class="rsm-house-window" x="${rightWindowX}" y="${windowY}" width="0.38" height="0.42" rx="0.05" />` : ''}`;
|
|
57
|
+
const details = buildingDetails(building, style);
|
|
58
|
+
const service = building.service ? escapeSvgText(serviceLabel(building.service, ui)) : '';
|
|
59
|
+
const title = building.service ? `<title>${serviceLabel(building.service, ui)}</title>` : '<title>Home</title>';
|
|
60
|
+
const serviceMark = serviceSignSvg(building, map, service);
|
|
61
|
+
return `<g class="rsm-building rsm-house-style-${style}" data-building-id="${building.id}" data-cell-x="${building.x}" data-cell-y="${building.y}">${title}<rect class="rsm-house-body" x="${building.x + 0.12}" y="${building.y + 0.35}" width="${building.width - 0.24}" height="${building.height - 0.35}" rx="0.18" />${roof}${details}${windows}<rect class="rsm-house-door" x="${doorX}" y="${doorY}" width="0.46" height="0.8" rx="0.06" />${serviceMark}</g>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function waterComponents(points: Point[]): Point[][] {
|
|
65
|
+
const remaining = new Set(points.map((point) => `${point.x},${point.y}`));
|
|
66
|
+
const components: Point[][] = [];
|
|
67
|
+
while (remaining.size) {
|
|
68
|
+
const first = [...remaining][0]!;
|
|
69
|
+
const queue = [first.split(',').map(Number) as [number, number]];
|
|
70
|
+
const component: Point[] = [];
|
|
71
|
+
remaining.delete(first);
|
|
72
|
+
while (queue.length) {
|
|
73
|
+
const [x, y] = queue.shift()!;
|
|
74
|
+
component.push({ x, y });
|
|
75
|
+
[{ x: x + 1, y }, { x: x - 1, y }, { x, y: y + 1 }, { x, y: y - 1 }].forEach((neighbor) => {
|
|
76
|
+
const key = `${neighbor.x},${neighbor.y}`;
|
|
77
|
+
if (remaining.delete(key)) queue.push([neighbor.x, neighbor.y]);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
components.push(component);
|
|
81
|
+
}
|
|
82
|
+
return components;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function waterKind(component: Point[]): string {
|
|
86
|
+
if (component.length <= 2) return 'river';
|
|
87
|
+
if (component.length === 3) return 'pond';
|
|
88
|
+
return 'lake';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function waterSvg(map: SettlementMap): string {
|
|
92
|
+
return waterComponents(map.water).map((component) => `<g class="rsm-water-component rsm-water-${waterKind(component)}" data-water-size="${component.length}">${component.map((point) => `<rect x="${point.x}" y="${point.y}" width="1" height="1" />`).join('')}</g>`).join('');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface RoadCell { point: Point; neighbors: Point[] }
|
|
96
|
+
|
|
97
|
+
function roadCell(cells: Map<string, RoadCell>, point: Point): RoadCell {
|
|
98
|
+
const key = `${point.x},${point.y}`;
|
|
99
|
+
const existing = cells.get(key);
|
|
100
|
+
if (existing) return existing;
|
|
101
|
+
const created = { point, neighbors: [] };
|
|
102
|
+
cells.set(key, created);
|
|
103
|
+
return created;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function roadNetwork(map: SettlementMap): Map<string, RoadCell> {
|
|
107
|
+
const cells = new Map<string, RoadCell>();
|
|
108
|
+
map.paths.forEach((path) => {
|
|
109
|
+
path.points.forEach((point) => roadCell(cells, point));
|
|
110
|
+
path.points.slice(1).forEach((point, index) => {
|
|
111
|
+
const previous = path.points[index]!;
|
|
112
|
+
roadCell(cells, previous).neighbors.push(point);
|
|
113
|
+
roadCell(cells, point).neighbors.push(previous);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
return cells;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function roadCellPath(cell: RoadCell): string {
|
|
120
|
+
const center = `${cell.point.x + 0.5},${cell.point.y + 0.5}`;
|
|
121
|
+
if (!cell.neighbors.length) return `M${center}h0`;
|
|
122
|
+
return cell.neighbors.map((neighbor) => `M${center}L${neighbor.x + 0.5},${neighbor.y + 0.5}`).join('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pathSvg(map: SettlementMap): string {
|
|
126
|
+
return [...roadNetwork(map).values()].map((cell) => `<path class="rsm-road-base" d="${roadCellPath(cell)}" /><path class="rsm-road-line" d="${roadCellPath(cell)}" />`).join('');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getTreeStyle(env: string, style?: string): string {
|
|
130
|
+
if (env === 'coast') return 'coastal';
|
|
131
|
+
if (env === 'mountain') return 'highland';
|
|
132
|
+
return style ?? 'medieval';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function treeSvg(point: Point, map: SettlementMap): string {
|
|
136
|
+
const x = point.x + 0.5;
|
|
137
|
+
const y = point.y + 0.5;
|
|
138
|
+
const style = getTreeStyle(map.config.environment, map.config.style);
|
|
139
|
+
const edoBlossom = (point.x * 7 + point.y * 11) % 3 === 0 ? 'green' : 'pink';
|
|
140
|
+
const shapes: Record<string, string> = {
|
|
141
|
+
coastal: '<path d="M0 .65V.05" /><path d="M0 .08C-.16-.2-.58-.27-.62-.52M0 .08C.16-.2.58-.27.62-.52M0 .08C-.05-.22.1-.5.22-.7" />',
|
|
142
|
+
highland: '<path d="M0 .65V.1" /><path d="M-.48 .15L0-.72 .48 .15Z" /><path d="M-.34-.18L0-.88 .34-.18Z" />',
|
|
143
|
+
edo: '<path d="M0 .65V.08" /><path d="M-.52 .08Q0-.48 .52 .08Q0 .27-.52 .08Z" /><path d="M-.34-.22Q0-.68 .34-.22Q0-.08-.34-.22Z" />',
|
|
144
|
+
sahelian: '<path d="M0 .65V.02" /><path d="M-.65 .02Q0-.48 .65 .02Q0 .2-.65 .02Z" />',
|
|
145
|
+
medieval: '<path d="M0 .65V.1" /><path d="M-.55 .14L0-.58 .55 .14Q0 .28-.55 .14Z" /><path d="M-.34-.15L0-.72 .34-.15Z" />',
|
|
146
|
+
timber: '<path d="M0 .65V.1" /><path d="M-.5 .12Q0-.48 .5 .12Q0 .28-.5 .12Z" /><path d="M-.3-.2Q0-.7 .3-.2Q0-.05-.3-.2Z" />',
|
|
147
|
+
stone: '<path d="M0 .65V.1" /><path d="M-.5 .12Q0-.5 .5 .12Q0 .28-.5 .12Z" /><path d="M-.3-.2Q0-.7 .3-.2Q0-.05-.3-.2Z" />',
|
|
148
|
+
};
|
|
149
|
+
const blossomClass = style === 'edo' ? ` rsm-tree-edo-${edoBlossom}` : '';
|
|
150
|
+
return `<g class="rsm-tree rsm-tree-${style}${blossomClass}" transform="translate(${x} ${y})">${shapes[style] ?? shapes.medieval}</g>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function wildSvg(map: SettlementMap): string { return map.wild.map((point) => treeSvg(point, map)).join(''); }
|
|
154
|
+
|
|
155
|
+
function plazaSvg(map: SettlementMap): string {
|
|
156
|
+
const plaza = map.plaza ?? { x: Math.floor(map.width / 2) - 3, y: Math.floor(map.height / 2) - 2, width: 6, height: 4 };
|
|
157
|
+
return `<g class="rsm-plaza"><rect x="${plaza.x}" y="${plaza.y}" width="${plaza.width}" height="${plaza.height}" rx="0.3" /><path d="M${plaza.x + 1} ${plaza.y + 1}H${plaza.x + plaza.width - 1}M${plaza.x + 1} ${plaza.y + plaza.height - 1}H${plaza.x + plaza.width - 1}" /></g>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function gridSvg(map: SettlementMap): string {
|
|
161
|
+
const vertical = Array.from({ length: map.width + 1 }, (_, index) => `<path d="M${index} 0V${map.height}" />`).join('');
|
|
162
|
+
const horizontal = Array.from({ length: map.height + 1 }, (_, index) => `<path d="M0 ${index}H${map.width}" />`).join('');
|
|
163
|
+
return `<g class="rsm-grid">${vertical}${horizontal}</g>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function environmentClass(environment: string): string { return `rsm-environment-${environment}`; }
|
|
167
|
+
|
|
168
|
+
export function buildMapSvg(map: SettlementMap, ui: SettlementMapUI): string {
|
|
169
|
+
return `<svg class="rsm-map-svg ${environmentClass(map.config.environment)}" data-settlement-map viewBox="0 0 ${map.width} ${map.height}" role="img" aria-label="Settlement map"><rect class="rsm-paper" x="0" y="0" width="${map.width}" height="${map.height}" rx="0.5" />${gridSvg(map)}${waterSvg(map)}${plazaSvg(map)}${wildSvg(map)}${pathSvg(map)}${map.buildings.map((building) => buildingSvg(building, map, ui)).join('')}</svg>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function updateMapHost(host: HTMLElement, map: SettlementMap, ui: SettlementMapUI): void { host.innerHTML = buildMapSvg(map, ui); }
|
|
173
|
+
export function svgToDataUrl(svg: string): string { return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; }
|
|
174
|
+
export function downloadFile(content: string | Blob, filename: string, type?: string): void { const blob = typeof content === 'string' ? new Blob([content], { type: type ?? 'text/plain' }) : content; const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; anchor.click(); URL.revokeObjectURL(url); }
|