@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-tabletop",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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-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>
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, map.config.style))).join('');
19
- const palette = standalone ? standaloneStyle(map.config.style) : '';
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-${map.config.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
+ 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 blob = new Blob([buildMapSvg(map, true)], { type: 'image/svg+xml;charset=utf-8' });
101
- downloadBlob(blob, filename(map, 'svg'));
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: 'Download PNG',
44
- exportSvg: 'Download SVG',
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 doors: Door[] = [];
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
- doors.push(...carvePath(grid, corridorPoints(from, to, random), config.style));
246
+ candidateDoors.push(...carvePath(grid, corridorPoints(from, to, random), config.style));
210
247
  }
211
- return uniqueDoors(doors);
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-seed-row {
95
+ .dmg-field {
96
96
  display: grid;
97
- grid-template-columns: minmax(0, 1fr) auto;
98
- gap: 0.55rem;
99
- align-items: end;
97
+ gap: 0.4rem;
100
98
  }
101
99
 
102
- .dmg-field {
103
- display: grid;
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 {
@@ -39,6 +39,7 @@ export interface DungeonMapGeneratorUI {
39
39
  linkCopied: string;
40
40
  exportPng: string;
41
41
  exportSvg: string;
42
+ exportPrint: string;
42
43
  exportJson: string;
43
44
  importJson: string;
44
45
  importError: string;