@jjlmoya/utils-tabletop 1.32.0 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/category/i18n/de.ts +1 -1
- package/src/category/i18n/es.ts +1 -1
- package/src/category/i18n/fr.ts +1 -1
- package/src/category/i18n/id.ts +1 -1
- package/src/category/i18n/it.ts +1 -1
- package/src/category/i18n/nl.ts +1 -1
- package/src/category/i18n/pl.ts +1 -1
- package/src/category/i18n/pt.ts +1 -1
- package/src/category/i18n/ru.ts +1 -1
- package/src/category/i18n/sv.ts +1 -1
- package/src/category/i18n/tr.ts +1 -1
- package/src/category/index.ts +25 -25
- package/src/components/PreviewNavSidebar.astro +1 -1
- package/src/components/PreviewToolbar.astro +1 -1
- package/src/entries.ts +43 -43
- package/src/env.d.ts +1 -1
- package/src/index.ts +24 -24
- package/src/layouts/PreviewLayout.astro +93 -93
- package/src/pages/[locale]/[slug].astro +143 -143
- package/src/pages/index.astro +1 -1
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +74 -0
- package/src/tests/i18n_coverage.test.ts +36 -36
- package/src/tests/mocks/astro_mock.js +1 -1
- package/src/tests/no_em_dash.test.ts +47 -47
- package/src/tests/no_en_dash.test.ts +70 -70
- package/src/tests/seo_length.test.ts +3 -3
- package/src/tests/seo_parity.test.ts +2 -2
- package/src/tests/seo_translation_completeness.test.ts +18 -12
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/spanish_leakage.test.ts +175 -175
- package/src/tool/dungeon-map-generator/component.astro +135 -135
- package/src/tool/dungeon-map-generator/controller.ts +243 -243
- package/src/tool/dungeon-map-generator/dom-views.ts +144 -144
- package/src/tool/dungeon-map-generator/i18n/de.ts +172 -172
- package/src/tool/dungeon-map-generator/logic.ts +281 -281
- package/src/tool/dungeon-map-generator/random-dungeon-map-generator.css +692 -692
- package/src/tool/dungeon-map-generator/ui.ts +54 -54
- package/src/tool/fantasy-runes-translator/logic.ts +1 -1
- package/src/tool/first-player-selector/i18n/tr.ts +1 -1
- package/src/tool/investigation-board/i18n/ru.ts +209 -209
- package/src/tool/investigation-board/i18n/zh.ts +209 -209
- package/src/tool/lunar-tide-tracker/client.ts +264 -264
- package/src/tool/rpg-initiative-tracker/i18n/de.ts +3 -3
- package/src/tool/rpg-initiative-tracker/i18n/es.ts +1 -1
- package/src/tool/rpg-initiative-tracker/i18n/pl.ts +2 -2
- package/src/tool/rpg-initiative-tracker/i18n/ru.ts +1 -1
- package/src/tool/score-tracker/i18n/pt.ts +1 -1
- package/src/tool/score-tracker/i18n/sv.ts +1 -1
- package/src/tool/score-tracker/i18n/tr.ts +1 -1
- package/src/tool/score-tracker/i18n/zh.ts +1 -1
- package/src/tools.ts +25 -25
|
@@ -1,281 +1,281 @@
|
|
|
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 isRoomCorner(point: Point, rooms: Room[]): boolean {
|
|
204
|
-
return rooms.some((r) => (point.x === r.x || point.x === r.x + r.width - 1)
|
|
205
|
-
&& (point.y === r.y || point.y === r.y + r.height - 1));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function tileAt(grid: Tile[][], x: number, y: number): Tile {
|
|
209
|
-
return grid[y]?.[x] ?? 0;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function isVerticalDoorFrame(grid: Tile[][], x: number, y: number): boolean {
|
|
213
|
-
const isWallPair = tileAt(grid, x, y - 1) === 0 && tileAt(grid, x, y + 1) === 0;
|
|
214
|
-
const isFloorPair = tileAt(grid, x - 1, y) !== 0 && tileAt(grid, x + 1, y) !== 0;
|
|
215
|
-
return isWallPair && isFloorPair;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function isHorizontalDoorFrame(grid: Tile[][], x: number, y: number): boolean {
|
|
219
|
-
const isWallPair = tileAt(grid, x - 1, y) === 0 && tileAt(grid, x + 1, y) === 0;
|
|
220
|
-
const isFloorPair = tileAt(grid, x, y - 1) !== 0 && tileAt(grid, x, y + 1) !== 0;
|
|
221
|
-
return isWallPair && isFloorPair;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function hasDoorWallFrame(grid: Tile[][], point: Point): boolean {
|
|
225
|
-
return isVerticalDoorFrame(grid, point.x, point.y) || isHorizontalDoorFrame(grid, point.x, point.y);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function filterValidDoors(grid: Tile[][], rooms: Room[], candidateDoors: Door[]): Door[] {
|
|
229
|
-
const validDoors: Door[] = [];
|
|
230
|
-
for (const door of uniqueDoors(candidateDoors)) {
|
|
231
|
-
if (isRoomCorner(door, rooms) || !hasDoorWallFrame(grid, door)) {
|
|
232
|
-
if (grid[door.y]?.[door.x] !== undefined) grid[door.y]![door.x] = 1;
|
|
233
|
-
} else {
|
|
234
|
-
validDoors.push(door);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return validDoors;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function connectRooms(grid: Tile[][], rooms: Room[], config: DungeonConfig, random: RandomSource): Door[] {
|
|
241
|
-
const ordered = [...rooms].sort((a, b) => center(a).x - center(b).x);
|
|
242
|
-
const candidateDoors: Door[] = [];
|
|
243
|
-
for (let index = 1; index < ordered.length; index += 1) {
|
|
244
|
-
const from = center(ordered[index - 1]!);
|
|
245
|
-
const to = center(ordered[index]!);
|
|
246
|
-
candidateDoors.push(...carvePath(grid, corridorPoints(from, to, random), config.style));
|
|
247
|
-
}
|
|
248
|
-
return filterValidDoors(grid, rooms, candidateDoors);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function uniqueDoors(doors: Door[]): Door[] {
|
|
252
|
-
const seen = new Set<string>();
|
|
253
|
-
return doors.filter((door) => {
|
|
254
|
-
const key = `${door.x},${door.y}`;
|
|
255
|
-
if (seen.has(key)) return false;
|
|
256
|
-
seen.add(key);
|
|
257
|
-
return true;
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function countTiles(tiles: Tile[][], target: Tile): number {
|
|
262
|
-
return tiles.reduce((sum, row) => sum + row.filter((tile) => tile === target).length, 0);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export function generateDungeon(input: Partial<DungeonConfig>): DungeonMap {
|
|
266
|
-
const config = normalizeConfig(input);
|
|
267
|
-
const random = createRandom(`${config.seed}:${config.style}:${config.columns}x${config.rows}`);
|
|
268
|
-
const tiles = createGrid(config);
|
|
269
|
-
const rooms = placeRooms(config, random);
|
|
270
|
-
rooms.forEach((room) => carveRoom(tiles, room, config, random));
|
|
271
|
-
const doors = connectRooms(tiles, rooms, config, random);
|
|
272
|
-
return { config, tiles, rooms, doors, doorCount: doors.length, floorCount: countTiles(tiles, 1) + countTiles(tiles, 2) };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export function encodeConfig(config: DungeonConfig): string {
|
|
276
|
-
return JSON.stringify(normalizeConfig(config));
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
export function decodeConfig(value: string): DungeonConfig | undefined {
|
|
280
|
-
try { return normalizeConfig(JSON.parse(value) as Partial<DungeonConfig>); } catch { return undefined; }
|
|
281
|
-
}
|
|
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 isRoomCorner(point: Point, rooms: Room[]): boolean {
|
|
204
|
+
return rooms.some((r) => (point.x === r.x || point.x === r.x + r.width - 1)
|
|
205
|
+
&& (point.y === r.y || point.y === r.y + r.height - 1));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function tileAt(grid: Tile[][], x: number, y: number): Tile {
|
|
209
|
+
return grid[y]?.[x] ?? 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isVerticalDoorFrame(grid: Tile[][], x: number, y: number): boolean {
|
|
213
|
+
const isWallPair = tileAt(grid, x, y - 1) === 0 && tileAt(grid, x, y + 1) === 0;
|
|
214
|
+
const isFloorPair = tileAt(grid, x - 1, y) !== 0 && tileAt(grid, x + 1, y) !== 0;
|
|
215
|
+
return isWallPair && isFloorPair;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isHorizontalDoorFrame(grid: Tile[][], x: number, y: number): boolean {
|
|
219
|
+
const isWallPair = tileAt(grid, x - 1, y) === 0 && tileAt(grid, x + 1, y) === 0;
|
|
220
|
+
const isFloorPair = tileAt(grid, x, y - 1) !== 0 && tileAt(grid, x, y + 1) !== 0;
|
|
221
|
+
return isWallPair && isFloorPair;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function hasDoorWallFrame(grid: Tile[][], point: Point): boolean {
|
|
225
|
+
return isVerticalDoorFrame(grid, point.x, point.y) || isHorizontalDoorFrame(grid, point.x, point.y);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function filterValidDoors(grid: Tile[][], rooms: Room[], candidateDoors: Door[]): Door[] {
|
|
229
|
+
const validDoors: Door[] = [];
|
|
230
|
+
for (const door of uniqueDoors(candidateDoors)) {
|
|
231
|
+
if (isRoomCorner(door, rooms) || !hasDoorWallFrame(grid, door)) {
|
|
232
|
+
if (grid[door.y]?.[door.x] !== undefined) grid[door.y]![door.x] = 1;
|
|
233
|
+
} else {
|
|
234
|
+
validDoors.push(door);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return validDoors;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function connectRooms(grid: Tile[][], rooms: Room[], config: DungeonConfig, random: RandomSource): Door[] {
|
|
241
|
+
const ordered = [...rooms].sort((a, b) => center(a).x - center(b).x);
|
|
242
|
+
const candidateDoors: Door[] = [];
|
|
243
|
+
for (let index = 1; index < ordered.length; index += 1) {
|
|
244
|
+
const from = center(ordered[index - 1]!);
|
|
245
|
+
const to = center(ordered[index]!);
|
|
246
|
+
candidateDoors.push(...carvePath(grid, corridorPoints(from, to, random), config.style));
|
|
247
|
+
}
|
|
248
|
+
return filterValidDoors(grid, rooms, candidateDoors);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function uniqueDoors(doors: Door[]): Door[] {
|
|
252
|
+
const seen = new Set<string>();
|
|
253
|
+
return doors.filter((door) => {
|
|
254
|
+
const key = `${door.x},${door.y}`;
|
|
255
|
+
if (seen.has(key)) return false;
|
|
256
|
+
seen.add(key);
|
|
257
|
+
return true;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function countTiles(tiles: Tile[][], target: Tile): number {
|
|
262
|
+
return tiles.reduce((sum, row) => sum + row.filter((tile) => tile === target).length, 0);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function generateDungeon(input: Partial<DungeonConfig>): DungeonMap {
|
|
266
|
+
const config = normalizeConfig(input);
|
|
267
|
+
const random = createRandom(`${config.seed}:${config.style}:${config.columns}x${config.rows}`);
|
|
268
|
+
const tiles = createGrid(config);
|
|
269
|
+
const rooms = placeRooms(config, random);
|
|
270
|
+
rooms.forEach((room) => carveRoom(tiles, room, config, random));
|
|
271
|
+
const doors = connectRooms(tiles, rooms, config, random);
|
|
272
|
+
return { config, tiles, rooms, doors, doorCount: doors.length, floorCount: countTiles(tiles, 1) + countTiles(tiles, 2) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function encodeConfig(config: DungeonConfig): string {
|
|
276
|
+
return JSON.stringify(normalizeConfig(config));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function decodeConfig(value: string): DungeonConfig | undefined {
|
|
280
|
+
try { return normalizeConfig(JSON.parse(value) as Partial<DungeonConfig>); } catch { return undefined; }
|
|
281
|
+
}
|