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