@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,688 @@
1
+ /**
2
+ * GamePieceRenderer — Three.js mesh factory for game pieces on a geodesic sphere.
3
+ *
4
+ * Builds 3D meshes from PieceShape primitives, accepts custom Object3D instances,
5
+ * handles instanced rendering for performance, surface-normal alignment, stacking,
6
+ * and piece animations (spin, bob, pulse, wobble, orbit, glow).
7
+ */
8
+ import * as THREE from 'three';
9
+ // ---------------------------------------------------------------------------
10
+ // Geometry cache — shared across all pieces to avoid duplicate geometry alloc
11
+ // ---------------------------------------------------------------------------
12
+ const geometryCache = new Map();
13
+ function getCachedGeometry(shape) {
14
+ if (geometryCache.has(shape))
15
+ return geometryCache.get(shape);
16
+ let geo;
17
+ switch (shape) {
18
+ case 'sphere':
19
+ geo = new THREE.SphereGeometry(0.5, 24, 16);
20
+ break;
21
+ case 'cube':
22
+ geo = new THREE.BoxGeometry(0.7, 0.7, 0.7);
23
+ break;
24
+ case 'cone':
25
+ geo = new THREE.ConeGeometry(0.4, 0.9, 16);
26
+ break;
27
+ case 'cylinder':
28
+ geo = new THREE.CylinderGeometry(0.35, 0.35, 0.8, 16);
29
+ break;
30
+ case 'pyramid':
31
+ geo = new THREE.ConeGeometry(0.5, 0.9, 4);
32
+ break;
33
+ case 'torus':
34
+ geo = new THREE.TorusGeometry(0.35, 0.12, 12, 24);
35
+ break;
36
+ case 'ring':
37
+ geo = new THREE.TorusGeometry(0.45, 0.06, 8, 32);
38
+ break;
39
+ case 'flag': {
40
+ // Flag = thin vertical pole + rectangular flag
41
+ const group = new THREE.BufferGeometry();
42
+ const pole = new THREE.CylinderGeometry(0.02, 0.02, 1.0, 6);
43
+ const flag = new THREE.PlaneGeometry(0.5, 0.3);
44
+ pole.translate(0, 0.5, 0);
45
+ flag.translate(0.25, 0.85, 0);
46
+ const merged = mergeGeometries([pole, flag]);
47
+ geo = merged ?? pole;
48
+ break;
49
+ }
50
+ case 'star': {
51
+ // Octahedron as a star approximation
52
+ geo = new THREE.OctahedronGeometry(0.5, 0);
53
+ geo.scale(1, 1.3, 1); // elongate vertically
54
+ break;
55
+ }
56
+ case 'diamond':
57
+ geo = new THREE.OctahedronGeometry(0.45, 0);
58
+ break;
59
+ case 'capsule':
60
+ geo = new THREE.CapsuleGeometry(0.25, 0.5, 8, 16);
61
+ break;
62
+ case 'octahedron':
63
+ geo = new THREE.OctahedronGeometry(0.5, 0);
64
+ break;
65
+ case 'dodecahedron':
66
+ geo = new THREE.DodecahedronGeometry(0.45, 0);
67
+ break;
68
+ case 'icosahedron':
69
+ geo = new THREE.IcosahedronGeometry(0.45, 0);
70
+ break;
71
+ default:
72
+ geo = new THREE.SphereGeometry(0.5, 24, 16);
73
+ }
74
+ geometryCache.set(shape, geo);
75
+ return geo;
76
+ }
77
+ /** Simple merge helper — concat two BufferGeometries into one */
78
+ function mergeGeometries(geos) {
79
+ if (geos.length === 0)
80
+ return null;
81
+ if (geos.length === 1)
82
+ return geos[0];
83
+ const positions = [];
84
+ const normals = [];
85
+ const indices = [];
86
+ let indexOffset = 0;
87
+ for (const geo of geos) {
88
+ const pos = geo.getAttribute('position');
89
+ const norm = geo.getAttribute('normal');
90
+ const idx = geo.getIndex();
91
+ if (pos) {
92
+ for (let i = 0; i < pos.count; i++) {
93
+ positions.push(pos.getX(i), pos.getY(i), pos.getZ(i));
94
+ }
95
+ }
96
+ if (norm) {
97
+ for (let i = 0; i < norm.count; i++) {
98
+ normals.push(norm.getX(i), norm.getY(i), norm.getZ(i));
99
+ }
100
+ }
101
+ if (idx) {
102
+ for (let i = 0; i < idx.count; i++) {
103
+ indices.push(idx.array[i] + indexOffset);
104
+ }
105
+ }
106
+ indexOffset += pos ? pos.count : 0;
107
+ }
108
+ const merged = new THREE.BufferGeometry();
109
+ merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
110
+ if (normals.length > 0) {
111
+ merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
112
+ }
113
+ if (indices.length > 0) {
114
+ merged.setIndex(indices);
115
+ }
116
+ return merged;
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // Material factory
120
+ // ---------------------------------------------------------------------------
121
+ function createPieceMaterial(piece) {
122
+ const mat = new THREE.MeshStandardMaterial({
123
+ color: new THREE.Color(piece.color ?? '#ffffff'),
124
+ metalness: piece.metalness ?? 0.1,
125
+ roughness: piece.roughness ?? 0.6,
126
+ transparent: (piece.opacity ?? 1) < 1,
127
+ opacity: piece.opacity ?? 1,
128
+ wireframe: piece.wireframe ?? false,
129
+ });
130
+ if (piece.emissive) {
131
+ mat.emissive = new THREE.Color(piece.emissive);
132
+ mat.emissiveIntensity = piece.emissiveIntensity ?? 0.5;
133
+ }
134
+ return mat;
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Label sprite factory (billboarded text above piece)
138
+ // ---------------------------------------------------------------------------
139
+ function createLabelSprite(text, color = '#ffffff', fontSize = 48) {
140
+ const canvas = document.createElement('canvas');
141
+ const ctx = canvas.getContext('2d');
142
+ canvas.width = 256;
143
+ canvas.height = 64;
144
+ ctx.fillStyle = 'transparent';
145
+ ctx.clearRect(0, 0, 256, 64);
146
+ ctx.font = `bold ${fontSize}px Consolas, "Courier New", monospace`;
147
+ ctx.textAlign = 'center';
148
+ ctx.textBaseline = 'middle';
149
+ // Shadow for readability
150
+ ctx.shadowColor = 'rgba(0,0,0,0.8)';
151
+ ctx.shadowBlur = 4;
152
+ ctx.shadowOffsetX = 1;
153
+ ctx.shadowOffsetY = 1;
154
+ ctx.fillStyle = color;
155
+ ctx.fillText(text, 128, 32);
156
+ const texture = new THREE.CanvasTexture(canvas);
157
+ texture.needsUpdate = true;
158
+ const spriteMat = new THREE.SpriteMaterial({
159
+ map: texture,
160
+ transparent: true,
161
+ depthTest: false,
162
+ });
163
+ const sprite = new THREE.Sprite(spriteMat);
164
+ sprite.scale.set(1.0, 0.25, 1);
165
+ return sprite;
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Count badge sprite (for army counts, resource counts, etc.)
169
+ // ---------------------------------------------------------------------------
170
+ function createCountBadge(count, color) {
171
+ const canvas = document.createElement('canvas');
172
+ const ctx = canvas.getContext('2d');
173
+ canvas.width = 128;
174
+ canvas.height = 128;
175
+ // Circle background
176
+ ctx.beginPath();
177
+ ctx.arc(64, 64, 50, 0, Math.PI * 2);
178
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
179
+ ctx.fill();
180
+ ctx.strokeStyle = color;
181
+ ctx.lineWidth = 4;
182
+ ctx.stroke();
183
+ // Number
184
+ ctx.font = 'bold 56px Consolas, "Courier New", monospace';
185
+ ctx.textAlign = 'center';
186
+ ctx.textBaseline = 'middle';
187
+ ctx.fillStyle = color;
188
+ ctx.fillText(String(count), 64, 64);
189
+ const texture = new THREE.CanvasTexture(canvas);
190
+ const spriteMat = new THREE.SpriteMaterial({
191
+ map: texture,
192
+ transparent: true,
193
+ depthTest: false,
194
+ });
195
+ const sprite = new THREE.Sprite(spriteMat);
196
+ sprite.scale.set(0.5, 0.5, 1);
197
+ return sprite;
198
+ }
199
+ // ---------------------------------------------------------------------------
200
+ // Public API
201
+ // ---------------------------------------------------------------------------
202
+ /**
203
+ * Build a Three.js Object3D from a GamePiece definition.
204
+ * Returns a Group containing the mesh, optional label, and optional count badge.
205
+ */
206
+ export function buildPieceMesh(piece) {
207
+ const group = new THREE.Group();
208
+ group.name = `piece-${piece.id}`;
209
+ group.userData = { pieceId: piece.id, piece };
210
+ // --- Main mesh ---
211
+ let mesh;
212
+ if (piece.object3D) {
213
+ // Custom Object3D — clone it
214
+ mesh = piece.object3D.clone();
215
+ }
216
+ else if (piece.modelUrl) {
217
+ // GLTF placeholder — will be replaced asynchronously
218
+ const placeholder = new THREE.Mesh(getCachedGeometry('sphere'), new THREE.MeshStandardMaterial({ color: piece.color ?? '#888888', wireframe: true }));
219
+ placeholder.name = 'gltf-placeholder';
220
+ placeholder.userData.modelUrl = piece.modelUrl;
221
+ mesh = placeholder;
222
+ }
223
+ else {
224
+ // Primitive shape
225
+ const shape = piece.shape ?? 'sphere';
226
+ const geo = getCachedGeometry(shape);
227
+ const mat = createPieceMaterial(piece);
228
+ mesh = new THREE.Mesh(geo, mat);
229
+ }
230
+ // --- Scale ---
231
+ if (piece.scale !== undefined) {
232
+ if (typeof piece.scale === 'number') {
233
+ mesh.scale.setScalar(piece.scale);
234
+ }
235
+ else {
236
+ mesh.scale.set(piece.scale[0], piece.scale[1], piece.scale[2]);
237
+ }
238
+ }
239
+ // --- Rotation ---
240
+ if (piece.rotationY !== undefined) {
241
+ mesh.rotation.y = piece.rotationY;
242
+ }
243
+ mesh.name = 'piece-mesh';
244
+ group.add(mesh);
245
+ // --- Stack rendering for count > 1 ---
246
+ const count = piece.count ?? 1;
247
+ const stackStyle = piece.stackStyle ?? 'badge';
248
+ if (count > 1 && stackStyle === 'stack') {
249
+ // Render stacked copies offset upward
250
+ const stackCount = Math.min(count, 5); // visual cap
251
+ for (let i = 1; i < stackCount; i++) {
252
+ const clone = mesh.clone();
253
+ clone.position.y += i * 0.15;
254
+ clone.name = `piece-stack-${i}`;
255
+ group.add(clone);
256
+ }
257
+ }
258
+ else if (count > 1 && stackStyle === 'ring') {
259
+ // Render copies in a ring around the center
260
+ const ringCount = Math.min(count, 8);
261
+ const ringRadius = 0.25;
262
+ for (let i = 1; i < ringCount; i++) {
263
+ const angle = (i / ringCount) * Math.PI * 2;
264
+ const clone = mesh.clone();
265
+ clone.position.x += Math.cos(angle) * ringRadius;
266
+ clone.position.z += Math.sin(angle) * ringRadius;
267
+ clone.scale.multiplyScalar(0.6);
268
+ clone.name = `piece-ring-${i}`;
269
+ group.add(clone);
270
+ }
271
+ }
272
+ // --- Count badge (default for count > 1) ---
273
+ if (count > 1 && stackStyle === 'badge') {
274
+ const badge = createCountBadge(count, piece.color ?? '#ffffff');
275
+ badge.position.y = 0.6;
276
+ badge.name = 'count-badge';
277
+ group.add(badge);
278
+ }
279
+ // --- Label sprite ---
280
+ if (piece.label) {
281
+ const labelSprite = createLabelSprite(piece.label, piece.labelColor ?? piece.color ?? '#ffffff');
282
+ labelSprite.position.y = count > 1 && stackStyle === 'stack'
283
+ ? 0.15 * Math.min(count, 5) + 0.5
284
+ : 0.7;
285
+ labelSprite.name = 'piece-label';
286
+ group.add(labelSprite);
287
+ }
288
+ // --- Store animation config ---
289
+ if (piece.animation && piece.animation !== 'none') {
290
+ const animConfig = typeof piece.animation === 'string'
291
+ ? { type: piece.animation }
292
+ : piece.animation;
293
+ group.userData.animation = animConfig;
294
+ }
295
+ return group;
296
+ }
297
+ /**
298
+ * Place a piece group onto the sphere surface at a given hex center position.
299
+ * Orients the piece so "up" points away from the sphere center (surface normal).
300
+ *
301
+ * @param pieceGroup - The group returned by buildPieceMesh
302
+ * @param hexCenter - The 3D position of the hex center on the unit sphere
303
+ * @param sphereRadius - Radius of the rendered sphere
304
+ * @param offsetY - Additional height above surface (from GamePiece.offsetY)
305
+ */
306
+ export function placePieceOnSphere(pieceGroup, hexCenter, sphereRadius, offsetY = 0) {
307
+ // Normal = direction from sphere center to hex center (unit sphere, so it IS the normal)
308
+ const normal = new THREE.Vector3(hexCenter.x, hexCenter.y, hexCenter.z).normalize();
309
+ // Position on sphere surface + offset
310
+ const surfacePos = normal.clone().multiplyScalar(sphereRadius + offsetY);
311
+ pieceGroup.position.copy(surfacePos);
312
+ // Orient so local Y axis aligns with surface normal
313
+ const up = new THREE.Vector3(0, 1, 0);
314
+ const quat = new THREE.Quaternion().setFromUnitVectors(up, normal);
315
+ pieceGroup.quaternion.copy(quat);
316
+ }
317
+ /**
318
+ * Animate a piece group based on its stored animation config.
319
+ * Call this every frame with the elapsed time.
320
+ *
321
+ * @param pieceGroup - The group with userData.animation
322
+ * @param time - Elapsed time in seconds
323
+ * @param deltaTime - Frame delta in seconds
324
+ */
325
+ export function animatePiece(pieceGroup, time, _deltaTime) {
326
+ const config = pieceGroup.userData.animation;
327
+ if (!config || config.type === 'none')
328
+ return;
329
+ const speed = config.speed ?? 1.0;
330
+ const amplitude = config.amplitude ?? 1.0;
331
+ const phase = config.phase ?? 0;
332
+ const t = time * speed + phase;
333
+ const mesh = pieceGroup.getObjectByName('piece-mesh');
334
+ if (!mesh)
335
+ return;
336
+ switch (config.type) {
337
+ case 'spin': {
338
+ const axis = config.axis ?? [0, 1, 0];
339
+ const axisVec = new THREE.Vector3(axis[0], axis[1], axis[2]).normalize();
340
+ mesh.rotateOnAxis(axisVec, 0.02 * speed);
341
+ break;
342
+ }
343
+ case 'bob': {
344
+ // Sinusoidal up-down motion
345
+ mesh.position.y = Math.sin(t * 2) * 0.1 * amplitude;
346
+ break;
347
+ }
348
+ case 'pulse': {
349
+ // Scale oscillation
350
+ const s = 1.0 + Math.sin(t * 3) * 0.1 * amplitude;
351
+ mesh.scale.setScalar(s);
352
+ break;
353
+ }
354
+ case 'wobble': {
355
+ mesh.rotation.x = Math.sin(t * 2.5) * 0.15 * amplitude;
356
+ mesh.rotation.z = Math.cos(t * 2.5) * 0.15 * amplitude;
357
+ break;
358
+ }
359
+ case 'orbit': {
360
+ const radius = 0.2 * amplitude;
361
+ mesh.position.x = Math.cos(t * 2) * radius;
362
+ mesh.position.z = Math.sin(t * 2) * radius;
363
+ break;
364
+ }
365
+ case 'glow': {
366
+ // Pulse emissive intensity
367
+ if (mesh instanceof THREE.Mesh && mesh.material instanceof THREE.MeshStandardMaterial) {
368
+ mesh.material.emissiveIntensity = 0.3 + Math.sin(t * 3) * 0.3 * amplitude;
369
+ }
370
+ break;
371
+ }
372
+ }
373
+ }
374
+ // ---------------------------------------------------------------------------
375
+ // Cell surface mesh builder (hex/pentagon face on sphere)
376
+ // ---------------------------------------------------------------------------
377
+ /**
378
+ * Create a flat polygon mesh for a hex or pentagon cell on the sphere surface.
379
+ * Used by GameSphere to render the board itself.
380
+ *
381
+ * @param center - Hex center on unit sphere
382
+ * @param vertices - Ordered vertices of the hex/pentagon (on unit sphere)
383
+ * @param radius - Sphere radius for scaling
384
+ * @param color - Fill color
385
+ * @param elevation - How far above the sphere surface (0 = flush)
386
+ */
387
+ export function buildCellMesh(center, vertices, radius, color = '#1a1a2e', elevation = 0) {
388
+ const normal = new THREE.Vector3(center.x, center.y, center.z).normalize();
389
+ const offset = normal.clone().multiplyScalar(radius + elevation);
390
+ // Build fan geometry from center to vertices
391
+ const positions = [];
392
+ const normals = [];
393
+ const indices = [];
394
+ // Center vertex
395
+ positions.push(offset.x, offset.y, offset.z);
396
+ normals.push(normal.x, normal.y, normal.z);
397
+ for (const v of vertices) {
398
+ const vn = new THREE.Vector3(v.x, v.y, v.z).normalize();
399
+ const pos = vn.multiplyScalar(radius + elevation);
400
+ positions.push(pos.x, pos.y, pos.z);
401
+ normals.push(normal.x, normal.y, normal.z);
402
+ }
403
+ // Triangle fan
404
+ for (let i = 1; i <= vertices.length; i++) {
405
+ indices.push(0, i, i < vertices.length ? i + 1 : 1);
406
+ }
407
+ const geo = new THREE.BufferGeometry();
408
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
409
+ geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
410
+ geo.setIndex(indices);
411
+ const mat = new THREE.MeshStandardMaterial({
412
+ color: new THREE.Color(color),
413
+ side: THREE.DoubleSide,
414
+ metalness: 0.05,
415
+ roughness: 0.8,
416
+ });
417
+ const mesh = new THREE.Mesh(geo, mat);
418
+ mesh.name = 'cell-face';
419
+ return mesh;
420
+ }
421
+ /**
422
+ * Build a cell border (wireframe outline) for a hex/pentagon on the sphere.
423
+ */
424
+ export function buildCellBorder(vertices, radius, color = '#333355', lineWidth = 1, elevation = 0) {
425
+ const points = [];
426
+ for (const v of vertices) {
427
+ const vn = new THREE.Vector3(v.x, v.y, v.z).normalize();
428
+ const pos = vn.multiplyScalar(radius + elevation + 0.001); // tiny offset to avoid z-fighting
429
+ points.push(pos);
430
+ }
431
+ const geo = new THREE.BufferGeometry().setFromPoints(points);
432
+ const mat = new THREE.LineBasicMaterial({
433
+ color: new THREE.Color(color),
434
+ linewidth: lineWidth,
435
+ });
436
+ const line = new THREE.LineLoop(geo, mat);
437
+ line.name = 'cell-border';
438
+ return line;
439
+ }
440
+ // ---------------------------------------------------------------------------
441
+ // Fog overlay mesh
442
+ // ---------------------------------------------------------------------------
443
+ /**
444
+ * Create a fog overlay sphere slightly larger than the game sphere.
445
+ * Individual cell fog is handled by modifying cell material opacity,
446
+ * but this provides a global atmospheric effect.
447
+ */
448
+ export function buildFogOverlay(radius, fogColor = 'rgba(0,0,0,0.5)') {
449
+ const geo = new THREE.SphereGeometry(radius + 0.01, 64, 32);
450
+ const mat = new THREE.MeshBasicMaterial({
451
+ color: new THREE.Color(fogColor.replace(/rgba?\([^)]+\)/, '#000000')),
452
+ transparent: true,
453
+ opacity: 0.0, // controlled per-cell via cell materials
454
+ side: THREE.BackSide,
455
+ depthWrite: false,
456
+ });
457
+ const mesh = new THREE.Mesh(geo, mat);
458
+ mesh.name = 'fog-overlay';
459
+ return mesh;
460
+ }
461
+ // ---------------------------------------------------------------------------
462
+ // Highlight ring
463
+ // ---------------------------------------------------------------------------
464
+ /**
465
+ * Build a glowing ring around a cell for highlights (attack target, selection, etc.)
466
+ */
467
+ export function buildHighlightRing(center, radius, ringRadius = 0.15, color = '#ffffff', intensity = 1.0) {
468
+ const normal = new THREE.Vector3(center.x, center.y, center.z).normalize();
469
+ const pos = normal.clone().multiplyScalar(radius + 0.02);
470
+ const geo = new THREE.TorusGeometry(ringRadius, 0.015, 8, 32);
471
+ const mat = new THREE.MeshBasicMaterial({
472
+ color: new THREE.Color(color),
473
+ transparent: true,
474
+ opacity: 0.6 * intensity,
475
+ });
476
+ const mesh = new THREE.Mesh(geo, mat);
477
+ mesh.position.copy(pos);
478
+ // Orient ring flat on sphere surface
479
+ const up = new THREE.Vector3(0, 0, 1); // torus default normal
480
+ const quat = new THREE.Quaternion().setFromUnitVectors(up, normal);
481
+ mesh.quaternion.copy(quat);
482
+ mesh.name = 'highlight-ring';
483
+ return mesh;
484
+ }
485
+ /**
486
+ * Create an attack trail particle system between two cells on the sphere.
487
+ * Returns a Points object and an update function.
488
+ */
489
+ export function buildAttackTrail(config) {
490
+ const { fromCenter, toCenter, sphereRadius, color = '#ff4444', particleCount = 20, } = config;
491
+ const from = new THREE.Vector3(fromCenter.x, fromCenter.y, fromCenter.z)
492
+ .normalize()
493
+ .multiplyScalar(sphereRadius + 0.05);
494
+ const to = new THREE.Vector3(toCenter.x, toCenter.y, toCenter.z)
495
+ .normalize()
496
+ .multiplyScalar(sphereRadius + 0.05);
497
+ // Arc midpoint lifted above sphere
498
+ const mid = from.clone().add(to).multiplyScalar(0.5);
499
+ const midNormal = mid.clone().normalize();
500
+ const arcHeight = from.distanceTo(to) * 0.3;
501
+ mid.copy(midNormal.multiplyScalar(sphereRadius + arcHeight));
502
+ const positions = new Float32Array(particleCount * 3);
503
+ const geo = new THREE.BufferGeometry();
504
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
505
+ const mat = new THREE.PointsMaterial({
506
+ color: new THREE.Color(color),
507
+ size: 0.06,
508
+ transparent: true,
509
+ opacity: 0.9,
510
+ depthWrite: false,
511
+ });
512
+ const points = new THREE.Points(geo, mat);
513
+ points.name = 'attack-trail';
514
+ const update = (progress) => {
515
+ const posAttr = geo.getAttribute('position');
516
+ for (let i = 0; i < particleCount; i++) {
517
+ const t = Math.max(0, Math.min(1, progress - i * 0.03));
518
+ // Quadratic Bezier: from → mid → to
519
+ const p = new THREE.Vector3();
520
+ p.x = (1 - t) * (1 - t) * from.x + 2 * (1 - t) * t * mid.x + t * t * to.x;
521
+ p.y = (1 - t) * (1 - t) * from.y + 2 * (1 - t) * t * mid.y + t * t * to.y;
522
+ p.z = (1 - t) * (1 - t) * from.z + 2 * (1 - t) * t * mid.z + t * t * to.z;
523
+ posAttr.setXYZ(i, p.x, p.y, p.z);
524
+ }
525
+ posAttr.needsUpdate = true;
526
+ mat.opacity = Math.max(0, 1 - progress * 0.5);
527
+ };
528
+ const dispose = () => {
529
+ geo.dispose();
530
+ mat.dispose();
531
+ };
532
+ return { points, update, dispose };
533
+ }
534
+ /**
535
+ * Create an orbital strike projectile that falls from high above to a cell.
536
+ * Returns the mesh, update function, and disposal.
537
+ */
538
+ export function buildOrbitalStrike(config) {
539
+ const { targetCenter, sphereRadius, color = '#ff8800', trailColor = '#ffaa44' } = config;
540
+ const normal = new THREE.Vector3(targetCenter.x, targetCenter.y, targetCenter.z).normalize();
541
+ const surfacePos = normal.clone().multiplyScalar(sphereRadius);
542
+ const startPos = normal.clone().multiplyScalar(sphereRadius * 3); // Start far above
543
+ const group = new THREE.Group();
544
+ group.name = 'orbital-strike';
545
+ // Projectile
546
+ const projGeo = new THREE.ConeGeometry(0.08, 0.3, 8);
547
+ const projMat = new THREE.MeshStandardMaterial({
548
+ color: new THREE.Color(color),
549
+ emissive: new THREE.Color(color),
550
+ emissiveIntensity: 1.5,
551
+ });
552
+ const projectile = new THREE.Mesh(projGeo, projMat);
553
+ projectile.name = 'projectile';
554
+ group.add(projectile);
555
+ // Trail particles
556
+ const trailCount = 30;
557
+ const trailPositions = new Float32Array(trailCount * 3);
558
+ const trailGeo = new THREE.BufferGeometry();
559
+ trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3));
560
+ const trailMat = new THREE.PointsMaterial({
561
+ color: new THREE.Color(trailColor),
562
+ size: 0.04,
563
+ transparent: true,
564
+ opacity: 0.7,
565
+ depthWrite: false,
566
+ });
567
+ const trail = new THREE.Points(trailGeo, trailMat);
568
+ trail.name = 'trail';
569
+ group.add(trail);
570
+ const update = (progress) => {
571
+ // Lerp projectile position
572
+ const pos = startPos.clone().lerp(surfacePos, progress);
573
+ projectile.position.copy(pos);
574
+ // Orient cone downward toward surface
575
+ const dir = surfacePos.clone().sub(pos).normalize();
576
+ const up = new THREE.Vector3(0, -1, 0);
577
+ const quat = new THREE.Quaternion().setFromUnitVectors(up, dir);
578
+ projectile.quaternion.copy(quat);
579
+ // Scale glow as it approaches
580
+ projMat.emissiveIntensity = 0.5 + progress * 2;
581
+ // Trail
582
+ const trailAttr = trailGeo.getAttribute('position');
583
+ for (let i = 0; i < trailCount; i++) {
584
+ const t = Math.max(0, progress - i * 0.02);
585
+ const tp = startPos.clone().lerp(surfacePos, t);
586
+ // Add slight random jitter
587
+ tp.x += (Math.random() - 0.5) * 0.03;
588
+ tp.y += (Math.random() - 0.5) * 0.03;
589
+ tp.z += (Math.random() - 0.5) * 0.03;
590
+ trailAttr.setXYZ(i, tp.x, tp.y, tp.z);
591
+ }
592
+ trailAttr.needsUpdate = true;
593
+ trailMat.opacity = Math.max(0, 0.7 - progress * 0.3);
594
+ };
595
+ const dispose = () => {
596
+ projGeo.dispose();
597
+ projMat.dispose();
598
+ trailGeo.dispose();
599
+ trailMat.dispose();
600
+ };
601
+ return { group, update, dispose };
602
+ }
603
+ // ---------------------------------------------------------------------------
604
+ // Utility: Dispose all geometries and materials in a group
605
+ // ---------------------------------------------------------------------------
606
+ export function disposePieceGroup(group) {
607
+ group.traverse((obj) => {
608
+ if (obj instanceof THREE.Mesh) {
609
+ // Don't dispose cached geometries
610
+ if (!geometryCache.has(obj.geometry?.type ?? '')) {
611
+ obj.geometry?.dispose();
612
+ }
613
+ if (obj.material instanceof THREE.Material) {
614
+ obj.material.dispose();
615
+ }
616
+ else if (Array.isArray(obj.material)) {
617
+ obj.material.forEach((m) => m.dispose());
618
+ }
619
+ }
620
+ if (obj instanceof THREE.Sprite) {
621
+ obj.material.map?.dispose();
622
+ obj.material.dispose();
623
+ }
624
+ if (obj instanceof THREE.Points) {
625
+ obj.geometry?.dispose();
626
+ obj.material.dispose();
627
+ }
628
+ if (obj instanceof THREE.LineLoop || obj instanceof THREE.Line) {
629
+ obj.geometry?.dispose();
630
+ obj.material.dispose();
631
+ }
632
+ });
633
+ }
634
+ // ---------------------------------------------------------------------------
635
+ // Cell state application helper
636
+ // ---------------------------------------------------------------------------
637
+ /**
638
+ * Apply CellGameState to a cell's mesh and border.
639
+ * Updates color, opacity (fog), and highlight state.
640
+ */
641
+ export function applyCellState(cellMesh, cellBorder, state, config) {
642
+ const mat = cellMesh.material;
643
+ // Owner color
644
+ if (state.ownerColor) {
645
+ const intensity = state.ownerColorIntensity ?? 0.7;
646
+ const baseColor = mat.color.clone();
647
+ const ownerColor = new THREE.Color(state.ownerColor);
648
+ mat.color.lerpColors(baseColor, ownerColor, intensity);
649
+ }
650
+ // Terrain color override
651
+ if (state.terrainColor) {
652
+ mat.color.set(state.terrainColor);
653
+ }
654
+ // Fog of war
655
+ switch (state.fogLevel) {
656
+ case 'hidden':
657
+ mat.opacity = config?.fogHiddenOpacity ?? 0.15;
658
+ mat.transparent = true;
659
+ break;
660
+ case 'dim':
661
+ mat.opacity = config?.fogDimOpacity ?? 0.4;
662
+ mat.transparent = true;
663
+ break;
664
+ case 'explored':
665
+ mat.opacity = config?.fogExploredOpacity ?? 0.65;
666
+ mat.transparent = true;
667
+ break;
668
+ case 'visible':
669
+ default:
670
+ mat.opacity = 1.0;
671
+ mat.transparent = false;
672
+ break;
673
+ }
674
+ // Border color from state
675
+ if (cellBorder && state.border) {
676
+ const borderMat = cellBorder.material;
677
+ borderMat.color.set(state.border.color);
678
+ if (state.border.emissive) {
679
+ // Swap to emissive-capable material would be needed; for line we just brighten
680
+ borderMat.color.offsetHSL(0, 0, 0.3);
681
+ }
682
+ }
683
+ // Elevation
684
+ if (state.elevation && state.elevation > 0) {
685
+ const normal = cellMesh.position.clone().normalize();
686
+ cellMesh.position.copy(normal.multiplyScalar(cellMesh.position.length() + state.elevation));
687
+ }
688
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * GameSphere — Full Three.js 3D board game renderer on a geodesic sphere.
3
+ *
4
+ * Renders hex/pentagon cells as colored polygons on a sphere mesh, places
5
+ * literal 3D game pieces (primitives, custom Object3D, GLTF models) on cells,
6
+ * handles fog of war, highlights, raycasting for click/hover, orbit camera,
7
+ * and piece animations. This is THE platform for any board game on a sphere.
8
+ */
9
+ import React from 'react';
10
+ import type { GameSphereProps } from '../types';
11
+ export declare const GameSphere: React.FC<GameSphereProps>;
12
+ export { GeodesicHexGrid } from '../math/HexCoordinates';
13
+ //# sourceMappingURL=GameSphere.d.ts.map