@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.
- package/README.md +66 -0
- package/dist/components/GamePieceRenderer.d.ts +133 -0
- package/dist/components/GamePieceRenderer.d.ts.map +1 -0
- package/dist/components/GamePieceRenderer.js +688 -0
- package/dist/components/GameSphere.d.ts +13 -0
- package/dist/components/GameSphere.d.ts.map +1 -0
- package/dist/components/GameSphere.js +622 -0
- package/dist/components/index.d.ts +5 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +4 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/territory/HexTerritoryGlobe.d.ts +15 -0
- package/dist/territory/HexTerritoryGlobe.d.ts.map +1 -0
- package/dist/territory/HexTerritoryGlobe.js +75 -0
- package/dist/territory/globe.d.ts +54 -0
- package/dist/territory/globe.d.ts.map +1 -0
- package/dist/territory/globe.js +180 -0
- package/dist/territory/index.d.ts +4 -0
- package/dist/territory/index.d.ts.map +1 -0
- package/dist/territory/index.js +3 -0
- package/dist/territory/narration.d.ts +12 -0
- package/dist/territory/narration.d.ts.map +1 -0
- package/dist/territory/narration.js +84 -0
- package/dist/types.d.ts +165 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -1
|
@@ -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"}
|
package/dist/components/index.js
CHANGED
|
@@ -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
package/dist/index.d.ts.map
CHANGED
|
@@ -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"}
|