@jjlmoya/utils-tabletop 1.20.0 → 1.22.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/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/spanish_leakage.test.ts +25 -3
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/decision-wheel/i18n/de.ts +10 -10
- package/src/tool/decision-wheel/i18n/es.ts +10 -10
- package/src/tool/decision-wheel/i18n/fr.ts +10 -10
- package/src/tool/decision-wheel/i18n/id.ts +10 -10
- package/src/tool/decision-wheel/i18n/it.ts +10 -10
- package/src/tool/decision-wheel/i18n/ja.ts +10 -10
- package/src/tool/decision-wheel/i18n/ko.ts +10 -10
- package/src/tool/decision-wheel/i18n/nl.ts +10 -10
- package/src/tool/decision-wheel/i18n/pl.ts +10 -10
- package/src/tool/decision-wheel/i18n/pt.ts +10 -10
- package/src/tool/decision-wheel/i18n/sv.ts +10 -10
- package/src/tool/decision-wheel/i18n/tr.ts +10 -10
- package/src/tool/decision-wheel/i18n/zh.ts +10 -10
- package/src/tool/dungeon-map-generator/bibliography.astro +16 -0
- package/src/tool/dungeon-map-generator/bibliography.ts +12 -0
- package/src/tool/dungeon-map-generator/component.astro +134 -0
- package/src/tool/dungeon-map-generator/controller.ts +242 -0
- package/src/tool/dungeon-map-generator/document.ts +72 -0
- package/src/tool/dungeon-map-generator/dom-views.ts +139 -0
- package/src/tool/dungeon-map-generator/editor.ts +57 -0
- package/src/tool/dungeon-map-generator/entry.ts +28 -0
- package/src/tool/dungeon-map-generator/evaluator.ts +40 -0
- package/src/tool/dungeon-map-generator/i18n/de.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/en.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/es.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/fr.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/id.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/it.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/ja.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/ko.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/nl.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/pl.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/pt.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/ru.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/sv.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/tr.ts +172 -0
- package/src/tool/dungeon-map-generator/i18n/zh.ts +172 -0
- package/src/tool/dungeon-map-generator/index.ts +11 -0
- package/src/tool/dungeon-map-generator/logic.test.ts +75 -0
- package/src/tool/dungeon-map-generator/logic.ts +255 -0
- package/src/tool/dungeon-map-generator/random-dungeon-map-generator.css +678 -0
- package/src/tool/dungeon-map-generator/seo.astro +16 -0
- package/src/tool/dungeon-map-generator/storage.ts +18 -0
- package/src/tool/dungeon-map-generator/ui.ts +53 -0
- package/src/tool/dungeon-map-generator/validation.ts +40 -0
- package/src/tool/hidden-role-dealer/i18n/de.ts +6 -6
- package/src/tool/hidden-role-dealer/i18n/id.ts +6 -6
- package/src/tool/hidden-role-dealer/i18n/it.ts +6 -6
- package/src/tool/hidden-role-dealer/i18n/ja.ts +6 -6
- package/src/tool/hidden-role-dealer/i18n/ko.ts +6 -6
- package/src/tool/hidden-role-dealer/i18n/nl.ts +6 -6
- package/src/tool/hidden-role-dealer/i18n/pl.ts +7 -7
- package/src/tool/hidden-role-dealer/i18n/pt.ts +7 -7
- package/src/tool/hidden-role-dealer/i18n/ru.ts +7 -7
- package/src/tool/hidden-role-dealer/i18n/sv.ts +7 -7
- package/src/tool/hidden-role-dealer/i18n/tr.ts +7 -7
- package/src/tool/hidden-role-dealer/i18n/zh.ts +9 -9
- package/src/tool/scatter-direction-selector/i18n/de.ts +9 -9
- package/src/tool/scatter-direction-selector/i18n/es.ts +9 -9
- package/src/tool/scatter-direction-selector/i18n/fr.ts +12 -12
- package/src/tool/scatter-direction-selector/i18n/id.ts +9 -9
- package/src/tool/scatter-direction-selector/i18n/it.ts +11 -11
- package/src/tool/scatter-direction-selector/i18n/ja.ts +8 -8
- package/src/tool/scatter-direction-selector/i18n/ko.ts +8 -8
- package/src/tool/scatter-direction-selector/i18n/nl.ts +9 -10
- package/src/tool/scatter-direction-selector/i18n/pl.ts +9 -9
- package/src/tool/scatter-direction-selector/i18n/pt.ts +12 -12
- package/src/tool/scatter-direction-selector/i18n/ru.ts +7 -7
- package/src/tool/scatter-direction-selector/i18n/sv.ts +9 -9
- package/src/tool/scatter-direction-selector/i18n/tr.ts +8 -8
- package/src/tool/scatter-direction-selector/i18n/zh.ts +11 -11
- package/src/tools.ts +2 -0
|
@@ -29,7 +29,7 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
29
29
|
},
|
|
30
30
|
seo: [
|
|
31
31
|
{ type: 'title', text: 'Beslutshjul för Brädspel och Gruppaktiviteter', level: 2 },
|
|
32
|
-
{ type: 'paragraph', html: 'Beslutshjulet är ett mångsidigt verktyg för brädspel.
|
|
32
|
+
{ type: 'paragraph', html: 'Beslutshjulet är ett mångsidigt digitalt verktyg för brädspel, rollspel och gruppaktiviteter. Det gör det möjligt att avgöra val snabbt, rättvist och helt slumpmässigt. Varje segment kan anpassas med egna etiketter, färger och sannolikhetsvikter för att passa just ditt spel.' },
|
|
33
33
|
{
|
|
34
34
|
type: 'stats',
|
|
35
35
|
items: [
|
|
@@ -40,15 +40,15 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
40
40
|
columns: 3,
|
|
41
41
|
},
|
|
42
42
|
{ type: 'title', text: 'Hur du Använder Beslutshjulet', level: 2 },
|
|
43
|
-
{ type: 'paragraph', html: 'Välj en mall eller skapa
|
|
44
|
-
{ type: 'title', text:
|
|
45
|
-
{ type: 'paragraph', html:
|
|
46
|
-
{ type: 'title', text:
|
|
47
|
-
{ type: 'paragraph', html:
|
|
48
|
-
{ type: 'title', text:
|
|
49
|
-
{ type: 'paragraph', html:
|
|
50
|
-
{ type: 'title', text:
|
|
51
|
-
{ type: 'paragraph', html:
|
|
43
|
+
{ type: 'paragraph', html: 'Välj en färdig mall som Ja/Nej, D20, Alignment eller Bytesskatter, eller skapa din egen anpassade lista. Justera vikterna från 1 till 5 för att ändra hur ofta varje alternativ dras. Fysikmotorn simulerar friktion och inbromsning för att ge spänning åt varje snurr.' },
|
|
44
|
+
{ type: 'title', text: 'Fler beslutsscenarier', level: 3 },
|
|
45
|
+
{ type: 'paragraph', html: 'Använd hjulet för snabba omröstningar i gruppen, slumpmässiga möten i rollspel, fördelning av spelturer eller för att välja vilken spelare som väljer karaktär först.' },
|
|
46
|
+
{ type: 'title', text: 'Mallar för sällskapsspel', level: 3 },
|
|
47
|
+
{ type: 'paragraph', html: 'De inbyggda mallarna låter dig ladda vanliga beslut på ett par sekunder, vilket sparar värdefull tid under spelsessioner och undviker onödiga diskussioner runt bordet.' },
|
|
48
|
+
{ type: 'title', text: 'Justera resultatvikter', level: 3 },
|
|
49
|
+
{ type: 'paragraph', html: 'Genom att ändra vikter kan du balansera eller styra dragningschanserna. En högre vikt ger segmentet en större yta på hjulet, vilket ökar sannolikheten att det väljs.' },
|
|
50
|
+
{ type: 'title', text: 'Granska historiken', level: 3 },
|
|
51
|
+
{ type: 'paragraph', html: 'Historikpanelen sparar de senaste tio snurren så att alla spelare kan kontrollera tidigare resultat i full transparens under spelets gång.' },
|
|
52
52
|
],
|
|
53
53
|
faq: [
|
|
54
54
|
{
|
|
@@ -29,7 +29,7 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
29
29
|
},
|
|
30
30
|
seo: [
|
|
31
31
|
{ type: 'title', text: 'Masa Oyunları ve Grup Etkinlikleri İçin Karar Çarkı', level: 2 },
|
|
32
|
-
{ type: 'paragraph', html: 'Karar çarkı, masa oyunları için çok yönlü bir rastgele seçim aracıdır.
|
|
32
|
+
{ type: 'paragraph', html: 'Karar çarkı, masa oyunları, RPG oyunları ve grup etkinlikleri için çok yönlü bir dijital rastgele seçim aracıdır. Seçenekler arasında hızlı, adil ve tamamen tarafsız bir çekiliş yapmanızı sağlar. Her dilimi kendi etiketleriniz, renkleriniz ve oyun kurallarınıza uygun olasılık ağırlıklarıyla özelleştirebilirsiniz.' },
|
|
33
33
|
{
|
|
34
34
|
type: 'stats',
|
|
35
35
|
items: [
|
|
@@ -40,15 +40,15 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
40
40
|
columns: 3,
|
|
41
41
|
},
|
|
42
42
|
{ type: 'title', text: 'Karar Çarkı Nasıl Kullanılır', level: 2 },
|
|
43
|
-
{ type: 'paragraph', html: '
|
|
44
|
-
{ type: 'title', text:
|
|
45
|
-
{ type: 'paragraph', html:
|
|
46
|
-
{ type: 'title', text:
|
|
47
|
-
{ type: 'paragraph', html:
|
|
48
|
-
{ type: 'title', text:
|
|
49
|
-
{ type: 'paragraph', html:
|
|
50
|
-
{ type: 'title', text:
|
|
51
|
-
{ type: 'paragraph', html:
|
|
43
|
+
{ type: 'paragraph', html: 'Evet/Hayır, D20, Hizalanma veya Ganimet gibi hazır şablonlardan birini seçin ya da kendi özel listenizi oluşturun. Seçeneklerin çıkma sıklığını değiştirmek için ağırlıkları 1 ile 5 arasında ayarlayın. Fizik motoru, her çevirmede heyecan katmak için sürtünme ve yavaşlama efektini simüle eder.' },
|
|
44
|
+
{ type: 'title', text: 'Ek karar senaryoları', level: 3 },
|
|
45
|
+
{ type: 'paragraph', html: 'Çarkı grupta hızlı oylamalar yapmak, rol yapma oyunlarında rastgele karşılaşmalar oluşturmak, oyuncu sıralamasını belirlemek veya eğlenceli görevler seçmek için kullanabilirsiniz.' },
|
|
46
|
+
{ type: 'title', text: 'Masaüstü oyun şablonları', level: 3 },
|
|
47
|
+
{ type: 'paragraph', html: 'Hazır şablonlar, masa oyunlarında sık kullanılan seçimleri saniyeler içinde yüklemenizi sağlayarak oyun zamanından tasarruf etmenize yardımcı olur.' },
|
|
48
|
+
{ type: 'title', text: 'Sonuç ağırlıklarını ayarlama', level: 3 },
|
|
49
|
+
{ type: 'paragraph', html: 'Ağırlıkları değiştirmek olasılıkları dengeler. Bir dilimin ağırlığını artırmak, onun çarktaki alanını büyüterek seçilme şansını yükseltir.' },
|
|
50
|
+
{ type: 'title', text: 'Geçmişi inceleme', level: 3 },
|
|
51
|
+
{ type: 'paragraph', html: 'Geçmiş paneli son 10 çevirmenin sonucunu saklar; böylece gruptaki herkes geçmiş çekilişleri şeffaf bir şekilde inceleyebilir.' },
|
|
52
52
|
],
|
|
53
53
|
faq: [
|
|
54
54
|
{
|
|
@@ -29,7 +29,7 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
29
29
|
},
|
|
30
30
|
seo: [
|
|
31
31
|
{ type: 'title', text: '适用于桌游及团队聚会的随机决策轮盘', level: 2 },
|
|
32
|
-
{ type: 'paragraph', html: '
|
|
32
|
+
{ type: 'paragraph', html: '决策轮盘是一个用于桌面游戏、TRPG跑团和团队聚会的多用途数字随机选择工具。它可以帮助您快速、公正且完全随机地决定各种选项。每个扇区都可以自定义文本、背景颜色以及中奖概率权重,以完美契合您的游戏规则。' },
|
|
33
33
|
{
|
|
34
34
|
type: 'stats',
|
|
35
35
|
items: [
|
|
@@ -40,15 +40,15 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
40
40
|
columns: 3,
|
|
41
41
|
},
|
|
42
42
|
{ type: 'title', text: '如何使用决策轮盘', level: 2 },
|
|
43
|
-
{ type: 'paragraph', html: '
|
|
44
|
-
{ type: 'title', text:
|
|
45
|
-
{ type: 'paragraph', html:
|
|
46
|
-
{ type: 'title', text:
|
|
47
|
-
{ type: 'paragraph', html:
|
|
48
|
-
{ type: 'title', text:
|
|
49
|
-
{ type: 'paragraph', html:
|
|
50
|
-
{ type: 'title', text:
|
|
51
|
-
{ type: 'paragraph', html:
|
|
43
|
+
{ type: 'paragraph', html: '选择内置预设方案(如是/否、D20、阵营或战利品掉落),或者创建您专属的自定义选项列表。通过将权重调整为 1 到 5,您可以灵活改变每个选项抽中的概率。逼真的物理阻尼旋转动画将为每一次抽取增添紧张刺激的体验。' },
|
|
44
|
+
{ type: 'title', text: '更多决策场景', level: 3 },
|
|
45
|
+
{ type: 'paragraph', html: '您可以使用决策轮盘在团队中快速发起投票、决定跑团中的随机遭遇、分配玩家行动顺序,或者在派对游戏中抽取惩罚项目。' },
|
|
46
|
+
{ type: 'title', text: '桌游预设', level: 3 },
|
|
47
|
+
{ type: 'paragraph', html: '内置桌游预设让您能在数秒内加载常见决策,节省游戏准备时间,避免桌上无谓的争议。' },
|
|
48
|
+
{ type: 'title', text: '调整结果权重', level: 3 },
|
|
49
|
+
{ type: 'paragraph', html: '调整权重可以平衡或倾斜抽取概率。提高某项权重会增大该分块在轮盘上的扇形面积,从而提升中奖几率。' },
|
|
50
|
+
{ type: 'title', text: '查看旋转历史', level: 3 },
|
|
51
|
+
{ type: 'paragraph', html: '历史记录面板会保存最近 10 次的旋转结果,方便全场所有玩家在游戏过程中透明地复核先前的抽签记录。' },
|
|
52
52
|
],
|
|
53
53
|
faq: [
|
|
54
54
|
{
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { dungeonMapGenerator } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props as Props;
|
|
11
|
+
const loader = dungeonMapGenerator.i18n[locale] || dungeonMapGenerator.i18n.en;
|
|
12
|
+
const content = await loader?.();
|
|
13
|
+
if (!content) return null;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Hilliard, Salis and Elaarag, Algorithms for Procedural Dungeon Generation',
|
|
6
|
+
url: 'https://www.pnnl.gov/publications/algorithms-procedural-dungeon-generation',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Baron, Procedural Dungeon Generation Analysis and Adaptation',
|
|
10
|
+
url: 'https://doi.org/10.1145/3077286.3077566',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { buildMapSvg } from './dom-views';
|
|
3
|
+
import { evaluateMap } from './evaluator';
|
|
4
|
+
import { DEFAULT_CONFIG, generateDungeon } from './logic';
|
|
5
|
+
import type { DungeonMapGeneratorUI } from './ui';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
ui: DungeonMapGeneratorUI;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { ui } = Astro.props;
|
|
12
|
+
const initialMap = generateDungeon(DEFAULT_CONFIG);
|
|
13
|
+
const initialEvaluation = evaluateMap(initialMap, ui);
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<div class="dmg" data-dungeon-generator data-style="dungeon">
|
|
17
|
+
<script is:inline type="application/json" data-ui set:html={JSON.stringify(ui)} />
|
|
18
|
+
<section class="dmg-controls" aria-label={ui.styleLabel}>
|
|
19
|
+
<div class="dmg-seed-row">
|
|
20
|
+
<label class="dmg-field dmg-field--seed">
|
|
21
|
+
<span>{ui.seedLabel}</span>
|
|
22
|
+
<input name="seed" type="text" value={DEFAULT_CONFIG.seed} maxlength="48" spellcheck="false" />
|
|
23
|
+
</label>
|
|
24
|
+
<button class="dmg-shuffle" type="button" data-random-seed>{ui.randomSeed}</button>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<fieldset class="dmg-styles">
|
|
28
|
+
<legend>{ui.styleLabel}</legend>
|
|
29
|
+
<label class="dmg-style-card">
|
|
30
|
+
<input type="radio" name="style" value="dungeon" checked />
|
|
31
|
+
<span class="dmg-style-mark" aria-hidden="true"></span>
|
|
32
|
+
<strong>{ui.dungeonStyle}</strong>
|
|
33
|
+
<small>{ui.dungeonStyleHint}</small>
|
|
34
|
+
</label>
|
|
35
|
+
<label class="dmg-style-card">
|
|
36
|
+
<input type="radio" name="style" value="cavern" />
|
|
37
|
+
<span class="dmg-style-mark dmg-style-mark-cavern" aria-hidden="true"></span>
|
|
38
|
+
<strong>{ui.cavernStyle}</strong>
|
|
39
|
+
<small>{ui.cavernStyleHint}</small>
|
|
40
|
+
</label>
|
|
41
|
+
<label class="dmg-style-card">
|
|
42
|
+
<input type="radio" name="style" value="scifi" />
|
|
43
|
+
<span class="dmg-style-mark dmg-style-mark-scifi" aria-hidden="true"></span>
|
|
44
|
+
<strong>{ui.scifiStyle}</strong>
|
|
45
|
+
<small>{ui.scifiStyleHint}</small>
|
|
46
|
+
</label>
|
|
47
|
+
</fieldset>
|
|
48
|
+
|
|
49
|
+
<div class="dmg-size-section">
|
|
50
|
+
<span class="dmg-section-label">{ui.sizeLabel}</span>
|
|
51
|
+
<div class="dmg-size-presets">
|
|
52
|
+
<button type="button" data-size data-columns="24" data-rows="18" data-density="38" aria-pressed="false">
|
|
53
|
+
<strong>{ui.sizeCompact}</strong><span>24 x 18</span>
|
|
54
|
+
</button>
|
|
55
|
+
<button type="button" data-size data-columns="36" data-rows="26" data-density="48" aria-pressed="true">
|
|
56
|
+
<strong>{ui.sizeSession}</strong><span>36 x 26</span>
|
|
57
|
+
</button>
|
|
58
|
+
<button type="button" data-size data-columns="52" data-rows="38" data-density="58" aria-pressed="false">
|
|
59
|
+
<strong>{ui.sizeStronghold}</strong><span>52 x 38</span>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<details class="dmg-grid-tuning">
|
|
65
|
+
<summary>{ui.fineTuneGrid}</summary>
|
|
66
|
+
<div class="dmg-measurements">
|
|
67
|
+
<label class="dmg-range">
|
|
68
|
+
<span>{ui.columnsLabel}</span>
|
|
69
|
+
<output data-output="columns">{DEFAULT_CONFIG.columns}</output>
|
|
70
|
+
<input name="columns" data-live-value type="range" min="16" max="64" value={DEFAULT_CONFIG.columns} />
|
|
71
|
+
</label>
|
|
72
|
+
<label class="dmg-range">
|
|
73
|
+
<span>{ui.rowsLabel}</span>
|
|
74
|
+
<output data-output="rows">{DEFAULT_CONFIG.rows}</output>
|
|
75
|
+
<input name="rows" data-live-value type="range" min="14" max="48" value={DEFAULT_CONFIG.rows} />
|
|
76
|
+
</label>
|
|
77
|
+
<label class="dmg-range">
|
|
78
|
+
<span>{ui.densityLabel}</span>
|
|
79
|
+
<output data-output="density">{DEFAULT_CONFIG.density}</output>
|
|
80
|
+
<input name="density" data-live-value type="range" min="20" max="75" value={DEFAULT_CONFIG.density} />
|
|
81
|
+
<small>{ui.densityHint}</small>
|
|
82
|
+
</label>
|
|
83
|
+
</div>
|
|
84
|
+
</details>
|
|
85
|
+
|
|
86
|
+
<button class="dmg-generate" type="button" data-generate>{ui.generate}</button>
|
|
87
|
+
</section>
|
|
88
|
+
|
|
89
|
+
<section class="dmg-chart" aria-label={ui.mapRegionLabel}>
|
|
90
|
+
<div class="dmg-map-frame">
|
|
91
|
+
<span class="dmg-edit-hint">{ui.editHint}</span>
|
|
92
|
+
<div class="dmg-map-host" data-map-host set:html={buildMapSvg(initialMap)}></div>
|
|
93
|
+
<div class="dmg-context-menu" data-context-menu hidden role="menu" aria-label={ui.editCell}>
|
|
94
|
+
<button type="button" data-context-tool="floor" role="menuitem">{ui.paintFloor}</button>
|
|
95
|
+
<button type="button" data-context-tool="wall" role="menuitem">{ui.paintWall}</button>
|
|
96
|
+
<button type="button" data-context-tool="door" role="menuitem">{ui.paintDoor}</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div class="dmg-map-footer" aria-live="polite">
|
|
101
|
+
<dl class="dmg-stats">
|
|
102
|
+
<div><dt>{ui.roomsLabel}</dt><dd data-room-count>{initialMap.rooms.length}</dd></div>
|
|
103
|
+
<div><dt>{ui.doorsLabel}</dt><dd data-door-count>{initialMap.doorCount}</dd></div>
|
|
104
|
+
<div><dt>{ui.floorLabel}</dt><dd data-floor-percent>{initialEvaluation.coverage}%</dd></div>
|
|
105
|
+
</dl>
|
|
106
|
+
<div class="dmg-legend" aria-label={ui.mapReady}>
|
|
107
|
+
<span><i data-tile="floor"></i>{ui.legendFloor}</span>
|
|
108
|
+
<span><i data-tile="wall"></i>{ui.legendWall}</span>
|
|
109
|
+
<span><i data-tile="door"></i>{ui.legendDoor}</span>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div class="dmg-actions">
|
|
114
|
+
<button class="dmg-action-primary" type="button" data-export-png>{ui.exportPng}</button>
|
|
115
|
+
<button type="button" data-export-svg>{ui.exportSvg}</button>
|
|
116
|
+
<button type="button" data-copy-link>{ui.copyLink}</button>
|
|
117
|
+
<details class="dmg-map-data">
|
|
118
|
+
<summary>{ui.mapData}</summary>
|
|
119
|
+
<div>
|
|
120
|
+
<button type="button" data-export-json>{ui.exportJson}</button>
|
|
121
|
+
<label class="dmg-import">
|
|
122
|
+
<span>{ui.importJson}</span>
|
|
123
|
+
<input type="file" accept="application/json,.json" data-import-json />
|
|
124
|
+
</label>
|
|
125
|
+
</div>
|
|
126
|
+
</details>
|
|
127
|
+
</div>
|
|
128
|
+
</section>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<script>
|
|
132
|
+
import { mountDungeonMapGenerators } from './controller';
|
|
133
|
+
mountDungeonMapGenerators();
|
|
134
|
+
</script>
|
|
@@ -0,0 +1,242 @@
|
|
|
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-json]')?.addEventListener('click', () => exportJson(state.map, state.edits));
|
|
106
|
+
state.root.querySelector<HTMLInputElement>('[data-import-json]')?.addEventListener('change', (event) => {
|
|
107
|
+
void importJson(state, event.currentTarget as HTMLInputElement);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function bindEditor(state: GeneratorState): void {
|
|
112
|
+
bindContextEditor(state.root, (x, y, tile) => applyCellEdit(state, x, y, tile));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function applyCellEdit(state: GeneratorState, x: number, y: number, tile: 0 | 1 | 2): void {
|
|
116
|
+
state.map = editDungeonTile(state.map, x, y, tile);
|
|
117
|
+
state.edits = updateEdits(state, x, y);
|
|
118
|
+
saveState(state);
|
|
119
|
+
renderMap(state);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function updateEdits(state: GeneratorState, x: number, y: number): DungeonEdit[] {
|
|
123
|
+
const edits = state.edits.filter((edit) => edit.x !== x || edit.y !== y);
|
|
124
|
+
const tile = state.map.tiles[y]?.[x];
|
|
125
|
+
if (tile === undefined || tile === state.baseMap.tiles[y]?.[x]) return edits;
|
|
126
|
+
return [...edits, { x, y, tile }];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function regenerate(state: GeneratorState): void {
|
|
130
|
+
state.config = readConfig(state.root);
|
|
131
|
+
state.baseMap = generateDungeon(state.config);
|
|
132
|
+
state.map = state.baseMap;
|
|
133
|
+
state.edits = [];
|
|
134
|
+
saveState(state);
|
|
135
|
+
syncSizePresets(state);
|
|
136
|
+
renderMap(state);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readConfig(root: HTMLElement): DungeonConfig {
|
|
140
|
+
return normalizeConfig({
|
|
141
|
+
columns: numberValue(root, '[name="columns"]') ?? DEFAULT_CONFIG.columns,
|
|
142
|
+
rows: numberValue(root, '[name="rows"]') ?? DEFAULT_CONFIG.rows,
|
|
143
|
+
density: numberValue(root, '[name="density"]') ?? DEFAULT_CONFIG.density,
|
|
144
|
+
seed: root.querySelector<HTMLInputElement>('[name="seed"]')?.value || DEFAULT_CONFIG.seed,
|
|
145
|
+
style: readStyle(root),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readStyle(root: HTMLElement): DungeonStyle {
|
|
150
|
+
const value = root.querySelector<HTMLInputElement>('[name="style"]:checked')?.value;
|
|
151
|
+
return value === 'cavern' || value === 'scifi' ? value : 'dungeon';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function numberValue(root: HTMLElement, selector: string): number | undefined {
|
|
155
|
+
const value = root.querySelector<HTMLInputElement>(selector)?.valueAsNumber;
|
|
156
|
+
return Number.isFinite(value) ? value : undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function syncControls(state: GeneratorState): void {
|
|
160
|
+
setInput(state.root, 'seed', state.config.seed);
|
|
161
|
+
setInput(state.root, 'columns', String(state.config.columns));
|
|
162
|
+
setInput(state.root, 'rows', String(state.config.rows));
|
|
163
|
+
setInput(state.root, 'density', String(state.config.density));
|
|
164
|
+
const style = state.root.querySelector<HTMLInputElement>(`[name="style"][value="${state.config.style}"]`);
|
|
165
|
+
if (style) style.checked = true;
|
|
166
|
+
syncSizePresets(state);
|
|
167
|
+
state.root.querySelectorAll<HTMLInputElement>('[data-live-value]').forEach((input) => updateOutput(state.root, input));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function syncSizePresets(state: GeneratorState): void {
|
|
171
|
+
state.root.querySelectorAll<HTMLButtonElement>('[data-size]').forEach((button) => {
|
|
172
|
+
const matchesColumns = Number(button.dataset.columns) === state.config.columns;
|
|
173
|
+
const matchesRows = Number(button.dataset.rows) === state.config.rows;
|
|
174
|
+
const matchesDensity = Number(button.dataset.density) === state.config.density;
|
|
175
|
+
button.setAttribute('aria-pressed', String(matchesColumns && matchesRows && matchesDensity));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function setInput(root: HTMLElement, name: string, value: string): void {
|
|
180
|
+
const input = root.querySelector<HTMLInputElement>(`[name="${name}"]`);
|
|
181
|
+
if (input) input.value = value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function updateOutput(root: HTMLElement, input: HTMLInputElement): void {
|
|
185
|
+
const output = root.querySelector<HTMLOutputElement>(`[data-output="${input.name}"]`);
|
|
186
|
+
if (output) output.value = input.value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function randomizeSeed(state: GeneratorState): void {
|
|
190
|
+
const values = new Uint32Array(2);
|
|
191
|
+
crypto.getRandomValues(values);
|
|
192
|
+
const seed = `${WORDS[values[0]! % WORDS.length]}-${PLACES[values[1]! % PLACES.length]}`;
|
|
193
|
+
setInput(state.root, 'seed', seed);
|
|
194
|
+
regenerate(state);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function copyLink(state: GeneratorState): Promise<void> {
|
|
198
|
+
const url = new URL(window.location.href);
|
|
199
|
+
url.searchParams.set('dungeon', encodeDocument(documentFromState(state)));
|
|
200
|
+
try {
|
|
201
|
+
await navigator.clipboard.writeText(url.toString());
|
|
202
|
+
showCopyStatus(state);
|
|
203
|
+
} catch {
|
|
204
|
+
window.prompt(state.ui.copyLink, url.toString());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function showCopyStatus(state: GeneratorState): void {
|
|
209
|
+
const button = state.root.querySelector<HTMLButtonElement>('[data-copy-link]');
|
|
210
|
+
if (!button) return;
|
|
211
|
+
const original = button.textContent || state.ui.copyLink;
|
|
212
|
+
button.textContent = state.ui.linkCopied;
|
|
213
|
+
window.setTimeout(() => { button.textContent = original; }, 1800);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function importJson(state: GeneratorState, input: HTMLInputElement): Promise<void> {
|
|
217
|
+
const file = input.files?.[0];
|
|
218
|
+
if (!file) return;
|
|
219
|
+
try {
|
|
220
|
+
const document = decodeDocument(await file.text());
|
|
221
|
+
if (!document) throw new Error('Invalid dungeon document');
|
|
222
|
+
state.config = document.config;
|
|
223
|
+
state.baseMap = generateDungeon(document.config);
|
|
224
|
+
state.edits = document.edits;
|
|
225
|
+
state.map = applyDungeonEdits(state.baseMap, state.edits);
|
|
226
|
+
syncControls(state);
|
|
227
|
+
saveState(state);
|
|
228
|
+
renderMap(state);
|
|
229
|
+
} catch {
|
|
230
|
+
window.alert(state.ui.importError);
|
|
231
|
+
} finally {
|
|
232
|
+
input.value = '';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function documentFromState(state: GeneratorState): DungeonDocument {
|
|
237
|
+
return { version: 1, config: state.config, edits: state.edits };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function saveState(state: GeneratorState): void {
|
|
241
|
+
saveDocument(documentFromState(state));
|
|
242
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeConfig,
|
|
3
|
+
type Door,
|
|
4
|
+
type DungeonConfig,
|
|
5
|
+
type DungeonMap,
|
|
6
|
+
type Tile,
|
|
7
|
+
} from './logic';
|
|
8
|
+
|
|
9
|
+
export interface DungeonEdit {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
tile: Tile;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DungeonDocument {
|
|
16
|
+
version: 1;
|
|
17
|
+
config: DungeonConfig;
|
|
18
|
+
edits: DungeonEdit[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function editDungeonTile(map: DungeonMap, x: number, y: number, tile: Tile): DungeonMap {
|
|
22
|
+
if (!map.tiles[y] || map.tiles[y]![x] === undefined) return map;
|
|
23
|
+
const tiles = map.tiles.map((row) => [...row]);
|
|
24
|
+
tiles[y]![x] = tile;
|
|
25
|
+
const doors = map.doors.filter((door) => door.x !== x || door.y !== y);
|
|
26
|
+
if (tile === 2) doors.push({ x, y, orientation: inferDoorOrientation(tiles, x, y) });
|
|
27
|
+
return createEditedMap(map, tiles, doors);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createEditedMap(map: DungeonMap, tiles: Tile[][], doors: Door[]): DungeonMap {
|
|
31
|
+
return {
|
|
32
|
+
...map,
|
|
33
|
+
tiles,
|
|
34
|
+
doors,
|
|
35
|
+
doorCount: doors.length,
|
|
36
|
+
floorCount: tiles.flat().filter((tile) => tile !== 0).length,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function inferDoorOrientation(tiles: Tile[][], x: number, y: number): Door['orientation'] {
|
|
41
|
+
const horizontalTravel = tiles[y]?.[x - 1] !== 0 && tiles[y]?.[x + 1] !== 0;
|
|
42
|
+
return horizontalTravel ? 'vertical' : 'horizontal';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applyDungeonEdits(map: DungeonMap, edits: DungeonEdit[]): DungeonMap {
|
|
46
|
+
return edits.reduce((current, edit) => editDungeonTile(current, edit.x, edit.y, edit.tile), map);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function encodeDocument(document: DungeonDocument): string {
|
|
50
|
+
return JSON.stringify({ version: 1, config: normalizeConfig(document.config), edits: document.edits });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function decodeDocument(value: string): DungeonDocument | undefined {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(value) as Partial<DungeonDocument>;
|
|
56
|
+
if (!parsed.config) return undefined;
|
|
57
|
+
return {
|
|
58
|
+
version: 1,
|
|
59
|
+
config: normalizeConfig(parsed.config),
|
|
60
|
+
edits: Array.isArray(parsed.edits) ? parsed.edits.filter(isDungeonEdit) : [],
|
|
61
|
+
};
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isDungeonEdit(value: unknown): value is DungeonEdit {
|
|
68
|
+
if (!value || typeof value !== 'object') return false;
|
|
69
|
+
const edit = value as Partial<DungeonEdit>;
|
|
70
|
+
return Number.isInteger(edit.x) && Number.isInteger(edit.y)
|
|
71
|
+
&& (edit.tile === 0 || edit.tile === 1 || edit.tile === 2);
|
|
72
|
+
}
|