@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.
- package/CHANGELOG.md +22 -0
- package/README.md +13 -4
- package/css/components/expanding-cards.css +94 -25
- package/css/primitives/primitives.css +257 -0
- package/css/vanduo.css +7 -0
- package/dist/build-info.json +3 -3
- package/dist/vanduo-core.css +515 -25
- package/dist/vanduo-core.css.map +1 -1
- package/dist/vanduo-core.min.css +2 -2
- package/dist/vanduo-core.min.css.map +1 -1
- package/dist/vanduo.cjs.js +40 -17
- package/dist/vanduo.cjs.js.map +2 -2
- package/dist/vanduo.cjs.min.js +5 -5
- package/dist/vanduo.cjs.min.js.map +3 -3
- package/dist/vanduo.css +515 -25
- package/dist/vanduo.css.map +1 -1
- package/dist/vanduo.esm.js +40 -17
- package/dist/vanduo.esm.js.map +2 -2
- package/dist/vanduo.esm.min.js +5 -5
- package/dist/vanduo.esm.min.js.map +3 -3
- package/dist/vanduo.js +40 -17
- package/dist/vanduo.js.map +2 -2
- package/dist/vanduo.min.css +2 -2
- package/dist/vanduo.min.css.map +1 -1
- package/dist/vanduo.min.js +5 -5
- package/dist/vanduo.min.js.map +3 -3
- package/js/components/expanding-cards.js +15 -3
- package/js/components/spotlight.js +46 -16
- package/package.json +1 -1
- package/js/components/vd-hex.js +0 -838
- package/js/utils/hex-math.js +0 -233
package/js/components/vd-hex.js
DELETED
|
@@ -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
|
-
}
|