@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,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
|