@vanduo-oss/framework 1.4.6 → 1.5.1

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.
@@ -1,838 +0,0 @@
1
- // VdHexGrid — Canonical source: framework/js/components/vd-hex.js
2
- // Based on web-civ HexGrid implementation
3
- // Enables developers to use hex grids as components and game devs creating web civ-like games
4
-
5
- import {
6
- hexToPixel,
7
- pixelToHex,
8
- getHexCorners,
9
- getAdjacentHexes,
10
- hexDistance,
11
- TerrainType,
12
- isPassable,
13
- getMovementCost,
14
- getTerrainYields,
15
- getTerrainColor
16
- } from '../utils/hex-math.js';
17
-
18
- // Constants
19
- const ZOOM_MIN = 0.3;
20
- const ZOOM_MAX = 3.0;
21
- const ZOOM_FACTOR = 0.1;
22
- const DRAG_THRESHOLD = 2;
23
-
24
- /**
25
- * VdHexGrid - A dynamic controllable hex grid component
26
- *
27
- * @example
28
- * const grid = new VdHexGrid({
29
- * element: document.getElementById('container'),
30
- * canvas: document.getElementById('canvas'),
31
- * size: 30,
32
- * width: 15,
33
- * height: 10,
34
- * rotation: 0 // Optional rotation in radians
35
- * });
36
- *
37
- * grid.on('select', (hex) => {
38
- * console.log('Selected:', hex.q, hex.r);
39
- * });
40
- */
41
- export class VdHexGrid {
42
- constructor({ element, canvas, size = 30, width = 10, height = 10, rotation = 0 }) {
43
- this.element = element;
44
- this.canvas = canvas;
45
- this.size = size;
46
- this.width = width;
47
- this.height = height;
48
- this.rotation = rotation;
49
- this.hexes = new Map();
50
- this.selectedHex = null;
51
- this.listeners = {};
52
-
53
- // Transform state for pan/zoom
54
- this.transform = { x: 0, y: 0, scale: 1 };
55
-
56
- // Drag state
57
- this.dragging = false;
58
- this.lastPos = null;
59
- this.hasMoved = false;
60
-
61
- // Theme colors
62
- this.themeColors = this._getThemeColors();
63
-
64
- // Custom render callback
65
- this.customRenderCallback = null;
66
-
67
- // Set up canvas if not already done
68
- if (!this.canvas) {
69
- this.canvas = element.querySelector('canvas') || document.createElement('canvas');
70
- if (!element.contains(this.canvas)) {
71
- element.appendChild(this.canvas);
72
- }
73
- }
74
-
75
- this.ctx = this.canvas.getContext('2d');
76
-
77
- // Generate the grid
78
- this._generateGrid();
79
- this._render();
80
- this._setupEvents();
81
-
82
- // Observe theme changes
83
- this._observeThemeChanges();
84
- }
85
-
86
- /**
87
- * Get theme colors from CSS custom properties
88
- */
89
- _getThemeColors() {
90
- const root = document.documentElement;
91
- const style = getComputedStyle(root);
92
-
93
- const readToken = (token, defaultValue) => {
94
- return style.getPropertyValue(token).trim() || defaultValue;
95
- };
96
-
97
- return {
98
- bgPrimary: readToken('--vd-bg-primary', '#ffffff'),
99
- bgSecondary: readToken('--vd-bg-secondary', '#f5f5f5'),
100
- borderColor: readToken('--vd-border-color', '#e0e0e0'),
101
- colorPrimary: readToken('--vd-color-primary', '#3b82f6'),
102
- textColor: readToken('--vd-text-primary', '#1f2937'),
103
- textMuted: readToken('--vd-text-muted', '#6b7280')
104
- };
105
- }
106
-
107
- /**
108
- * Observe theme changes and re-render when theme changes
109
- */
110
- _observeThemeChanges() {
111
- this._themeObserver = new MutationObserver(() => {
112
- this.themeColors = this._getThemeColors();
113
- this._render();
114
- });
115
-
116
- this._themeObserver.observe(document.documentElement, {
117
- attributes: true,
118
- attributeFilter: ['data-theme']
119
- });
120
- }
121
-
122
- /**
123
- * Disconnect the theme observer and remove all canvas event listeners.
124
- * Call this before discarding the instance (e.g. on SPA page unload).
125
- */
126
- destroy() {
127
- if (this._themeObserver) {
128
- this._themeObserver.disconnect();
129
- this._themeObserver = null;
130
- }
131
- }
132
-
133
- /**
134
- * Convert screen coordinates to world coordinates
135
- */
136
- _screenToWorld(screenX, screenY) {
137
- const rect = this.canvas.getBoundingClientRect();
138
- const canvasX = screenX - rect.left;
139
- const canvasY = screenY - rect.top;
140
-
141
- return {
142
- x: (canvasX - this.transform.x) / this.transform.scale,
143
- y: (canvasY - this.transform.y) / this.transform.scale
144
- };
145
- }
146
-
147
- /**
148
- * Convert client coordinates to canvas-local coordinates
149
- */
150
- _clientToCanvas(clientX, clientY) {
151
- const rect = this.canvas.getBoundingClientRect();
152
- return {
153
- x: clientX - rect.left,
154
- y: clientY - rect.top
155
- };
156
- }
157
-
158
- /**
159
- * Generate hex grid data
160
- */
161
- _generateGrid() {
162
- this.hexes.clear();
163
-
164
- for (let r = 0; r < this.height; r++) {
165
- const qOffset = Math.floor(r / 2);
166
- for (let q = -qOffset; q < this.width - qOffset; q++) {
167
- const pixel = hexToPixel(q, r, this.size, this.rotation);
168
-
169
- const hex = {
170
- q,
171
- r,
172
- x: pixel.x,
173
- y: pixel.y,
174
- fill: this.themeColors.bgSecondary,
175
- stroke: this.themeColors.borderColor,
176
- adjacent: getAdjacentHexes(q, r),
177
- terrain: null,
178
- data: {}
179
- };
180
- this.hexes.set(`${q},${r}`, hex);
181
- }
182
- }
183
- }
184
-
185
- /**
186
- * Keep selected hex reference in sync after grid regeneration
187
- */
188
- _resyncSelectedHex() {
189
- if (!this.selectedHex) return;
190
- this.selectedHex = this.hexes.get(`${this.selectedHex.q},${this.selectedHex.r}`) ?? null;
191
- }
192
-
193
- /**
194
- * Render the hex grid on canvas
195
- */
196
- _render() {
197
- // Get canvas displayed size
198
- const rect = this.canvas.getBoundingClientRect();
199
- const displayWidth = rect.width || 800;
200
- const displayHeight = rect.height || 400;
201
-
202
- // Set canvas internal resolution to match display
203
- this.canvas.width = displayWidth;
204
- this.canvas.height = displayHeight;
205
-
206
- // Clear canvas with theme background
207
- this.ctx.fillStyle = this.themeColors.bgPrimary;
208
- this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
209
-
210
- // Apply transform
211
- this.ctx.save();
212
- this.ctx.translate(this.transform.x, this.transform.y);
213
- this.ctx.scale(this.transform.scale, this.transform.scale);
214
-
215
- // Draw all hexes
216
- this.hexes.forEach(hex => {
217
- this._drawHex(hex);
218
-
219
- // Call custom render callback if set
220
- if (this.customRenderCallback) {
221
- this.customRenderCallback(this.ctx, hex, this.size);
222
- }
223
- });
224
-
225
- // Redraw selected hex if any
226
- if (this.selectedHex) {
227
- this._drawHex(this.selectedHex, true);
228
- }
229
-
230
- this.ctx.restore();
231
- }
232
-
233
- /**
234
- * Draw a single hex
235
- */
236
- _drawHex(hex, isSelected = false) {
237
- const corners = getHexCorners(hex.x, hex.y, this.size, this.rotation);
238
-
239
- this.ctx.beginPath();
240
- this.ctx.moveTo(corners[0].x, corners[0].y);
241
- for (let i = 1; i < corners.length; i++) {
242
- this.ctx.lineTo(corners[i].x, corners[i].y);
243
- }
244
- this.ctx.closePath();
245
-
246
- // Determine fill color: terrain > custom fill > theme
247
- let fill;
248
- if (isSelected) {
249
- fill = this.themeColors.colorPrimary;
250
- } else if (hex.terrain) {
251
- fill = getTerrainColor(hex.terrain);
252
- } else if (hex.fill) {
253
- fill = hex.fill;
254
- } else {
255
- fill = this.themeColors.bgSecondary;
256
- }
257
- this.ctx.fillStyle = fill;
258
- this.ctx.fill();
259
-
260
- // Stroke with theme color
261
- const stroke = isSelected ? this.themeColors.colorPrimary : (hex.stroke || this.themeColors.borderColor);
262
- this.ctx.strokeStyle = stroke;
263
- this.ctx.lineWidth = isSelected ? 3 : 1;
264
- this.ctx.stroke();
265
-
266
- // Draw coordinates for selected hex
267
- if (isSelected) {
268
- this.ctx.fillStyle = '#ffffff';
269
- this.ctx.font = '10px monospace';
270
- this.ctx.textAlign = 'center';
271
- this.ctx.textBaseline = 'middle';
272
- this.ctx.fillText(`${hex.q},${hex.r}`, hex.x, hex.y);
273
- }
274
- }
275
-
276
- /**
277
- * Set up mouse/touch events for hex selection, pan, and zoom
278
- */
279
- _setupEvents() {
280
- // Touch state for pinch-to-zoom
281
- this.touchState = {
282
- initialDistance: 0,
283
- initialScale: 1,
284
- touches: []
285
- };
286
-
287
- // Pan - pointer down
288
- this.canvas.addEventListener('pointerdown', (e) => {
289
- this.dragging = true;
290
- this.hasMoved = false;
291
- this.lastPos = { x: e.clientX, y: e.clientY };
292
- this.canvas.style.cursor = 'grabbing';
293
- });
294
-
295
- // Pan - pointer move
296
- this.canvas.addEventListener('pointermove', (e) => {
297
- if (!this.dragging) return;
298
-
299
- const cur = { x: e.clientX, y: e.clientY };
300
- const dx = cur.x - this.lastPos.x;
301
- const dy = cur.y - this.lastPos.y;
302
-
303
- if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
304
- this.hasMoved = true;
305
- }
306
-
307
- this.transform.x += dx;
308
- this.transform.y += dy;
309
- this.lastPos = cur;
310
- this._render();
311
- });
312
-
313
- // Pan - pointer up
314
- const stopDrag = () => {
315
- this.dragging = false;
316
- if (!this.hasMoved) {
317
- this.canvas.style.cursor = 'pointer';
318
- }
319
- };
320
- this.canvas.addEventListener('pointerup', stopDrag);
321
- this.canvas.addEventListener('pointerleave', stopDrag);
322
-
323
- // Click (tap without drag)
324
- this.canvas.addEventListener('click', (e) => {
325
- if (this.hasMoved) return;
326
-
327
- const worldPos = this._screenToWorld(e.clientX, e.clientY);
328
- const hexCoords = pixelToHex(worldPos.x, worldPos.y, this.size, this.rotation);
329
- const hex = this.hexes.get(`${hexCoords.q},${hexCoords.r}`);
330
-
331
- if (hex) {
332
- this.selectedHex = hex;
333
- this._render();
334
- this._emit('select', hex);
335
- }
336
- });
337
-
338
- // Zoom - mouse wheel
339
- this.canvas.addEventListener('wheel', (e) => {
340
- e.preventDefault();
341
-
342
- const zoomFactor = e.deltaY > 0 ? 1 - ZOOM_FACTOR : 1 + ZOOM_FACTOR;
343
- const newScale = Math.max(ZOOM_MIN, Math.min(this.transform.scale * zoomFactor, ZOOM_MAX));
344
-
345
- // Zoom toward cursor
346
- const mouse = this._clientToCanvas(e.clientX, e.clientY);
347
-
348
- const scaleDiff = newScale / this.transform.scale;
349
- this.transform.x = mouse.x - (mouse.x - this.transform.x) * scaleDiff;
350
- this.transform.y = mouse.y - (mouse.y - this.transform.y) * scaleDiff;
351
- this.transform.scale = newScale;
352
-
353
- this._render();
354
- this._emit('zoom', { scale: this.transform.scale });
355
- }, { passive: false });
356
-
357
- // Touch events for pinch-to-zoom
358
- this.canvas.addEventListener('touchstart', (e) => {
359
- if (e.touches.length === 2) {
360
- e.preventDefault();
361
- this.touchState.touches = Array.from(e.touches);
362
- this.touchState.initialDistance = this._getTouchDistance(e.touches);
363
- this.touchState.initialScale = this.transform.scale;
364
- }
365
- }, { passive: false });
366
-
367
- this.canvas.addEventListener('touchmove', (e) => {
368
- if (e.touches.length === 2) {
369
- e.preventDefault();
370
- const currentDistance = this._getTouchDistance(e.touches);
371
- const scale = (currentDistance / this.touchState.initialDistance) * this.touchState.initialScale;
372
- const newScale = Math.max(ZOOM_MIN, Math.min(scale, ZOOM_MAX));
373
-
374
- // Zoom toward center of pinch
375
- const centerClientX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
376
- const centerClientY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
377
- const center = this._clientToCanvas(centerClientX, centerClientY);
378
-
379
- const scaleDiff = newScale / this.transform.scale;
380
- this.transform.x = center.x - (center.x - this.transform.x) * scaleDiff;
381
- this.transform.y = center.y - (center.y - this.transform.y) * scaleDiff;
382
- this.transform.scale = newScale;
383
-
384
- this._render();
385
- this._emit('zoom', { scale: this.transform.scale });
386
- }
387
- }, { passive: false });
388
-
389
- this.canvas.addEventListener('touchend', () => {
390
- this.touchState.touches = [];
391
- });
392
-
393
- // Cursor style
394
- this.canvas.addEventListener('mouseenter', () => {
395
- this.canvas.style.cursor = 'grab';
396
- });
397
- this.canvas.addEventListener('mouseleave', () => {
398
- this.canvas.style.cursor = 'default';
399
- });
400
- }
401
-
402
- /**
403
- * Calculate distance between two touch points
404
- * @param {TouchList} touches - Touch list
405
- * @returns {number} Distance in pixels
406
- */
407
- _getTouchDistance(touches) {
408
- if (touches.length < 2) return 0;
409
- const dx = touches[0].clientX - touches[1].clientX;
410
- const dy = touches[0].clientY - touches[1].clientY;
411
- return Math.sqrt(dx * dx + dy * dy);
412
- }
413
-
414
- /**
415
- * Set hex size
416
- */
417
- setSize(size) {
418
- this.size = size;
419
- this._generateGrid();
420
- this._resyncSelectedHex();
421
- this._render();
422
- }
423
-
424
- /**
425
- * Set grid dimensions
426
- */
427
- setDimensions(width, height) {
428
- this.width = width;
429
- this.height = height;
430
- this._generateGrid();
431
- this._resyncSelectedHex();
432
- this._render();
433
- }
434
-
435
- /**
436
- * Reset grid to defaults
437
- */
438
- reset() {
439
- this.size = 30;
440
- this.width = 15;
441
- this.height = 10;
442
- this.rotation = 0;
443
- this.selectedHex = null;
444
- this.transform = { x: 0, y: 0, scale: 1 };
445
- this._generateGrid();
446
- this._render();
447
- }
448
-
449
- /**
450
- * Fill hexes with random colors
451
- */
452
- fillRandom() {
453
- const colors = ['#f0f0f0', '#d4e5d4', '#e5d4d4', '#d4d4e5', '#e5e5d4', '#d4e5e5', '#e8e8e8', '#d0d0d0'];
454
- this.hexes.forEach(hex => {
455
- hex.fill = colors[Math.floor(Math.random() * colors.length)];
456
- });
457
- this._render();
458
- }
459
-
460
- /**
461
- * Get hex by coordinates
462
- */
463
- getHex(q, r) {
464
- return this.hexes.get(`${q},${r}`);
465
- }
466
-
467
- /**
468
- * Get all hexes
469
- */
470
- getAllHexes() {
471
- return Array.from(this.hexes.values());
472
- }
473
-
474
- /**
475
- * Set hex fill color
476
- */
477
- setHexFill(q, r, color) {
478
- const hex = this.hexes.get(`${q},${r}`);
479
- if (hex) {
480
- hex.fill = color;
481
- this._render();
482
- }
483
- }
484
-
485
- /**
486
- * Reset view to default position
487
- */
488
- resetView() {
489
- this.transform = { x: 0, y: 0, scale: 1 };
490
- this._render();
491
- this._emit('pan', { x: 0, y: 0 });
492
- this._emit('zoom', { scale: 1 });
493
- }
494
-
495
- /**
496
- * Zoom in
497
- */
498
- zoomIn() {
499
- const newScale = Math.min(this.transform.scale * (1 + ZOOM_FACTOR), ZOOM_MAX);
500
- this.transform.scale = newScale;
501
- this._render();
502
- this._emit('zoom', { scale: this.transform.scale });
503
- }
504
-
505
- /**
506
- * Zoom out
507
- */
508
- zoomOut() {
509
- const newScale = Math.max(this.transform.scale * (1 - ZOOM_FACTOR), ZOOM_MIN);
510
- this.transform.scale = newScale;
511
- this._render();
512
- this._emit('zoom', { scale: this.transform.scale });
513
- }
514
-
515
- /**
516
- * Get current transform state
517
- */
518
- getTransform() {
519
- return { ...this.transform };
520
- }
521
-
522
- /**
523
- * Subscribe to events
524
- */
525
- on(event, callback) {
526
- if (!this.listeners[event]) {
527
- this.listeners[event] = [];
528
- }
529
- this.listeners[event].push(callback);
530
- }
531
-
532
- /**
533
- * Emit events
534
- */
535
- _emit(event, data) {
536
- if (this.listeners[event]) {
537
- this.listeners[event].forEach(callback => callback(data));
538
- }
539
- }
540
-
541
- // ═══════════════════════════════════════════════════════
542
- // Terrain System
543
- // ═══════════════════════════════════════════════════════
544
-
545
- /**
546
- * Set terrain type for a hex
547
- * @param {number} q - Hex column
548
- * @param {number} r - Hex row
549
- * @param {string} terrainType - Terrain type (e.g., 'GRASSLAND', 'OCEAN')
550
- */
551
- setHexTerrain(q, r, terrainType) {
552
- const hex = this.hexes.get(`${q},${r}`);
553
- if (hex) {
554
- hex.terrain = terrainType;
555
- this._render();
556
- }
557
- }
558
-
559
- /**
560
- * Get terrain type for a hex
561
- * @param {number} q - Hex column
562
- * @param {number} r - Hex row
563
- * @returns {string|null} Terrain type or null
564
- */
565
- getHexTerrain(q, r) {
566
- const hex = this.hexes.get(`${q},${r}`);
567
- return hex ? hex.terrain : null;
568
- }
569
-
570
- /**
571
- * Generate random terrain for all hexes
572
- */
573
- generateRandomTerrain() {
574
- const terrainTypes = Object.values(TerrainType);
575
- this.hexes.forEach(hex => {
576
- hex.terrain = terrainTypes[Math.floor(Math.random() * terrainTypes.length)];
577
- });
578
- this._render();
579
- }
580
-
581
- /**
582
- * Get terrain yields for a hex
583
- * @param {number} q - Hex column
584
- * @param {number} r - Hex row
585
- * @returns {Object} Yields object {food, production, gold}
586
- */
587
- getHexYields(q, r) {
588
- const terrain = this.getHexTerrain(q, r);
589
- return terrain ? getTerrainYields(terrain) : { food: 0, production: 0, gold: 0 };
590
- }
591
-
592
- /**
593
- * Get movement cost for a hex
594
- * @param {number} q - Hex column
595
- * @param {number} r - Hex row
596
- * @returns {number} Movement cost
597
- */
598
- getHexMovementCost(q, r) {
599
- const terrain = this.getHexTerrain(q, r);
600
- return terrain ? getMovementCost(terrain) : 999;
601
- }
602
-
603
- /**
604
- * Check if hex is passable
605
- * @param {number} q - Hex column
606
- * @param {number} r - Hex row
607
- * @returns {boolean} True if passable
608
- */
609
- isHexPassable(q, r) {
610
- const terrain = this.getHexTerrain(q, r);
611
- return terrain ? isPassable(terrain) : false;
612
- }
613
-
614
- // ═══════════════════════════════════════════════════════
615
- // Hex Data Attachment
616
- // ═══════════════════════════════════════════════════════
617
-
618
- /**
619
- * Set custom data for a hex
620
- * @param {number} q - Hex column
621
- * @param {number} r - Hex row
622
- * @param {Object} data - Custom data object
623
- */
624
- setHexData(q, r, data) {
625
- const hex = this.hexes.get(`${q},${r}`);
626
- if (hex) {
627
- hex.data = { ...hex.data, ...data };
628
- }
629
- }
630
-
631
- /**
632
- * Get custom data for a hex
633
- * @param {number} q - Hex column
634
- * @param {number} r - Hex row
635
- * @returns {Object} Custom data object
636
- */
637
- getHexData(q, r) {
638
- const hex = this.hexes.get(`${q},${r}`);
639
- return hex ? hex.data : {};
640
- }
641
-
642
- /**
643
- * Clear custom data for a hex
644
- * @param {number} q - Hex column
645
- * @param {number} r - Hex row
646
- */
647
- clearHexData(q, r) {
648
- const hex = this.hexes.get(`${q},${r}`);
649
- if (hex) {
650
- hex.data = {};
651
- }
652
- }
653
-
654
- // ═══════════════════════════════════════════════════════
655
- // Distance & Pathfinding
656
- // ═══════════════════════════════════════════════════════
657
-
658
- /**
659
- * Calculate distance between two hexes
660
- * @param {number} q1 - First hex q coordinate
661
- * @param {number} r1 - First hex r coordinate
662
- * @param {number} q2 - Second hex q coordinate
663
- * @param {number} r2 - Second hex r coordinate
664
- * @returns {number} Distance in hex steps
665
- */
666
- hexDistance(q1, r1, q2, r2) {
667
- return hexDistance(q1, r1, q2, r2);
668
- }
669
-
670
- /**
671
- * Get valid moves from a hex within movement points
672
- * @param {number} q - Starting hex column
673
- * @param {number} r - Starting hex row
674
- * @param {number} movementPoints - Available movement points
675
- * @returns {Array<{q: number, r: number}>} Array of valid hex coordinates
676
- */
677
- getValidMoves(q, r, movementPoints) {
678
- const validHexes = [];
679
- const adjacent = getAdjacentHexes(q, r);
680
-
681
- for (const hex of adjacent) {
682
- if (!this.hexes.has(`${hex.q},${hex.r}`)) continue;
683
-
684
- const cost = this.getHexMovementCost(hex.q, hex.r);
685
- if (cost < 999 && movementPoints >= cost) {
686
- validHexes.push(hex);
687
- }
688
- }
689
-
690
- return validHexes;
691
- }
692
-
693
- /**
694
- * Get path between two hexes (simple BFS)
695
- * @param {number} startQ - Starting hex column
696
- * @param {number} startR - Starting hex row
697
- * @param {number} endQ - Ending hex column
698
- * @param {number} endR - Ending hex row
699
- * @returns {Array<{q: number, r: number}>} Array of hex coordinates forming path
700
- */
701
- getPath(startQ, startR, endQ, endR) {
702
- const startKey = `${startQ},${startR}`;
703
- const endKey = `${endQ},${endR}`;
704
-
705
- if (!this.hexes.has(startKey) || !this.hexes.has(endKey)) {
706
- return [];
707
- }
708
-
709
- const queue = [[startQ, startR]];
710
- const visited = new Set([startKey]);
711
- const parent = new Map();
712
-
713
- while (queue.length > 0) {
714
- const [currentQ, currentR] = queue.shift();
715
- const currentKey = `${currentQ},${currentR}`;
716
-
717
- if (currentKey === endKey) {
718
- // Reconstruct path
719
- const path = [];
720
- let key = endKey;
721
- while (key) {
722
- const [q, r] = key.split(',').map(Number);
723
- path.unshift({ q, r });
724
- key = parent.get(key);
725
- }
726
- return path;
727
- }
728
-
729
- const adjacent = getAdjacentHexes(currentQ, currentR);
730
- for (const neighbor of adjacent) {
731
- const neighborKey = `${neighbor.q},${neighbor.r}`;
732
- if (this.hexes.has(neighborKey) && !visited.has(neighborKey)) {
733
- if (this.isHexPassable(neighbor.q, neighbor.r)) {
734
- visited.add(neighborKey);
735
- parent.set(neighborKey, currentKey);
736
- queue.push([neighbor.q, neighbor.r]);
737
- }
738
- }
739
- }
740
- }
741
-
742
- return []; // No path found
743
- }
744
-
745
- // ═══════════════════════════════════════════════════════
746
- // Grid Rotation
747
- // ═══════════════════════════════════════════════════════
748
-
749
- /**
750
- * Set grid rotation
751
- * @param {number} rotation - Rotation in radians
752
- */
753
- setRotation(rotation) {
754
- this.rotation = rotation;
755
- this._generateGrid();
756
- this._resyncSelectedHex();
757
- this._render();
758
- }
759
-
760
- /**
761
- * Get current grid rotation
762
- * @returns {number} Rotation in radians
763
- */
764
- getRotation() {
765
- return this.rotation;
766
- }
767
-
768
- // ═══════════════════════════════════════════════════════
769
- // Custom Rendering
770
- // ═══════════════════════════════════════════════════════
771
-
772
- /**
773
- * Set custom render callback for each hex
774
- * @param {function} callback - Called with (ctx, hex, size) for each hex
775
- */
776
- setCustomRender(callback) {
777
- this.customRenderCallback = callback;
778
- this._render();
779
- }
780
-
781
- /**
782
- * Clear custom render callback
783
- */
784
- clearCustomRender() {
785
- this.customRenderCallback = null;
786
- this._render();
787
- }
788
-
789
- // ═══════════════════════════════════════════════════════
790
- // Utility Methods
791
- // ═══════════════════════════════════════════════════════
792
-
793
- /**
794
- * Check if hex exists at coordinates
795
- * @param {number} q - Hex column
796
- * @param {number} r - Hex row
797
- * @returns {boolean}
798
- */
799
- hasHex(q, r) {
800
- return this.hexes.has(`${q},${r}`);
801
- }
802
-
803
- /**
804
- * Get hex count
805
- * @returns {number} Number of hexes in grid
806
- */
807
- getHexCount() {
808
- return this.hexes.size;
809
- }
810
-
811
- /**
812
- * Export terrain data as JSON
813
- * @returns {Object} Terrain data object
814
- */
815
- exportTerrainData() {
816
- const data = {};
817
- this.hexes.forEach((hex, key) => {
818
- if (hex.terrain) {
819
- data[key] = hex.terrain;
820
- }
821
- });
822
- return data;
823
- }
824
-
825
- /**
826
- * Import terrain data from JSON
827
- * @param {Object} data - Terrain data object
828
- */
829
- importTerrainData(data) {
830
- // Mutate hex data directly to avoid triggering _render() on every hex.
831
- // A single render is issued after all terrain values have been applied.
832
- Object.entries(data).forEach(([key, terrain]) => {
833
- const hex = this.hexes.get(key);
834
- if (hex) hex.terrain = terrain;
835
- });
836
- this._render();
837
- }
838
- }