@jjlmoya/utils-tabletop 1.23.0 → 1.25.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/tool/dungeon-map-generator/component.astro +7 -6
- package/src/tool/dungeon-map-generator/controller.ts +1 -0
- package/src/tool/dungeon-map-generator/dom-views.ts +19 -14
- package/src/tool/dungeon-map-generator/i18n/en.ts +3 -2
- package/src/tool/dungeon-map-generator/i18n/es.ts +1 -0
- package/src/tool/dungeon-map-generator/logic.ts +42 -16
- package/src/tool/dungeon-map-generator/random-dungeon-map-generator.css +20 -6
- package/src/tool/dungeon-map-generator/ui.ts +1 -0
package/package.json
CHANGED
|
@@ -16,12 +16,12 @@ const initialEvaluation = evaluateMap(initialMap, ui);
|
|
|
16
16
|
<div class="dmg" data-dungeon-generator data-style="dungeon">
|
|
17
17
|
<script is:inline type="application/json" data-ui set:html={JSON.stringify(ui)} />
|
|
18
18
|
<section class="dmg-controls" aria-label={ui.styleLabel}>
|
|
19
|
-
<div class="dmg-seed
|
|
20
|
-
<label
|
|
21
|
-
|
|
22
|
-
<input name="seed" type="text" value={DEFAULT_CONFIG.seed} maxlength="48" spellcheck="false" />
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
<div class="dmg-field dmg-field-seed">
|
|
20
|
+
<label for="dmg-seed-input">{ui.seedLabel}</label>
|
|
21
|
+
<div class="dmg-seed-input-group">
|
|
22
|
+
<input id="dmg-seed-input" name="seed" type="text" value={DEFAULT_CONFIG.seed} maxlength="48" spellcheck="false" />
|
|
23
|
+
<button class="dmg-shuffle" type="button" data-random-seed>{ui.randomSeed}</button>
|
|
24
|
+
</div>
|
|
25
25
|
</div>
|
|
26
26
|
|
|
27
27
|
<fieldset class="dmg-styles">
|
|
@@ -113,6 +113,7 @@ const initialEvaluation = evaluateMap(initialMap, ui);
|
|
|
113
113
|
<div class="dmg-actions">
|
|
114
114
|
<button class="dmg-action-primary" type="button" data-export-png>{ui.exportPng}</button>
|
|
115
115
|
<button type="button" data-export-svg>{ui.exportSvg}</button>
|
|
116
|
+
<button type="button" data-export-print>{ui.exportPrint}</button>
|
|
116
117
|
<button type="button" data-copy-link>{ui.copyLink}</button>
|
|
117
118
|
<details class="dmg-map-data">
|
|
118
119
|
<summary>{ui.mapData}</summary>
|
|
@@ -102,6 +102,7 @@ function bindActions(state: GeneratorState): void {
|
|
|
102
102
|
state.root.querySelector('[data-copy-link]')?.addEventListener('click', () => void copyLink(state));
|
|
103
103
|
state.root.querySelector('[data-export-svg]')?.addEventListener('click', () => exportSvg(state.map));
|
|
104
104
|
state.root.querySelector('[data-export-png]')?.addEventListener('click', () => void exportPng(state.map));
|
|
105
|
+
state.root.querySelector('[data-export-print]')?.addEventListener('click', () => void exportPng(state.map, true));
|
|
105
106
|
state.root.querySelector('[data-export-json]')?.addEventListener('click', () => exportJson(state.map, state.edits));
|
|
106
107
|
state.root.querySelector<HTMLInputElement>('[data-import-json]')?.addEventListener('change', (event) => {
|
|
107
108
|
void importJson(state, event.currentTarget as HTMLInputElement);
|
|
@@ -11,15 +11,16 @@ interface RenderContext {
|
|
|
11
11
|
|
|
12
12
|
const CELL = 20;
|
|
13
13
|
|
|
14
|
-
export function buildMapSvg(map: DungeonMap, standalone = false): string {
|
|
14
|
+
export function buildMapSvg(map: DungeonMap, standalone = false, isPrint = false): string {
|
|
15
15
|
const width = map.config.columns * CELL;
|
|
16
16
|
const height = map.config.rows * CELL;
|
|
17
|
+
const style = isPrint ? 'print' : map.config.style;
|
|
17
18
|
const cells = map.tiles.flatMap((row, y) =>
|
|
18
|
-
row.map((tile, x) => tileSvg(tile, x, y,
|
|
19
|
-
const palette = standalone ? standaloneStyle(
|
|
19
|
+
row.map((tile, x) => tileSvg(tile, x, y, style))).join('');
|
|
20
|
+
const palette = standalone ? standaloneStyle(style) : '';
|
|
20
21
|
const doors = map.doors.map(doorSvg).join('');
|
|
21
22
|
const hitGrid = standalone ? '' : interactionGridSvg(map);
|
|
22
|
-
return `<svg class="dmg-map dmg-map-${
|
|
23
|
+
return `<svg class="dmg-map dmg-map-${style}" viewBox="0 0 ${width} ${height}" role="img" aria-label="Generated dungeon map" xmlns="http://www.w3.org/2000/svg">${palette}<rect class="dmg-map-void" width="${width}" height="${height}"/>${cells}${doors}${compassSvg(width, height)}${hitGrid}</svg>`;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
function tileSvg(tile: Tile, x: number, y: number, style: string): string {
|
|
@@ -52,6 +53,9 @@ function interactionGridSvg(map: DungeonMap): string {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
function standaloneStyle(style: string): string {
|
|
56
|
+
if (style === 'print') {
|
|
57
|
+
return `<style>.dmg-map-void{fill:#ffffff}.dmg-map-floor{fill:#ffffff;stroke:#333333;stroke-width:.6}.dmg-map-door line{stroke:#000000;stroke-width:4}.dmg-map-door circle{fill:#000000}.dmg-map-compass circle{fill:#ffffff;stroke:#000000;stroke-width:1}.dmg-map-compass path{fill:#000000}</style>`;
|
|
58
|
+
}
|
|
55
59
|
const floor = style === 'scifi' ? '#d9f6f0' : '#ead9b7';
|
|
56
60
|
const door = style === 'cavern' ? '#d7774f' : '#b98142';
|
|
57
61
|
return `<style>.dmg-map-void{fill:#171b22}.dmg-map-floor{fill:${floor};stroke:#6d6253;stroke-width:.7}.dmg-map-door line{stroke:${door};stroke-width:4}.dmg-map-door circle{fill:#fff}.dmg-map-compass circle{fill:#171b22;stroke:#c5a25c;stroke-width:1}.dmg-map-compass path{fill:#c5a25c}</style>`;
|
|
@@ -91,14 +95,15 @@ function downloadBlob(blob: Blob, filename: string): void {
|
|
|
91
95
|
URL.revokeObjectURL(url);
|
|
92
96
|
}
|
|
93
97
|
|
|
94
|
-
function filename(map: DungeonMap, extension: string): string {
|
|
98
|
+
function filename(map: DungeonMap, extension: string, suffix = ''): string {
|
|
95
99
|
const seed = map.config.seed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
96
|
-
return `dungeon-${seed || 'map'}.${extension}`;
|
|
100
|
+
return `dungeon-${seed || 'map'}${suffix}.${extension}`;
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
export function exportSvg(map: DungeonMap): void {
|
|
100
|
-
const
|
|
101
|
-
|
|
103
|
+
export function exportSvg(map: DungeonMap, isPrint = false): void {
|
|
104
|
+
const svg = buildMapSvg(map, true, isPrint);
|
|
105
|
+
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
|
|
106
|
+
downloadBlob(blob, filename(map, 'svg', isPrint ? '-print' : ''));
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
export function exportJson(map: DungeonMap, edits: DungeonEdit[]): void {
|
|
@@ -106,18 +111,18 @@ export function exportJson(map: DungeonMap, edits: DungeonEdit[]): void {
|
|
|
106
111
|
downloadBlob(new Blob([data], { type: 'application/json' }), filename(map, 'json'));
|
|
107
112
|
}
|
|
108
113
|
|
|
109
|
-
export async function exportPng(map: DungeonMap): Promise<void> {
|
|
110
|
-
const svg = buildMapSvg(map, true);
|
|
114
|
+
export async function exportPng(map: DungeonMap, isPrint = false): Promise<void> {
|
|
115
|
+
const svg = buildMapSvg(map, true, isPrint);
|
|
111
116
|
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
|
|
112
117
|
const source = URL.createObjectURL(blob);
|
|
113
118
|
try {
|
|
114
|
-
await drawPng(map, source);
|
|
119
|
+
await drawPng(map, source, isPrint ? '-print' : '');
|
|
115
120
|
} finally {
|
|
116
121
|
URL.revokeObjectURL(source);
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
async function drawPng(map: DungeonMap, source: string): Promise<void> {
|
|
125
|
+
async function drawPng(map: DungeonMap, source: string, suffix = ''): Promise<void> {
|
|
121
126
|
const image = await loadImage(source);
|
|
122
127
|
const canvas = document.createElement('canvas');
|
|
123
128
|
canvas.width = map.config.columns * CELL * 2;
|
|
@@ -126,7 +131,7 @@ async function drawPng(map: DungeonMap, source: string): Promise<void> {
|
|
|
126
131
|
if (!context) return;
|
|
127
132
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
128
133
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
|
|
129
|
-
if (blob) downloadBlob(blob, filename(map, 'png'));
|
|
134
|
+
if (blob) downloadBlob(blob, filename(map, 'png', suffix));
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
function loadImage(source: string): Promise<HTMLImageElement> {
|
|
@@ -40,8 +40,9 @@ const ui: DungeonMapGeneratorUI = {
|
|
|
40
40
|
mapData: 'Map data',
|
|
41
41
|
copyLink: 'Copy map link',
|
|
42
42
|
linkCopied: 'Link copied',
|
|
43
|
-
exportPng: '
|
|
44
|
-
exportSvg: '
|
|
43
|
+
exportPng: 'Export PNG',
|
|
44
|
+
exportSvg: 'Export SVG',
|
|
45
|
+
exportPrint: 'Printable',
|
|
45
46
|
exportJson: 'Save setup JSON',
|
|
46
47
|
importJson: 'Open setup JSON',
|
|
47
48
|
importError: 'This file does not contain a valid dungeon map setup.',
|
|
@@ -42,6 +42,7 @@ const ui: DungeonMapGeneratorUI = {
|
|
|
42
42
|
linkCopied: 'Enlace copiado',
|
|
43
43
|
exportPng: 'Descargar PNG',
|
|
44
44
|
exportSvg: 'Descargar SVG',
|
|
45
|
+
exportPrint: 'Imprimir',
|
|
45
46
|
exportJson: 'Guardar JSON',
|
|
46
47
|
importJson: 'Abrir JSON',
|
|
47
48
|
importError: 'Este archivo no contiene una configuración válida de mapa de mazmorra.',
|
|
@@ -200,15 +200,52 @@ function carvePoint(grid: Tile[][], point: Point, style: DungeonStyle, isDoor: b
|
|
|
200
200
|
if (style === 'scifi' && nextRow?.[point.x] !== undefined) nextRow[point.x] = 1;
|
|
201
201
|
}
|
|
202
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
|
+
|
|
203
240
|
function connectRooms(grid: Tile[][], rooms: Room[], config: DungeonConfig, random: RandomSource): Door[] {
|
|
204
241
|
const ordered = [...rooms].sort((a, b) => center(a).x - center(b).x);
|
|
205
|
-
const
|
|
242
|
+
const candidateDoors: Door[] = [];
|
|
206
243
|
for (let index = 1; index < ordered.length; index += 1) {
|
|
207
244
|
const from = center(ordered[index - 1]!);
|
|
208
245
|
const to = center(ordered[index]!);
|
|
209
|
-
|
|
246
|
+
candidateDoors.push(...carvePath(grid, corridorPoints(from, to, random), config.style));
|
|
210
247
|
}
|
|
211
|
-
return
|
|
248
|
+
return filterValidDoors(grid, rooms, candidateDoors);
|
|
212
249
|
}
|
|
213
250
|
|
|
214
251
|
function uniqueDoors(doors: Door[]): Door[] {
|
|
@@ -232,14 +269,7 @@ export function generateDungeon(input: Partial<DungeonConfig>): DungeonMap {
|
|
|
232
269
|
const rooms = placeRooms(config, random);
|
|
233
270
|
rooms.forEach((room) => carveRoom(tiles, room, config, random));
|
|
234
271
|
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
|
-
};
|
|
272
|
+
return { config, tiles, rooms, doors, doorCount: doors.length, floorCount: countTiles(tiles, 1) + countTiles(tiles, 2) };
|
|
243
273
|
}
|
|
244
274
|
|
|
245
275
|
export function encodeConfig(config: DungeonConfig): string {
|
|
@@ -247,9 +277,5 @@ export function encodeConfig(config: DungeonConfig): string {
|
|
|
247
277
|
}
|
|
248
278
|
|
|
249
279
|
export function decodeConfig(value: string): DungeonConfig | undefined {
|
|
250
|
-
try {
|
|
251
|
-
return normalizeConfig(JSON.parse(value) as Partial<DungeonConfig>);
|
|
252
|
-
} catch {
|
|
253
|
-
return undefined;
|
|
254
|
-
}
|
|
280
|
+
try { return normalizeConfig(JSON.parse(value) as Partial<DungeonConfig>); } catch { return undefined; }
|
|
255
281
|
}
|
|
@@ -92,18 +92,18 @@
|
|
|
92
92
|
border-right: 1px solid var(--n-dmg-line);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
.dmg-
|
|
95
|
+
.dmg-field {
|
|
96
96
|
display: grid;
|
|
97
|
-
|
|
98
|
-
gap: 0.55rem;
|
|
99
|
-
align-items: end;
|
|
97
|
+
gap: 0.4rem;
|
|
100
98
|
}
|
|
101
99
|
|
|
102
|
-
.dmg-field {
|
|
103
|
-
display:
|
|
100
|
+
.dmg-field-seed {
|
|
101
|
+
display: flex;
|
|
102
|
+
flex-direction: column;
|
|
104
103
|
gap: 0.4rem;
|
|
105
104
|
}
|
|
106
105
|
|
|
106
|
+
.dmg-field-seed label,
|
|
107
107
|
.dmg-field > span,
|
|
108
108
|
.dmg-styles legend,
|
|
109
109
|
.dmg-section-label,
|
|
@@ -115,6 +115,13 @@
|
|
|
115
115
|
text-transform: uppercase;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
.dmg-seed-input-group {
|
|
119
|
+
display: flex;
|
|
120
|
+
gap: 0.5rem;
|
|
121
|
+
align-items: center;
|
|
122
|
+
width: 100%;
|
|
123
|
+
}
|
|
124
|
+
|
|
118
125
|
.dmg-field input {
|
|
119
126
|
width: 100%;
|
|
120
127
|
min-height: 2.65rem;
|
|
@@ -125,7 +132,13 @@
|
|
|
125
132
|
border-radius: 0.65rem;
|
|
126
133
|
}
|
|
127
134
|
|
|
135
|
+
.dmg-seed-input-group input {
|
|
136
|
+
flex: 1 1 auto;
|
|
137
|
+
min-width: 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
128
140
|
.dmg-shuffle {
|
|
141
|
+
flex: 0 0 auto;
|
|
129
142
|
min-height: 2.65rem;
|
|
130
143
|
padding: 0.55rem 0.72rem;
|
|
131
144
|
color: var(--n-dmg-accent);
|
|
@@ -134,6 +147,7 @@
|
|
|
134
147
|
border-radius: 0.65rem;
|
|
135
148
|
font-size: 0.78rem;
|
|
136
149
|
font-weight: 800;
|
|
150
|
+
white-space: nowrap;
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
.dmg-styles {
|