@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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { DungeonMapGeneratorLocaleContent, DungeonMapGeneratorUI } from '../entry';
|
|
4
|
+
|
|
5
|
+
const ui: DungeonMapGeneratorUI = {
|
|
6
|
+
intro: '选择种子值,设置网格大小与建筑风格。生成全连通的地下城地图,适用于笔记、打印或虚拟桌宠/VTT。',
|
|
7
|
+
seedLabel: '地图种子 (Seed)',
|
|
8
|
+
seedHint: '使用相同的种子与设置可以完全重构相同的地图。',
|
|
9
|
+
randomSeed: '新种子',
|
|
10
|
+
sizeLabel: '探险规模',
|
|
11
|
+
sizeCompact: '单次模组 (One-shot)',
|
|
12
|
+
sizeSession: '标准跑团',
|
|
13
|
+
sizeStronghold: '大型堡垒',
|
|
14
|
+
fineTuneGrid: '网格与密度微调',
|
|
15
|
+
columnsLabel: '列数(横向)',
|
|
16
|
+
rowsLabel: '行数(纵向)',
|
|
17
|
+
densityLabel: '房间密度',
|
|
18
|
+
densityHint: '更高的密度会生成更多房间和可行走地面。',
|
|
19
|
+
styleLabel: '建筑风格',
|
|
20
|
+
dungeonStyle: '地牢 (Dungeon)',
|
|
21
|
+
dungeonStyleHint: '石质房间与单格宽度的通道。',
|
|
22
|
+
cavernStyle: '洞穴 (Cavern)',
|
|
23
|
+
cavernStyleHint: '边缘圆润的不规则天然溶洞。',
|
|
24
|
+
scifiStyle: '科幻舱室 (Sci-Fi)',
|
|
25
|
+
scifiStyleHint: '宽敞的模块舱室与双格宽通道。',
|
|
26
|
+
generate: '绘制此地图',
|
|
27
|
+
mapRegionLabel: '生成的地下城地图及导出控制',
|
|
28
|
+
connectedBadge: '所有房间已连通',
|
|
29
|
+
roomsLabel: '房间数',
|
|
30
|
+
doorsLabel: '门数量',
|
|
31
|
+
floorLabel: '地面覆盖率',
|
|
32
|
+
legendFloor: '可行走地面',
|
|
33
|
+
legendWall: '实体墙壁',
|
|
34
|
+
legendDoor: '门 / 气闸',
|
|
35
|
+
editHint: '右键点击格点进行编辑',
|
|
36
|
+
editCell: '编辑此格',
|
|
37
|
+
paintFloor: '地面',
|
|
38
|
+
paintWall: '墙壁',
|
|
39
|
+
paintDoor: '门',
|
|
40
|
+
mapData: '地图数据',
|
|
41
|
+
copyLink: '复制地图链接',
|
|
42
|
+
linkCopied: '链接已复制',
|
|
43
|
+
exportPng: '下载 PNG',
|
|
44
|
+
exportSvg: '下载 SVG',
|
|
45
|
+
exportJson: '保存 JSON',
|
|
46
|
+
importJson: '打开 JSON',
|
|
47
|
+
importError: '此文件不包含有效的地下城地图设置。',
|
|
48
|
+
compactMap: '紧张探索',
|
|
49
|
+
balancedMap: '平衡探险',
|
|
50
|
+
sprawlingMap: '开放堡垒',
|
|
51
|
+
compactHint: '较长的通道使房间之间留出更多未知区域。',
|
|
52
|
+
balancedHint: '房间与通道比例得当。',
|
|
53
|
+
sprawlingHint: '广阔的地面有利于战斗与快速移动。',
|
|
54
|
+
mapReady: '图例说明',
|
|
55
|
+
dimensionsUnit: '网格格数',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const faq = [
|
|
59
|
+
{
|
|
60
|
+
question: '相同的种子总是生成相同的地下城地图吗?',
|
|
61
|
+
answer: '是的。种子、网格尺寸、密度和风格构成确定的配置,使用相同设置将重构完全相同的地图。',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
question: '生成的房间都是互通的吗?',
|
|
65
|
+
answer: '是的。房间在生成后会通过主路径连接,确保所有地面都属于同一个连通地图。',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
question: '地牢、洞穴和科幻风格有什么区别?',
|
|
69
|
+
answer: '地牢风格采用规则的石室,洞穴风格生成不规则的天然溶洞,科幻风格采用宽敞模块和双格通道。',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
question: '虚拟桌宠/跑团平台(VTT)应该导出什么格式?',
|
|
73
|
+
answer: 'PNG 格式最适合大多数 VTT 平台。SVG 格式支持无损缩放,方便在矢量软件中编辑。',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
question: '其他人可以重现我生成的随机地图吗?',
|
|
77
|
+
answer: '可以。只需复制地图链接或发送 JSON 文件,对方即可载入完全相同的地图配置。',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
question: '这个生成器会自动添加怪物、陷阱或宝箱吗?',
|
|
81
|
+
answer: '不会。它生成中立的建筑平面图,主持人可以根据任何规则书自行填充内容。',
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const howTo = [
|
|
86
|
+
{
|
|
87
|
+
name: '设置网格尺寸',
|
|
88
|
+
text: '选择适合您的笔记或 VTT 场景的列数和行数。',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: '选择建筑风格',
|
|
92
|
+
text: '选择地牢、洞穴或科幻风格,并调整房间密度。',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: '生成并检查',
|
|
96
|
+
text: '输入种子生成地图,查看房间和门的数据统计。',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: '导出或分享',
|
|
100
|
+
text: '下载 PNG/SVG 图像,或通过链接和 JSON 保存配置。',
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
105
|
+
'@context': 'https://schema.org',
|
|
106
|
+
'@type': 'FAQPage',
|
|
107
|
+
mainEntity: faq.map((item) => ({
|
|
108
|
+
'@type': 'Question',
|
|
109
|
+
name: item.question,
|
|
110
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
111
|
+
})),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
115
|
+
'@context': 'https://schema.org',
|
|
116
|
+
'@type': 'SoftwareApplication',
|
|
117
|
+
name: '随机地下城地图生成器',
|
|
118
|
+
operatingSystem: 'All',
|
|
119
|
+
applicationCategory: 'UtilitiesApplication',
|
|
120
|
+
description: '全房间连通的随机地下城地图生成工具。',
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const howToSchema: WithContext<HowTo> = {
|
|
124
|
+
'@context': 'https://schema.org',
|
|
125
|
+
'@type': 'HowTo',
|
|
126
|
+
name: '如何生成全连通的地下城地图',
|
|
127
|
+
step: howTo.map((item) => ({
|
|
128
|
+
'@type': 'HowToStep',
|
|
129
|
+
name: item.name,
|
|
130
|
+
text: item.text,
|
|
131
|
+
})),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const content: DungeonMapGeneratorLocaleContent = {
|
|
135
|
+
slug: 'random-dungeon-map-generator',
|
|
136
|
+
title: '桌游 TRPG 随机地下城地图生成器',
|
|
137
|
+
description: '生成全房间连通的地下城、洞穴与科幻地图,支持 PNG 和 SVG 格式导出。',
|
|
138
|
+
ui,
|
|
139
|
+
seo: [
|
|
140
|
+
{ type: 'title', text: '使用可复用种子生成可玩的地下城地图', level: 2 },
|
|
141
|
+
{ type: 'paragraph', html: '实用且好用的随机地下城地图需要超越单纯的随机视觉噪声。每个房间都必须通过连通的通道确保玩家可达,网格大小需完美匹配游戏桌面或线上跑团软件,并且结果可以在任何设备上随时重现。本生成器将地图种子与各项参数视为一份精炼且确定性的地图规格说明。只要保持相同的种子、行列数、密度和建筑风格,日后就能完全重构出相同的平面图。' },
|
|
142
|
+
{ type: 'title', text: '适合打印与虚拟桌面(VTT)的网格尺寸', level: 2 },
|
|
143
|
+
{ type: 'paragraph', html: '设置网格大小是规划跑团会话时的实际第一步。较小的网格易于打印在 A4 纸张上用于单次短跑模组或特定地点,而大型地图则为复杂的战术探索、阵营拉锯与备用路线提供充裕空间。导出的图像保持完美的正方形网格单元,以便无缝对齐虚拟桌面 (VTT) 软件中的网格设置。' },
|
|
144
|
+
{
|
|
145
|
+
type: 'table',
|
|
146
|
+
headers: ['网格尺寸', '典型用途', '初始密度', '设计建议'],
|
|
147
|
+
rows: [
|
|
148
|
+
['20 x 16 格', '短跑模组 (One-shot)', '35 至 45', '易于打印在纸张上,视觉读取直观快速'],
|
|
149
|
+
['36 x 26 格', '标准跑团地牢', '45 至 55', '房间与通道节奏保持良好平衡'],
|
|
150
|
+
['52 x 38 格', '大型复合建筑', '50 至 65', '容纳多个阵营和备用通道路线'],
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
{ type: 'tip', title: '根据遭遇战规模调整地图大小', html: '在选择总尺寸之前,先计算大型战斗遭遇或BOSS战所需的格数。大型地牢如果充满微型房间会显得拥挤狭窄,而带有宽敞中央大厅的紧凑平面图则有利于营造难忘的遭遇战场景。' },
|
|
154
|
+
{ type: 'title', text: '将地面覆盖率作为探索节奏信号', level: 2 },
|
|
155
|
+
{ type: 'paragraph', html: '地面覆盖率表示画布中可供玩家移动的区域百分比。低覆盖率强调迷雾中的未知感、隔绝感与房间之间的紧张感。中等覆盖率提供稳健的探险节奏。高覆盖率则构建出一个守备森严的堡垒或设施,其中的战斗与巡逻会迅速蔓延至相邻房间。' },
|
|
156
|
+
{
|
|
157
|
+
type: 'list',
|
|
158
|
+
items: [
|
|
159
|
+
'<strong>紧张探索:</strong> 利用空白区域提示隐藏通道、深邃空间或未知的隔绝区域。',
|
|
160
|
+
'<strong>平衡探险:</strong> 交替配置调查房间、连接通道与战斗遭遇区域。',
|
|
161
|
+
'<strong>开放堡垒:</strong> 预留清晰视线、巡逻路线以及相互关联的遭遇事件。',
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
{ type: 'title', text: '将中立平面图转化为冒险舞台', level: 2 },
|
|
165
|
+
{ type: 'paragraph', html: '生成的地图故意仅提供基础建筑结构。请为每个主要房间赋予明确的叙事功能,并据此布置线索、陷阱、生物与宝藏。将门与气闸用作玩家做出的战略决策点。' },
|
|
166
|
+
{ type: 'tip', title: '在记录遭遇细节前保存配置', html: '在添加地下城主 (DM) 笔记之前复制地图链接或保存 JSON 配置,即可为玩家保留一份干净的地图,而不会泄露剧透细节。' },
|
|
167
|
+
],
|
|
168
|
+
faq,
|
|
169
|
+
bibliography,
|
|
170
|
+
howTo,
|
|
171
|
+
schemas: [faqSchema, appSchema, howToSchema] as unknown as Record<string, unknown>[],
|
|
172
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { dungeonMapGenerator } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const DUNGEON_MAP_GENERATOR_TOOL: ToolDefinition = {
|
|
7
|
+
entry: dungeonMapGenerator,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { decodeDocument, editDungeonTile, encodeDocument } from './document';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_CONFIG,
|
|
5
|
+
decodeConfig,
|
|
6
|
+
encodeConfig,
|
|
7
|
+
generateDungeon,
|
|
8
|
+
normalizeConfig,
|
|
9
|
+
} from './logic';
|
|
10
|
+
import { isDungeonConnected } from './validation';
|
|
11
|
+
|
|
12
|
+
describe('dungeon map generation', () => {
|
|
13
|
+
it('returns the same layout for the same seed and configuration', () => {
|
|
14
|
+
const first = generateDungeon(DEFAULT_CONFIG);
|
|
15
|
+
const second = generateDungeon(DEFAULT_CONFIG);
|
|
16
|
+
expect(second.tiles).toEqual(first.tiles);
|
|
17
|
+
expect(second.rooms).toEqual(first.rooms);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('changes the layout when the seed changes', () => {
|
|
21
|
+
const first = generateDungeon({ ...DEFAULT_CONFIG, seed: 'amber' });
|
|
22
|
+
const second = generateDungeon({ ...DEFAULT_CONFIG, seed: 'indigo' });
|
|
23
|
+
expect(second.tiles).not.toEqual(first.tiles);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it.each(['dungeon', 'cavern', 'scifi'] as const)('creates a connected %s map', (style) => {
|
|
27
|
+
const map = generateDungeon({ ...DEFAULT_CONFIG, style, seed: `connected-${style}` });
|
|
28
|
+
expect(map.rooms.length).toBeGreaterThanOrEqual(2);
|
|
29
|
+
expect(map.doorCount).toBeGreaterThan(0);
|
|
30
|
+
expect(isDungeonConnected(map)).toBe(true);
|
|
31
|
+
map.doors.forEach((door) => {
|
|
32
|
+
const neighbors = [
|
|
33
|
+
map.tiles[door.y]?.[door.x - 1],
|
|
34
|
+
map.tiles[door.y]?.[door.x + 1],
|
|
35
|
+
map.tiles[door.y - 1]?.[door.x],
|
|
36
|
+
map.tiles[door.y + 1]?.[door.x],
|
|
37
|
+
].filter((tile) => tile !== undefined && tile !== 0);
|
|
38
|
+
expect(neighbors.length).toBeGreaterThanOrEqual(2);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('edits floor, wall, and oriented door cells', () => {
|
|
43
|
+
let map = generateDungeon(DEFAULT_CONFIG);
|
|
44
|
+
map = editDungeonTile(map, 0, 0, 1);
|
|
45
|
+
expect(map.tiles[0]?.[0]).toBe(1);
|
|
46
|
+
map = editDungeonTile(map, 1, 0, 2);
|
|
47
|
+
expect(map.doors.some((door) => door.x === 1 && door.y === 0)).toBe(true);
|
|
48
|
+
map = editDungeonTile(map, 1, 0, 0);
|
|
49
|
+
expect(map.doors.some((door) => door.x === 1 && door.y === 0)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('honors the normalized grid dimensions', () => {
|
|
53
|
+
const map = generateDungeon({ columns: 200, rows: 2, density: 120 });
|
|
54
|
+
expect(map.tiles).toHaveLength(14);
|
|
55
|
+
expect(map.tiles[0]).toHaveLength(64);
|
|
56
|
+
expect(map.config.density).toBe(75);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('falls back from invalid values', () => {
|
|
60
|
+
const config = normalizeConfig({ style: 'unknown' as never, seed: '' });
|
|
61
|
+
expect(config.style).toBe('dungeon');
|
|
62
|
+
expect(config.seed).toBe(DEFAULT_CONFIG.seed);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('round trips a shareable configuration', () => {
|
|
66
|
+
const config = normalizeConfig({ seed: 'glass-keep', style: 'scifi', columns: 52 });
|
|
67
|
+
expect(decodeConfig(encodeConfig(config))).toEqual(config);
|
|
68
|
+
expect(decodeConfig('{not-json')).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('round trips a document with manual edits', () => {
|
|
72
|
+
const document = { version: 1 as const, config: DEFAULT_CONFIG, edits: [{ x: 2, y: 3, tile: 1 as const }] };
|
|
73
|
+
expect(decodeDocument(encodeDocument(document))).toEqual(document);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
export type DungeonStyle = 'dungeon' | 'cavern' | 'scifi';
|
|
2
|
+
export type Tile = 0 | 1 | 2;
|
|
3
|
+
|
|
4
|
+
export interface DungeonConfig {
|
|
5
|
+
columns: number;
|
|
6
|
+
rows: number;
|
|
7
|
+
density: number;
|
|
8
|
+
seed: string;
|
|
9
|
+
style: DungeonStyle;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Room {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Door {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
orientation: 'horizontal' | 'vertical';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DungeonMap {
|
|
26
|
+
config: DungeonConfig;
|
|
27
|
+
tiles: Tile[][];
|
|
28
|
+
rooms: Room[];
|
|
29
|
+
doors: Door[];
|
|
30
|
+
doorCount: number;
|
|
31
|
+
floorCount: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type RandomSource = () => number;
|
|
35
|
+
type Point = { x: number; y: number };
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_CONFIG: DungeonConfig = {
|
|
38
|
+
columns: 36,
|
|
39
|
+
rows: 26,
|
|
40
|
+
density: 48,
|
|
41
|
+
seed: 'ember-vault',
|
|
42
|
+
style: 'dungeon',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const clamp = (value: number, min: number, max: number): number =>
|
|
46
|
+
Math.min(max, Math.max(min, Math.round(value)));
|
|
47
|
+
|
|
48
|
+
export function normalizeConfig(input: Partial<DungeonConfig>): DungeonConfig {
|
|
49
|
+
const style = isStyle(input.style) ? input.style : DEFAULT_CONFIG.style;
|
|
50
|
+
return {
|
|
51
|
+
columns: clamp(input.columns ?? DEFAULT_CONFIG.columns, 16, 64),
|
|
52
|
+
rows: clamp(input.rows ?? DEFAULT_CONFIG.rows, 14, 48),
|
|
53
|
+
density: clamp(input.density ?? DEFAULT_CONFIG.density, 20, 75),
|
|
54
|
+
seed: String(input.seed || DEFAULT_CONFIG.seed).slice(0, 48),
|
|
55
|
+
style,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isStyle(value: unknown): value is DungeonStyle {
|
|
60
|
+
return value === 'dungeon' || value === 'cavern' || value === 'scifi';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hashSeed(seed: string): number {
|
|
64
|
+
let hash = 2166136261;
|
|
65
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
66
|
+
hash ^= seed.charCodeAt(index);
|
|
67
|
+
hash = Math.imul(hash, 16777619);
|
|
68
|
+
}
|
|
69
|
+
return hash >>> 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createRandom(seed: string): RandomSource {
|
|
73
|
+
let state = hashSeed(seed);
|
|
74
|
+
return () => {
|
|
75
|
+
state += 0x6D2B79F5;
|
|
76
|
+
let value = state;
|
|
77
|
+
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
78
|
+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
79
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function randomInt(random: RandomSource, min: number, max: number): number {
|
|
84
|
+
return Math.floor(random() * (max - min + 1)) + min;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createGrid(config: DungeonConfig): Tile[][] {
|
|
88
|
+
return Array.from({ length: config.rows }, () =>
|
|
89
|
+
Array.from({ length: config.columns }, () => 0 as Tile));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function roomTarget(config: DungeonConfig): number {
|
|
93
|
+
const areaFactor = (config.columns * config.rows) / 150;
|
|
94
|
+
return clamp(areaFactor * (config.density / 45), 4, 18);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function roomSize(config: DungeonConfig, random: RandomSource): Point {
|
|
98
|
+
const wide = config.style === 'scifi';
|
|
99
|
+
const width = randomInt(random, wide ? 5 : 4, wide ? 10 : 9);
|
|
100
|
+
const height = randomInt(random, wide ? 4 : 4, wide ? 7 : 8);
|
|
101
|
+
return { x: width, y: height };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function overlaps(room: Room, other: Room): boolean {
|
|
105
|
+
return room.x - 1 < other.x + other.width
|
|
106
|
+
&& room.x + room.width + 1 > other.x
|
|
107
|
+
&& room.y - 1 < other.y + other.height
|
|
108
|
+
&& room.y + room.height + 1 > other.y;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createRoom(config: DungeonConfig, random: RandomSource): Room {
|
|
112
|
+
const size = roomSize(config, random);
|
|
113
|
+
return {
|
|
114
|
+
x: randomInt(random, 2, Math.max(2, config.columns - size.x - 2)),
|
|
115
|
+
y: randomInt(random, 2, Math.max(2, config.rows - size.y - 2)),
|
|
116
|
+
width: size.x,
|
|
117
|
+
height: size.y,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function placeRooms(config: DungeonConfig, random: RandomSource): Room[] {
|
|
122
|
+
const rooms: Room[] = [];
|
|
123
|
+
const attempts = roomTarget(config) * 18;
|
|
124
|
+
for (let index = 0; index < attempts && rooms.length < roomTarget(config); index += 1) {
|
|
125
|
+
const room = createRoom(config, random);
|
|
126
|
+
if (!rooms.some((existing) => overlaps(room, existing))) rooms.push(room);
|
|
127
|
+
}
|
|
128
|
+
return rooms.length >= 2 ? rooms : fallbackRooms(config);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fallbackRooms(config: DungeonConfig): Room[] {
|
|
132
|
+
const width = Math.max(4, Math.floor(config.columns / 5));
|
|
133
|
+
const height = Math.max(4, Math.floor(config.rows / 4));
|
|
134
|
+
return [
|
|
135
|
+
{ x: 2, y: 2, width, height },
|
|
136
|
+
{ x: config.columns - width - 2, y: config.rows - height - 2, width, height },
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function carveRoom(grid: Tile[][], room: Room, config: DungeonConfig, random: RandomSource): void {
|
|
141
|
+
for (let y = room.y; y < room.y + room.height; y += 1) {
|
|
142
|
+
for (let x = room.x; x < room.x + room.width; x += 1) {
|
|
143
|
+
if (config.style !== 'cavern' || isCavernFloor(room, x, y, random)) grid[y]![x] = 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isCavernFloor(room: Room, x: number, y: number, random: RandomSource): boolean {
|
|
149
|
+
const nx = Math.abs((x + 0.5 - room.x) / room.width - 0.5) * 2;
|
|
150
|
+
const ny = Math.abs((y + 0.5 - room.y) / room.height - 0.5) * 2;
|
|
151
|
+
return nx + ny < 1.35 + random() * 0.35;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function center(room: Room): Point {
|
|
155
|
+
return {
|
|
156
|
+
x: Math.floor(room.x + room.width / 2),
|
|
157
|
+
y: Math.floor(room.y + room.height / 2),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function line(from: Point, to: Point): Point[] {
|
|
162
|
+
const points: Point[] = [];
|
|
163
|
+
const stepX = from.x <= to.x ? 1 : -1;
|
|
164
|
+
const stepY = from.y <= to.y ? 1 : -1;
|
|
165
|
+
for (let x = from.x; x !== to.x; x += stepX) points.push({ x, y: from.y });
|
|
166
|
+
for (let y = from.y; y !== to.y; y += stepY) points.push({ x: to.x, y });
|
|
167
|
+
points.push(to);
|
|
168
|
+
return points;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function corridorPoints(from: Point, to: Point, random: RandomSource): Point[] {
|
|
172
|
+
if (random() < 0.5) return line(from, to);
|
|
173
|
+
return line({ x: from.y, y: from.x }, { x: to.y, y: to.x })
|
|
174
|
+
.map((point) => ({ x: point.y, y: point.x }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function carvePath(grid: Tile[][], points: Point[], style: DungeonStyle): Door[] {
|
|
178
|
+
const original = points.map(({ x, y }) => grid[y]?.[x] === 1);
|
|
179
|
+
const doors: Door[] = [];
|
|
180
|
+
points.forEach((point, index) => {
|
|
181
|
+
const entersWall = original[index - 1] === true && original[index] === false;
|
|
182
|
+
const entersRoom = original[index] === false && original[index + 1] === true;
|
|
183
|
+
const isDoor = entersWall || entersRoom;
|
|
184
|
+
carvePoint(grid, point, style, isDoor);
|
|
185
|
+
if (isDoor) doors.push(createDoor(point, points[index - 1], points[index + 1]));
|
|
186
|
+
});
|
|
187
|
+
return doors;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function createDoor(point: Point, previous?: Point, next?: Point): Door {
|
|
191
|
+
const horizontalTravel = previous?.y === point.y || next?.y === point.y;
|
|
192
|
+
return { ...point, orientation: horizontalTravel ? 'vertical' : 'horizontal' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function carvePoint(grid: Tile[][], point: Point, style: DungeonStyle, isDoor: boolean): void {
|
|
196
|
+
const row = grid[point.y];
|
|
197
|
+
if (!row || row[point.x] === undefined) return;
|
|
198
|
+
row[point.x] = isDoor ? 2 : 1;
|
|
199
|
+
const nextRow = grid[point.y + 1];
|
|
200
|
+
if (style === 'scifi' && nextRow?.[point.x] !== undefined) nextRow[point.x] = 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function connectRooms(grid: Tile[][], rooms: Room[], config: DungeonConfig, random: RandomSource): Door[] {
|
|
204
|
+
const ordered = [...rooms].sort((a, b) => center(a).x - center(b).x);
|
|
205
|
+
const doors: Door[] = [];
|
|
206
|
+
for (let index = 1; index < ordered.length; index += 1) {
|
|
207
|
+
const from = center(ordered[index - 1]!);
|
|
208
|
+
const to = center(ordered[index]!);
|
|
209
|
+
doors.push(...carvePath(grid, corridorPoints(from, to, random), config.style));
|
|
210
|
+
}
|
|
211
|
+
return uniqueDoors(doors);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function uniqueDoors(doors: Door[]): Door[] {
|
|
215
|
+
const seen = new Set<string>();
|
|
216
|
+
return doors.filter((door) => {
|
|
217
|
+
const key = `${door.x},${door.y}`;
|
|
218
|
+
if (seen.has(key)) return false;
|
|
219
|
+
seen.add(key);
|
|
220
|
+
return true;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function countTiles(tiles: Tile[][], target: Tile): number {
|
|
225
|
+
return tiles.reduce((sum, row) => sum + row.filter((tile) => tile === target).length, 0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function generateDungeon(input: Partial<DungeonConfig>): DungeonMap {
|
|
229
|
+
const config = normalizeConfig(input);
|
|
230
|
+
const random = createRandom(`${config.seed}:${config.style}:${config.columns}x${config.rows}`);
|
|
231
|
+
const tiles = createGrid(config);
|
|
232
|
+
const rooms = placeRooms(config, random);
|
|
233
|
+
rooms.forEach((room) => carveRoom(tiles, room, config, random));
|
|
234
|
+
const doors = connectRooms(tiles, rooms, config, random);
|
|
235
|
+
return {
|
|
236
|
+
config,
|
|
237
|
+
tiles,
|
|
238
|
+
rooms,
|
|
239
|
+
doors,
|
|
240
|
+
doorCount: doors.length,
|
|
241
|
+
floorCount: countTiles(tiles, 1) + countTiles(tiles, 2),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function encodeConfig(config: DungeonConfig): string {
|
|
246
|
+
return JSON.stringify(normalizeConfig(config));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function decodeConfig(value: string): DungeonConfig | undefined {
|
|
250
|
+
try {
|
|
251
|
+
return normalizeConfig(JSON.parse(value) as Partial<DungeonConfig>);
|
|
252
|
+
} catch {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|