@buley/hexgrid-3d 3.6.0 → 3.6.2

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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GameSphere.d.ts","sourceRoot":"","sources":["../../src/components/GameSphere.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAkD,MAAM,OAAO,CAAC;AAEvE,OAAO,KAAK,EACV,eAAe,EAKhB,MAAM,UAAU,CAAC;AA+OlB,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CAyjBhD,CAAC;AAGF,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC"}
@@ -0,0 +1,632 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * GameSphere — Full Three.js 3D board game renderer on a geodesic sphere.
4
+ *
5
+ * Renders hex/pentagon cells as colored polygons on a sphere mesh, places
6
+ * literal 3D game pieces (primitives, custom Object3D, GLTF models) on cells,
7
+ * handles fog of war, highlights, raycasting for click/hover, orbit camera,
8
+ * and piece animations. This is THE platform for any board game on a sphere.
9
+ */
10
+ import { useRef, useEffect, useCallback, useMemo } from 'react';
11
+ import * as THREE from 'three';
12
+ import { GeodesicHexGrid } from '../math/HexCoordinates';
13
+ import { Vector3 } from '../math/Vector3';
14
+ import { buildPieceMesh, placePieceOnSphere, animatePiece, buildCellMesh, buildCellBorder, buildHighlightRing, applyCellState, disposePieceGroup, } from './GamePieceRenderer';
15
+ // ---------------------------------------------------------------------------
16
+ // Defaults
17
+ // ---------------------------------------------------------------------------
18
+ const DEFAULT_CONFIG = {
19
+ subdivisions: 3,
20
+ sphereRadius: 5,
21
+ cameraDistance: 12,
22
+ cameraFov: 50,
23
+ enableOrbitControls: true,
24
+ autoRotate: false,
25
+ autoRotateSpeed: 0.5,
26
+ ambientLightIntensity: 0.4,
27
+ directionalLightIntensity: 0.8,
28
+ directionalLightPosition: [5, 10, 7],
29
+ hexBaseColor: '#1a1a2e',
30
+ hexBorderColor: '#333355',
31
+ hexBorderWidth: 0.02,
32
+ pentagonBaseColor: '#2a1a3e',
33
+ pentagonBorderColor: '#553377',
34
+ fogDimColor: 'rgba(0,0,0,0.5)',
35
+ fogHiddenColor: 'rgba(0,0,0,0.85)',
36
+ fogExploredColor: 'rgba(0,0,0,0.3)',
37
+ defaultPieceScale: 0.3,
38
+ defaultPieceColor: '#ffffff',
39
+ enableRaycasting: true,
40
+ enableDragDrop: false,
41
+ hoverHighlightColor: '#ffffff33',
42
+ enableBloom: false,
43
+ enableShadows: false,
44
+ enableAntialias: true,
45
+ enableInstancing: true,
46
+ maxVisiblePieces: 500,
47
+ pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio : 1,
48
+ };
49
+ // ---------------------------------------------------------------------------
50
+ // Highlight color map
51
+ // ---------------------------------------------------------------------------
52
+ const HIGHLIGHT_COLORS = {
53
+ selected: '#00ffff',
54
+ hover: '#ffffff',
55
+ 'attack-target': '#ff4444',
56
+ 'move-target': '#44ff44',
57
+ 'great-circle': '#ffaa00',
58
+ path: '#8844ff',
59
+ danger: '#ff0000',
60
+ friendly: '#00ff88',
61
+ contested: '#ff8800',
62
+ };
63
+ function getHighlightColor(highlight) {
64
+ if (highlight === 'none')
65
+ return '#000000';
66
+ return HIGHLIGHT_COLORS[highlight] ?? highlight; // If it's a custom color string, use directly
67
+ }
68
+ function normalizeReactNode(node) {
69
+ if (typeof node === 'bigint') {
70
+ return node.toString();
71
+ }
72
+ if (Array.isArray(node)) {
73
+ return node.map(normalizeReactNode);
74
+ }
75
+ return node;
76
+ }
77
+ function createOrbitControls(camera, canvas, initialDistance) {
78
+ const state = {
79
+ theta: 0,
80
+ phi: Math.PI / 3,
81
+ distance: initialDistance,
82
+ target: new THREE.Vector3(0, 0, 0),
83
+ isDragging: false,
84
+ lastX: 0,
85
+ lastY: 0,
86
+ isPinching: false,
87
+ lastPinchDist: 0,
88
+ };
89
+ const onMouseDown = (e) => {
90
+ state.isDragging = true;
91
+ state.lastX = e.clientX;
92
+ state.lastY = e.clientY;
93
+ };
94
+ const onMouseMove = (e) => {
95
+ if (!state.isDragging)
96
+ return;
97
+ const dx = e.clientX - state.lastX;
98
+ const dy = e.clientY - state.lastY;
99
+ state.theta -= dx * 0.005;
100
+ state.phi = Math.max(0.1, Math.min(Math.PI - 0.1, state.phi - dy * 0.005));
101
+ state.lastX = e.clientX;
102
+ state.lastY = e.clientY;
103
+ };
104
+ const onMouseUp = () => {
105
+ state.isDragging = false;
106
+ };
107
+ const onWheel = (e) => {
108
+ e.preventDefault();
109
+ state.distance = Math.max(6, Math.min(30, state.distance + e.deltaY * 0.01));
110
+ };
111
+ const onTouchStart = (e) => {
112
+ if (e.touches.length === 1) {
113
+ state.isDragging = true;
114
+ state.lastX = e.touches[0].clientX;
115
+ state.lastY = e.touches[0].clientY;
116
+ }
117
+ else if (e.touches.length === 2) {
118
+ state.isPinching = true;
119
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
120
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
121
+ state.lastPinchDist = Math.sqrt(dx * dx + dy * dy);
122
+ }
123
+ };
124
+ const onTouchMove = (e) => {
125
+ if (e.touches.length === 1 && state.isDragging) {
126
+ const dx = e.touches[0].clientX - state.lastX;
127
+ const dy = e.touches[0].clientY - state.lastY;
128
+ state.theta -= dx * 0.005;
129
+ state.phi = Math.max(0.1, Math.min(Math.PI - 0.1, state.phi - dy * 0.005));
130
+ state.lastX = e.touches[0].clientX;
131
+ state.lastY = e.touches[0].clientY;
132
+ }
133
+ else if (e.touches.length === 2 && state.isPinching) {
134
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
135
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
136
+ const dist = Math.sqrt(dx * dx + dy * dy);
137
+ const delta = state.lastPinchDist - dist;
138
+ state.distance = Math.max(6, Math.min(30, state.distance + delta * 0.02));
139
+ state.lastPinchDist = dist;
140
+ }
141
+ };
142
+ const onTouchEnd = () => {
143
+ state.isDragging = false;
144
+ state.isPinching = false;
145
+ };
146
+ canvas.addEventListener('mousedown', onMouseDown);
147
+ window.addEventListener('mousemove', onMouseMove);
148
+ window.addEventListener('mouseup', onMouseUp);
149
+ canvas.addEventListener('wheel', onWheel, { passive: false });
150
+ canvas.addEventListener('touchstart', onTouchStart, { passive: true });
151
+ canvas.addEventListener('touchmove', onTouchMove, { passive: true });
152
+ canvas.addEventListener('touchend', onTouchEnd);
153
+ const update = () => {
154
+ camera.position.set(state.distance * Math.sin(state.phi) * Math.cos(state.theta), state.distance * Math.cos(state.phi), state.distance * Math.sin(state.phi) * Math.sin(state.theta));
155
+ camera.lookAt(state.target);
156
+ };
157
+ const dispose = () => {
158
+ canvas.removeEventListener('mousedown', onMouseDown);
159
+ window.removeEventListener('mousemove', onMouseMove);
160
+ window.removeEventListener('mouseup', onMouseUp);
161
+ canvas.removeEventListener('wheel', onWheel);
162
+ canvas.removeEventListener('touchstart', onTouchStart);
163
+ canvas.removeEventListener('touchmove', onTouchMove);
164
+ canvas.removeEventListener('touchend', onTouchEnd);
165
+ };
166
+ return { state, update, dispose };
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // Raycaster helpers
170
+ // ---------------------------------------------------------------------------
171
+ function getCellUnderMouse(event, canvas, camera, cellMeshes) {
172
+ const rect = canvas.getBoundingClientRect();
173
+ const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
174
+ const raycaster = new THREE.Raycaster();
175
+ raycaster.setFromCamera(mouse, camera);
176
+ const intersects = raycaster.intersectObjects(cellMeshes, false);
177
+ if (intersects.length > 0) {
178
+ return intersects[0].object.userData.cellIndex;
179
+ }
180
+ return null;
181
+ }
182
+ // ---------------------------------------------------------------------------
183
+ // GameSphere Component
184
+ // ---------------------------------------------------------------------------
185
+ export const GameSphere = ({ cellGameState, config: configProp, events, width = '100%', height = '100%', className, style, rendererRef, sceneRef, paused = false, children, }) => {
186
+ const overlayChildren = normalizeReactNode(children);
187
+ const containerRef = useRef(null);
188
+ const canvasRef = useRef(null);
189
+ const rendererInternalRef = useRef(null);
190
+ const sceneInternalRef = useRef(null);
191
+ const cameraRef = useRef(null);
192
+ const orbitRef = useRef(null);
193
+ const gridRef = useRef(null);
194
+ const rafRef = useRef(0);
195
+ const clockRef = useRef(new THREE.Clock());
196
+ // Mutable refs for cell/piece groups (avoid re-creating scene each frame)
197
+ const cellGroupRef = useRef(new THREE.Group());
198
+ const pieceGroupRef = useRef(new THREE.Group());
199
+ const highlightGroupRef = useRef(new THREE.Group());
200
+ const cellMeshesRef = useRef([]);
201
+ const cellBordersRef = useRef([]);
202
+ const pieceMeshMapRef = useRef(new Map());
203
+ const highlightMeshMapRef = useRef(new Map());
204
+ // Hover state
205
+ const hoveredCellRef = useRef(null);
206
+ const config = useMemo(() => {
207
+ return { ...DEFAULT_CONFIG, ...configProp };
208
+ }, [configProp]);
209
+ // -----------------------------------------------------------------------
210
+ // Initialize Three.js scene
211
+ // -----------------------------------------------------------------------
212
+ useEffect(() => {
213
+ const canvas = canvasRef.current;
214
+ const container = containerRef.current;
215
+ if (!canvas || !container)
216
+ return;
217
+ // Renderer
218
+ const renderer = new THREE.WebGLRenderer({
219
+ canvas,
220
+ antialias: config.enableAntialias,
221
+ alpha: true,
222
+ });
223
+ renderer.setPixelRatio(config.pixelRatio);
224
+ renderer.setClearColor(0x000000, 0);
225
+ if (config.enableShadows) {
226
+ renderer.shadowMap.enabled = true;
227
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
228
+ }
229
+ rendererInternalRef.current = renderer;
230
+ if (rendererRef) {
231
+ rendererRef.current = renderer;
232
+ }
233
+ // Scene
234
+ const scene = new THREE.Scene();
235
+ sceneInternalRef.current = scene;
236
+ if (sceneRef) {
237
+ sceneRef.current = scene;
238
+ }
239
+ // Camera
240
+ const camera = new THREE.PerspectiveCamera(config.cameraFov, container.clientWidth / container.clientHeight, 0.1, 100);
241
+ cameraRef.current = camera;
242
+ // Lighting
243
+ const ambient = new THREE.AmbientLight(0xffffff, config.ambientLightIntensity);
244
+ scene.add(ambient);
245
+ const dirLight = new THREE.DirectionalLight(0xffffff, config.directionalLightIntensity);
246
+ const [lx, ly, lz] = config.directionalLightPosition;
247
+ dirLight.position.set(lx, ly, lz);
248
+ if (config.enableShadows) {
249
+ dirLight.castShadow = true;
250
+ }
251
+ scene.add(dirLight);
252
+ // Add a subtle hemisphere light for more natural illumination
253
+ const hemiLight = new THREE.HemisphereLight(0x8888cc, 0x222233, 0.3);
254
+ scene.add(hemiLight);
255
+ // Groups
256
+ cellGroupRef.current = new THREE.Group();
257
+ cellGroupRef.current.name = 'cells';
258
+ scene.add(cellGroupRef.current);
259
+ pieceGroupRef.current = new THREE.Group();
260
+ pieceGroupRef.current.name = 'pieces';
261
+ scene.add(pieceGroupRef.current);
262
+ highlightGroupRef.current = new THREE.Group();
263
+ highlightGroupRef.current.name = 'highlights';
264
+ scene.add(highlightGroupRef.current);
265
+ // Orbit controls
266
+ if (config.enableOrbitControls) {
267
+ orbitRef.current = createOrbitControls(camera, canvas, config.cameraDistance);
268
+ }
269
+ else {
270
+ camera.position.set(0, 0, config.cameraDistance);
271
+ camera.lookAt(0, 0, 0);
272
+ }
273
+ // Generate geodesic grid
274
+ const grid = new GeodesicHexGrid(config.subdivisions);
275
+ gridRef.current = grid;
276
+ // Build cell meshes
277
+ buildAllCells(grid, config);
278
+ // Resize handler
279
+ const handleResize = () => {
280
+ if (!container)
281
+ return;
282
+ const w = container.clientWidth;
283
+ const h = container.clientHeight;
284
+ camera.aspect = w / h;
285
+ camera.updateProjectionMatrix();
286
+ renderer.setSize(w, h);
287
+ };
288
+ handleResize();
289
+ const resizeObserver = new ResizeObserver(handleResize);
290
+ resizeObserver.observe(container);
291
+ return () => {
292
+ resizeObserver.disconnect();
293
+ orbitRef.current?.dispose();
294
+ renderer.dispose();
295
+ // Clean up scene
296
+ scene.traverse((obj) => {
297
+ if (obj instanceof THREE.Mesh) {
298
+ obj.geometry?.dispose();
299
+ if (obj.material instanceof THREE.Material)
300
+ obj.material.dispose();
301
+ if (Array.isArray(obj.material))
302
+ obj.material.forEach((m) => m.dispose());
303
+ }
304
+ });
305
+ cancelAnimationFrame(rafRef.current);
306
+ };
307
+ }, [config.subdivisions, config.sphereRadius]); // Re-init only on structural changes
308
+ // -----------------------------------------------------------------------
309
+ // Build all cell meshes from geodesic grid
310
+ // -----------------------------------------------------------------------
311
+ const buildAllCells = useCallback((grid, cfg) => {
312
+ const cellGroup = cellGroupRef.current;
313
+ // Clear existing
314
+ while (cellGroup.children.length > 0) {
315
+ cellGroup.remove(cellGroup.children[0]);
316
+ }
317
+ cellMeshesRef.current = [];
318
+ cellBordersRef.current = [];
319
+ for (let i = 0; i < grid.hexCenters.length; i++) {
320
+ const center = grid.hexCenters[i];
321
+ const isPentagon = grid.isPentagon(i);
322
+ const sides = grid.getHexSides(i);
323
+ // Generate vertex positions for this cell
324
+ const cellVertices = generateCellVertices(center, grid, i, sides);
325
+ const baseColor = isPentagon ? cfg.pentagonBaseColor : cfg.hexBaseColor;
326
+ const borderColor = isPentagon ? cfg.pentagonBorderColor : cfg.hexBorderColor;
327
+ // Cell face
328
+ const face = buildCellMesh(center, cellVertices, cfg.sphereRadius, baseColor);
329
+ face.userData.cellIndex = i;
330
+ face.userData.isPentagon = isPentagon;
331
+ face.userData.baseColor = baseColor;
332
+ cellGroup.add(face);
333
+ cellMeshesRef.current.push(face);
334
+ // Cell border
335
+ const border = buildCellBorder(cellVertices, cfg.sphereRadius, borderColor);
336
+ border.userData.cellIndex = i;
337
+ cellGroup.add(border);
338
+ cellBordersRef.current.push(border);
339
+ }
340
+ }, []);
341
+ // -----------------------------------------------------------------------
342
+ // Generate cell vertices (approximate hex/pentagon shape on sphere)
343
+ // -----------------------------------------------------------------------
344
+ function generateCellVertices(center, grid, cellIndex, sides) {
345
+ const neighbors = grid.neighbors[cellIndex] || [];
346
+ const normal = new THREE.Vector3(center.x, center.y, center.z).normalize();
347
+ // If we have enough neighbors, compute vertices from neighbor midpoints
348
+ if (neighbors.length >= sides) {
349
+ const midpoints = [];
350
+ for (let j = 0; j < Math.min(neighbors.length, sides); j++) {
351
+ const n = grid.hexCenters[neighbors[j]];
352
+ midpoints.push(new Vector3((center.x + n.x) / 2, (center.y + n.y) / 2, (center.z + n.z) / 2));
353
+ }
354
+ // Sort midpoints by angle around the normal for consistent winding
355
+ const tangent = new THREE.Vector3(0, 1, 0);
356
+ if (Math.abs(normal.dot(tangent)) > 0.99) {
357
+ tangent.set(1, 0, 0);
358
+ }
359
+ const bitangent = new THREE.Vector3().crossVectors(normal, tangent).normalize();
360
+ const realTangent = new THREE.Vector3().crossVectors(bitangent, normal).normalize();
361
+ midpoints.sort((a, b) => {
362
+ const aProj = new THREE.Vector3(a.x - center.x, a.y - center.y, a.z - center.z);
363
+ const bProj = new THREE.Vector3(b.x - center.x, b.y - center.y, b.z - center.z);
364
+ const aAngle = Math.atan2(aProj.dot(bitangent), aProj.dot(realTangent));
365
+ const bAngle = Math.atan2(bProj.dot(bitangent), bProj.dot(realTangent));
366
+ return aAngle - bAngle;
367
+ });
368
+ return midpoints;
369
+ }
370
+ // Fallback: generate regular polygon vertices
371
+ const radius = 0.15; // Approximate hex radius on unit sphere
372
+ const vertices = [];
373
+ const tangent = new THREE.Vector3(0, 1, 0);
374
+ if (Math.abs(normal.dot(tangent)) > 0.99) {
375
+ tangent.set(1, 0, 0);
376
+ }
377
+ const bitangent = new THREE.Vector3().crossVectors(normal, tangent).normalize();
378
+ const realTangent = new THREE.Vector3().crossVectors(bitangent, normal).normalize();
379
+ for (let j = 0; j < sides; j++) {
380
+ const angle = (j / sides) * Math.PI * 2;
381
+ const x = center.x + Math.cos(angle) * radius * realTangent.x + Math.sin(angle) * radius * bitangent.x;
382
+ const y = center.y + Math.cos(angle) * radius * realTangent.y + Math.sin(angle) * radius * bitangent.y;
383
+ const z = center.z + Math.cos(angle) * radius * realTangent.z + Math.sin(angle) * radius * bitangent.z;
384
+ vertices.push(new Vector3(x, y, z));
385
+ }
386
+ return vertices;
387
+ }
388
+ // -----------------------------------------------------------------------
389
+ // Apply cell game state
390
+ // -----------------------------------------------------------------------
391
+ useEffect(() => {
392
+ if (!cellGameState || cellMeshesRef.current.length === 0)
393
+ return;
394
+ const cellMeshes = cellMeshesRef.current;
395
+ const cellBorders = cellBordersRef.current;
396
+ const pieceGroup = pieceGroupRef.current;
397
+ const highlightGroup = highlightGroupRef.current;
398
+ const grid = gridRef.current;
399
+ if (!grid)
400
+ return;
401
+ // Track which pieces are still active
402
+ const activePieceIds = new Set();
403
+ // Track which cells have highlights
404
+ const activeHighlightCells = new Set();
405
+ for (const [cellIndex, state] of cellGameState) {
406
+ if (cellIndex >= cellMeshes.length)
407
+ continue;
408
+ const cellMesh = cellMeshes[cellIndex];
409
+ const cellBorder = cellBorders[cellIndex] ?? null;
410
+ // Reset cell to base color first
411
+ const baseColor = cellMesh.userData.baseColor;
412
+ cellMesh.material.color.set(baseColor);
413
+ cellMesh.material.opacity = 1;
414
+ cellMesh.material.transparent = false;
415
+ // Apply state (owner color, fog, border, elevation)
416
+ applyCellState(cellMesh, cellBorder, state, {
417
+ fogDimOpacity: 0.4,
418
+ fogHiddenOpacity: 0.15,
419
+ fogExploredOpacity: 0.65,
420
+ });
421
+ // --- Highlights ---
422
+ if (state.highlight && state.highlight !== 'none') {
423
+ activeHighlightCells.add(cellIndex);
424
+ const hlColor = state.highlightColor ?? getHighlightColor(state.highlight);
425
+ const hlIntensity = state.highlightIntensity ?? 0.8;
426
+ if (!highlightMeshMapRef.current.has(cellIndex)) {
427
+ const center = grid.hexCenters[cellIndex];
428
+ const ring = buildHighlightRing(center, config.sphereRadius, 0.12, hlColor, hlIntensity);
429
+ ring.userData.cellIndex = cellIndex;
430
+ highlightGroup.add(ring);
431
+ highlightMeshMapRef.current.set(cellIndex, ring);
432
+ }
433
+ else {
434
+ // Update existing highlight
435
+ const ring = highlightMeshMapRef.current.get(cellIndex);
436
+ ring.material.color.set(hlColor);
437
+ ring.material.opacity = 0.6 * hlIntensity;
438
+ }
439
+ }
440
+ // --- Game pieces ---
441
+ if (state.pieces) {
442
+ for (const piece of state.pieces) {
443
+ activePieceIds.add(piece.id);
444
+ if (!pieceMeshMapRef.current.has(piece.id)) {
445
+ // Build new piece
446
+ const meshGroup = buildPieceMesh(piece);
447
+ const center = grid.hexCenters[cellIndex];
448
+ const defaultScale = config.defaultPieceScale;
449
+ if (!piece.scale) {
450
+ meshGroup.scale.setScalar(defaultScale);
451
+ }
452
+ placePieceOnSphere(meshGroup, center, config.sphereRadius, piece.offsetY ?? 0.05);
453
+ pieceGroup.add(meshGroup);
454
+ pieceMeshMapRef.current.set(piece.id, meshGroup);
455
+ }
456
+ else {
457
+ // Update existing piece position (cell may have changed)
458
+ const meshGroup = pieceMeshMapRef.current.get(piece.id);
459
+ const center = grid.hexCenters[cellIndex];
460
+ placePieceOnSphere(meshGroup, center, config.sphereRadius, piece.offsetY ?? 0.05);
461
+ // Update count badge if count changed
462
+ const existingBadge = meshGroup.getObjectByName('count-badge');
463
+ if (piece.count && piece.count > 1) {
464
+ if (existingBadge) {
465
+ meshGroup.remove(existingBadge);
466
+ }
467
+ // Rebuild badge
468
+ const canvas = document.createElement('canvas');
469
+ const ctx = canvas.getContext('2d');
470
+ canvas.width = 128;
471
+ canvas.height = 128;
472
+ ctx.beginPath();
473
+ ctx.arc(64, 64, 50, 0, Math.PI * 2);
474
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
475
+ ctx.fill();
476
+ ctx.strokeStyle = piece.color ?? '#ffffff';
477
+ ctx.lineWidth = 4;
478
+ ctx.stroke();
479
+ ctx.font = 'bold 56px Consolas, "Courier New", monospace';
480
+ ctx.textAlign = 'center';
481
+ ctx.textBaseline = 'middle';
482
+ ctx.fillStyle = piece.color ?? '#ffffff';
483
+ ctx.fillText(String(piece.count), 64, 64);
484
+ const texture = new THREE.CanvasTexture(canvas);
485
+ const spriteMat = new THREE.SpriteMaterial({
486
+ map: texture,
487
+ transparent: true,
488
+ depthTest: false,
489
+ });
490
+ const badge = new THREE.Sprite(spriteMat);
491
+ badge.scale.set(0.5, 0.5, 1);
492
+ badge.position.y = 0.6;
493
+ badge.name = 'count-badge';
494
+ meshGroup.add(badge);
495
+ }
496
+ }
497
+ }
498
+ }
499
+ }
500
+ // Remove pieces no longer in state
501
+ for (const [pieceId, meshGroup] of pieceMeshMapRef.current) {
502
+ if (!activePieceIds.has(pieceId)) {
503
+ pieceGroup.remove(meshGroup);
504
+ disposePieceGroup(meshGroup);
505
+ pieceMeshMapRef.current.delete(pieceId);
506
+ }
507
+ }
508
+ // Remove highlights no longer active
509
+ for (const [cellIndex, ring] of highlightMeshMapRef.current) {
510
+ if (!activeHighlightCells.has(cellIndex)) {
511
+ highlightGroup.remove(ring);
512
+ ring.geometry?.dispose();
513
+ ring.material.dispose();
514
+ highlightMeshMapRef.current.delete(cellIndex);
515
+ }
516
+ }
517
+ // Reset cells not in game state to base appearance
518
+ for (let i = 0; i < cellMeshes.length; i++) {
519
+ if (!cellGameState.has(i)) {
520
+ const mat = cellMeshes[i].material;
521
+ mat.color.set(cellMeshes[i].userData.baseColor);
522
+ mat.opacity = 1;
523
+ mat.transparent = false;
524
+ }
525
+ }
526
+ }, [cellGameState, config.sphereRadius, config.defaultPieceScale]);
527
+ // -----------------------------------------------------------------------
528
+ // Mouse interaction (raycasting)
529
+ // -----------------------------------------------------------------------
530
+ useEffect(() => {
531
+ const canvas = canvasRef.current;
532
+ const camera = cameraRef.current;
533
+ if (!canvas || !camera || !config.enableRaycasting)
534
+ return;
535
+ const handleClick = (e) => {
536
+ const cellIndex = getCellUnderMouse(e, canvas, camera, cellMeshesRef.current);
537
+ if (cellIndex !== null && events?.onCellClick) {
538
+ events.onCellClick(cellIndex, { shiftKey: e.shiftKey, ctrlKey: e.ctrlKey || e.metaKey });
539
+ }
540
+ // Check piece clicks
541
+ if (cellIndex !== null && events?.onPieceClick && cellGameState) {
542
+ const state = cellGameState.get(cellIndex);
543
+ if (state?.pieces?.length) {
544
+ events.onPieceClick(cellIndex, state.pieces[0]);
545
+ }
546
+ }
547
+ };
548
+ const handleMouseMove = (e) => {
549
+ if (!events?.onCellHover)
550
+ return;
551
+ const cellIndex = getCellUnderMouse(e, canvas, camera, cellMeshesRef.current);
552
+ if (cellIndex !== hoveredCellRef.current) {
553
+ hoveredCellRef.current = cellIndex;
554
+ events.onCellHover(cellIndex);
555
+ }
556
+ };
557
+ canvas.addEventListener('click', handleClick);
558
+ canvas.addEventListener('mousemove', handleMouseMove);
559
+ return () => {
560
+ canvas.removeEventListener('click', handleClick);
561
+ canvas.removeEventListener('mousemove', handleMouseMove);
562
+ };
563
+ }, [events, cellGameState, config.enableRaycasting]);
564
+ // -----------------------------------------------------------------------
565
+ // Render loop
566
+ // -----------------------------------------------------------------------
567
+ useEffect(() => {
568
+ const renderer = rendererInternalRef.current;
569
+ const scene = sceneInternalRef.current;
570
+ const camera = cameraRef.current;
571
+ if (!renderer || !scene || !camera)
572
+ return;
573
+ const animate = () => {
574
+ rafRef.current = requestAnimationFrame(animate);
575
+ if (paused)
576
+ return;
577
+ const delta = clockRef.current.getDelta();
578
+ const elapsed = clockRef.current.getElapsedTime();
579
+ // Update orbit controls
580
+ if (orbitRef.current) {
581
+ if (config.autoRotate && !orbitRef.current.state.isDragging) {
582
+ orbitRef.current.state.theta += config.autoRotateSpeed * 0.001;
583
+ }
584
+ orbitRef.current.update();
585
+ }
586
+ // Animate pieces
587
+ for (const [, meshGroup] of pieceMeshMapRef.current) {
588
+ animatePiece(meshGroup, elapsed, delta);
589
+ }
590
+ // Pulse highlight rings
591
+ for (const [, ring] of highlightMeshMapRef.current) {
592
+ const mat = ring.material;
593
+ mat.opacity = 0.4 + Math.sin(elapsed * 3) * 0.2;
594
+ }
595
+ // Camera change callback
596
+ if (events?.onCameraChange && camera) {
597
+ events.onCameraChange([camera.position.x, camera.position.y, camera.position.z], [0, 0, 0]);
598
+ }
599
+ // Frame callback
600
+ events?.onFrame?.(delta);
601
+ renderer.render(scene, camera);
602
+ };
603
+ animate();
604
+ return () => {
605
+ cancelAnimationFrame(rafRef.current);
606
+ };
607
+ }, [paused, config.autoRotate, config.autoRotateSpeed, events]);
608
+ // -----------------------------------------------------------------------
609
+ // Public API via ref
610
+ // -----------------------------------------------------------------------
611
+ return (_jsxs("div", { ref: containerRef, className: className, style: {
612
+ position: 'relative',
613
+ width: typeof width === 'number' ? `${width}px` : width,
614
+ height: typeof height === 'number' ? `${height}px` : height,
615
+ overflow: 'hidden',
616
+ ...style,
617
+ }, children: [_jsx("canvas", { ref: canvasRef, style: {
618
+ width: '100%',
619
+ height: '100%',
620
+ display: 'block',
621
+ touchAction: 'none', // Prevent browser gesture interference
622
+ } }), overlayChildren != null ? (_jsx("div", { style: {
623
+ position: 'absolute',
624
+ top: 0,
625
+ left: 0,
626
+ right: 0,
627
+ bottom: 0,
628
+ pointerEvents: 'none',
629
+ }, children: _jsx("div", { style: { pointerEvents: 'auto' }, children: overlayChildren }) })) : null] }));
630
+ };
631
+ // Re-export GeodesicHexGrid for game consumers
632
+ export { GeodesicHexGrid } from '../math/HexCoordinates';
@@ -1 +1 @@
1
- {"version":3,"file":"HexGrid.d.ts","sourceRoot":"","sources":["../../src/components/HexGrid.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAA;AAYhF,OAAO,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,UAAU,CAAA;AAI/C,YAAY,EAAE,KAAK,EAAE,CAAA;AAWrB,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,OAAO;IAEvC,KAAK,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAA;IACrB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAA;IAGhB,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,IAAI,CAAA;IACzC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;IAEnC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAA;IAC9C,mBAAmB,CAAC,EAAE,CAAC,WAAW,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,KAAK,IAAI,CAAA;IAC5G,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,0BAA0B,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACpD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,KAAK,CAAA;IACZ,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAC1C,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;CACf;AAoLD,eAAO,MAAM,OAAO,GAAI,CAAC,GAAG,OAAO,EAAE,iMAalC,YAAY,CAAC,CAAC,CAAC,4CA4xIjB,CAAA;AAk7DD,eAAe,OAAO,CAAC"}
1
+ {"version":3,"file":"HexGrid.d.ts","sourceRoot":"","sources":["../../src/components/HexGrid.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAA;AAYhF,OAAO,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,UAAU,CAAA;AAU/C,YAAY,EAAE,KAAK,EAAE,CAAA;AAWrB,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,OAAO;IAEvC,KAAK,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAA;IACrB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAA;IAGhB,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,IAAI,CAAA;IACzC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;IAEnC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAA;IAC9C,mBAAmB,CAAC,EAAE,CAAC,WAAW,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,KAAK,IAAI,CAAA;IAC5G,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,0BAA0B,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACpD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,KAAK,CAAA;IACZ,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAC1C,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;CACf;AAsLD,eAAO,MAAM,OAAO,GAAI,CAAC,GAAG,OAAO,EAAE,iMAalC,YAAY,CAAC,CAAC,CAAC,4CAkzIjB,CAAA;AAk2DD,eAAe,OAAO,CAAC"}