@almadar/ui 5.107.0 → 5.109.0

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.
Files changed (83) hide show
  1. package/dist/avl/index.cjs +1427 -2828
  2. package/dist/avl/index.js +525 -1926
  3. package/dist/components/core/atoms/Box.d.ts +2 -0
  4. package/dist/components/{game → core}/atoms/GameIcon.d.ts +1 -1
  5. package/dist/components/core/atoms/index.d.ts +1 -0
  6. package/dist/components/core/templates/index.d.ts +0 -1
  7. package/dist/components/game/molecules/index.d.ts +8 -28
  8. package/dist/components/index.cjs +223 -1691
  9. package/dist/components/index.js +213 -1671
  10. package/dist/lib/drawable/projector.d.ts +1 -1
  11. package/dist/{components/game/three/molecules/GameCanvas3D.d.ts → lib/drawable/three/Canvas3DHost.d.ts} +12 -9
  12. package/dist/lib/drawable/{Drawable3D.d.ts → three/Drawable3D.d.ts} +2 -2
  13. package/dist/lib/drawable/three/GameCamera3D.d.ts +16 -0
  14. package/dist/lib/drawable/three/game3dTheme.d.ts +14 -0
  15. package/dist/{components/game → lib/drawable}/three/hooks/useGameCanvas3DEvents.d.ts +1 -1
  16. package/dist/{components/game → lib/drawable}/three/index.cjs +2019 -2020
  17. package/dist/{components/game → lib/drawable}/three/index.css +3 -3
  18. package/dist/lib/drawable/three/index.d.ts +39 -0
  19. package/dist/{components/game → lib/drawable}/three/index.js +1932 -1934
  20. package/dist/lib/drawable/{mesh3d.d.ts → three/mesh3d.d.ts} +4 -4
  21. package/dist/{components/game/lib → lib}/puzzleObject.d.ts +11 -2
  22. package/dist/marketing/index.d.cts +2 -0
  23. package/dist/{components/game/providers → providers}/GameAudioProvider.d.ts +1 -1
  24. package/dist/providers/index.cjs +1619 -2690
  25. package/dist/providers/index.d.ts +2 -0
  26. package/dist/providers/index.js +827 -1902
  27. package/dist/runtime/index.cjs +1288 -2689
  28. package/dist/runtime/index.js +505 -1906
  29. package/package.json +7 -7
  30. package/dist/components/game/atoms/ActionButton.d.ts +0 -31
  31. package/dist/components/game/atoms/ComboCounter.d.ts +0 -20
  32. package/dist/components/game/atoms/DamageNumber.d.ts +0 -18
  33. package/dist/components/game/atoms/GameCard.d.ts +0 -34
  34. package/dist/components/game/atoms/ItemSlot.d.ts +0 -31
  35. package/dist/components/game/atoms/ResourceCounter.d.ts +0 -26
  36. package/dist/components/game/atoms/Sprite.d.ts +0 -52
  37. package/dist/components/game/atoms/StatusEffect.d.ts +0 -25
  38. package/dist/components/game/atoms/TurnIndicator.d.ts +0 -22
  39. package/dist/components/game/atoms/WaypointMarker.d.ts +0 -23
  40. package/dist/components/game/molecules/EventLog.d.ts +0 -31
  41. package/dist/components/game/molecules/InventoryGrid.d.ts +0 -35
  42. package/dist/components/game/molecules/ObjectRulePanel.d.ts +0 -32
  43. package/dist/components/game/molecules/ResourceBar.d.ts +0 -26
  44. package/dist/components/game/molecules/RuleEditor.d.ts +0 -39
  45. package/dist/components/game/molecules/VariablePanel.d.ts +0 -28
  46. package/dist/components/game/templates/GameTemplate.d.ts +0 -35
  47. package/dist/components/game/three/atoms/SpriteBillboard3D.d.ts +0 -18
  48. package/dist/components/game/three/game3dTheme.d.ts +0 -55
  49. package/dist/components/game/three/index.d.ts +0 -12
  50. package/dist/components/game/three/lib/game3dAssetManifest.d.ts +0 -48
  51. package/dist/components/game/three/molecules/GameCamera3D.d.ts +0 -35
  52. package/dist/components/game/three/molecules/index.d.ts +0 -32
  53. package/dist/components/game/three/patterns.d.ts +0 -16
  54. /package/dist/{components/game/hooks → hooks}/useCamera.d.ts +0 -0
  55. /package/dist/{components/game/hooks → hooks}/useGameAudio.d.ts +0 -0
  56. /package/dist/{components/game/hooks → hooks}/useImageCache.d.ts +0 -0
  57. /package/dist/{components/game/hooks → hooks}/useUnitSpriteAtlas.d.ts +0 -0
  58. /package/dist/{components/game/lib → lib}/boardEntity.d.ts +0 -0
  59. /package/dist/{components/game/three/molecules → lib/drawable/three}/Camera3D.d.ts +0 -0
  60. /package/dist/{components/game/three/molecules → lib/drawable/three}/Canvas3DErrorBoundary.d.ts +0 -0
  61. /package/dist/{components/game/three/molecules → lib/drawable/three}/Canvas3DLoadingState.d.ts +0 -0
  62. /package/dist/{components/game/three/molecules → lib/drawable/three}/Lighting3D.d.ts +0 -0
  63. /package/dist/{components/game/three/molecules → lib/drawable/three}/ModelLoader.d.ts +0 -0
  64. /package/dist/{components/game/three/molecules → lib/drawable/three}/Scene3D.d.ts +0 -0
  65. /package/dist/{components/game → lib/drawable}/three/hooks/useAssetLoader.d.ts +0 -0
  66. /package/dist/{components/game → lib/drawable}/three/hooks/useRaycaster.d.ts +0 -0
  67. /package/dist/{components/game → lib/drawable}/three/hooks/useSceneGraph.d.ts +0 -0
  68. /package/dist/{components/game → lib/drawable}/three/hooks/useThree.d.ts +0 -0
  69. /package/dist/{components/game → lib/drawable}/three/lib/AssetLoader.d.ts +0 -0
  70. /package/dist/{components/game → lib/drawable}/three/lib/culling.d.ts +0 -0
  71. /package/dist/{components/game → lib/drawable}/three/lib/grid3D.d.ts +0 -0
  72. /package/dist/{components/game/lib → lib}/editorUtils.d.ts +0 -0
  73. /package/dist/{components/game/lib → lib}/game.d.ts +0 -0
  74. /package/dist/{components/game/lib/index.d.ts → lib/gameShared.d.ts} +0 -0
  75. /package/dist/{components/game/lib → lib}/isometric.d.ts +0 -0
  76. /package/dist/{components/game/lib → lib}/isometricTypes.d.ts +0 -0
  77. /package/dist/{components/game/lib → lib}/makeAsset.d.ts +0 -0
  78. /package/dist/{components/game/lib → lib}/mechanics.d.ts +0 -0
  79. /package/dist/{components/game/lib → lib}/physicsPresets.d.ts +0 -0
  80. /package/dist/{components/game/lib → lib}/physicsTypes.d.ts +0 -0
  81. /package/dist/{components/game/lib → lib}/spriteAnimation.d.ts +0 -0
  82. /package/dist/{components/game/lib → lib}/spriteAnimationTypes.d.ts +0 -0
  83. /package/dist/{components/game/lib → lib}/spriteSheetConstants.d.ts +0 -0
@@ -1,195 +1,307 @@
1
- import React7, { forwardRef, useRef, useEffect, useImperativeHandle, useState, useMemo, useCallback, createContext, useContext, Component, useReducer } from 'react';
2
- import { useThree, useFrame, Canvas, useLoader } from '@react-three/fiber';
3
- import * as THREE6 from 'three';
1
+ import React6, { forwardRef, useRef, useState, useEffect, useMemo, useImperativeHandle, useCallback, createContext, useContext, Component, useReducer } from 'react';
2
+ import { EventBusContext, useTraitScope } from '@almadar/ui/providers';
3
+ import { createLogger } from '@almadar/logger';
4
+ import { Canvas, useThree, useFrame, useLoader } from '@react-three/fiber';
5
+ import * as THREE9 from 'three';
4
6
  import { Vector3, QuadraticBezierCurve3, MathUtils, Quaternion } from 'three';
7
+ import { Grid, OrbitControls, Billboard, Text, Stars, Sparkles, Html, RoundedBox } from '@react-three/drei';
5
8
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
6
- import { OrbitControls, Grid, Billboard, Text, Stars, Sparkles, Html, RoundedBox } from '@react-three/drei';
7
- import { createLogger } from '@almadar/logger';
8
9
  import { GLTFLoader as GLTFLoader$1 } from 'three/examples/jsm/loaders/GLTFLoader';
9
10
  import { clone } from 'three/examples/jsm/utils/SkeletonUtils';
11
+ import { clsx } from 'clsx';
12
+ import { twMerge } from 'tailwind-merge';
10
13
  import { OrbitControls as OrbitControls$1 } from 'three/examples/jsm/controls/OrbitControls.js';
11
14
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
12
15
  import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
13
- import { EventBusContext, useTraitScope } from '@almadar/ui/providers';
14
- import { clsx } from 'clsx';
15
- import { twMerge } from 'tailwind-merge';
16
16
  import { EffectComposer, Bloom, DepthOfField, Vignette } from '@react-three/postprocessing';
17
17
  import { useTranslate } from '@almadar/ui/hooks';
18
18
 
19
19
  var __defProp = Object.defineProperty;
20
20
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
21
21
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
22
- function Scene3D({ background = "#1a1a2e", fog, children }) {
23
- const { scene } = useThree();
24
- const initializedRef = useRef(false);
25
- useEffect(() => {
26
- if (initializedRef.current) return;
27
- initializedRef.current = true;
28
- if (background.startsWith("#") || background.startsWith("rgb")) {
29
- scene.background = new THREE6.Color(background);
30
- } else {
31
- const loader = new THREE6.TextureLoader();
32
- loader.load(background, (texture) => {
33
- scene.background = texture;
22
+ var log = createLogger("almadar:eventbus");
23
+ var subLog = createLogger("almadar:eventbus:subscribe");
24
+ var scopeLog = createLogger("almadar:ui:trait-scope");
25
+ function getGlobalEventBus() {
26
+ if (typeof window !== "undefined") {
27
+ return window.__kflowEventBus ?? null;
28
+ }
29
+ return null;
30
+ }
31
+ var fallbackListeners = /* @__PURE__ */ new Map();
32
+ var fallbackAnyListeners = /* @__PURE__ */ new Set();
33
+ var fallbackEventBus = {
34
+ emit: (type, payload, source) => {
35
+ const event = {
36
+ type,
37
+ payload,
38
+ timestamp: Date.now(),
39
+ source
40
+ };
41
+ const handlers = fallbackListeners.get(type);
42
+ log.debug("emit", { type, payloadKeys: payload ? Object.keys(payload).length : 0, listenerCount: (handlers?.size ?? 0) + fallbackAnyListeners.size });
43
+ if (handlers) {
44
+ handlers.forEach((handler) => {
45
+ try {
46
+ handler(event);
47
+ } catch (error) {
48
+ log.error("Error in listener", { type, error: error instanceof Error ? error : String(error) });
49
+ }
34
50
  });
35
51
  }
36
- if (fog) {
37
- scene.fog = new THREE6.Fog(fog.color, fog.near, fog.far);
38
- }
39
- return () => {
40
- scene.background = null;
41
- scene.fog = null;
42
- };
43
- }, [scene, background, fog]);
44
- return /* @__PURE__ */ jsx(Fragment, { children });
45
- }
46
- var Camera3D = forwardRef(
47
- ({
48
- mode = "isometric",
49
- position = [10, 10, 10],
50
- target = [0, 0, 0],
51
- zoom = 1,
52
- fov = 45,
53
- enableOrbit = true,
54
- minDistance = 2,
55
- maxDistance = 100,
56
- onChange
57
- }, ref) => {
58
- const { camera, set, viewport } = useThree();
59
- const controlsRef = useRef(null);
60
- const initialPosition = useRef(new THREE6.Vector3(...position));
61
- const initialTarget = useRef(new THREE6.Vector3(...target));
62
- useEffect(() => {
63
- let newCamera;
64
- if (mode === "isometric") {
65
- const aspect = viewport.aspect;
66
- const size = 10 / zoom;
67
- newCamera = new THREE6.OrthographicCamera(
68
- -size * aspect,
69
- size * aspect,
70
- size,
71
- -size,
72
- 0.1,
73
- 1e3
74
- );
75
- } else {
76
- newCamera = new THREE6.PerspectiveCamera(fov, viewport.aspect, 0.1, 1e3);
77
- }
78
- newCamera.position.copy(initialPosition.current);
79
- newCamera.lookAt(initialTarget.current.x, initialTarget.current.y, initialTarget.current.z);
80
- set({ camera: newCamera });
81
- if (mode === "top-down") {
82
- newCamera.position.set(0, 20 / zoom, 0);
83
- newCamera.lookAt(0, 0, 0);
84
- }
85
- return () => {
86
- };
87
- }, [mode, fov, zoom, viewport.aspect, set]);
88
- useFrame(() => {
89
- if (onChange) {
90
- onChange(camera);
52
+ fallbackAnyListeners.forEach((handler) => {
53
+ try {
54
+ handler(event);
55
+ } catch (error) {
56
+ log.error("Error in onAny listener", { type, error: error instanceof Error ? error : String(error) });
91
57
  }
92
58
  });
93
- useImperativeHandle(ref, () => ({
94
- getCamera: () => camera,
95
- setPosition: (x, y, z) => {
96
- camera.position.set(x, y, z);
97
- if (controlsRef.current) {
98
- controlsRef.current.update();
59
+ },
60
+ on: (type, listener) => {
61
+ if (!fallbackListeners.has(type)) {
62
+ fallbackListeners.set(type, /* @__PURE__ */ new Set());
63
+ }
64
+ fallbackListeners.get(type).add(listener);
65
+ subLog.debug("subscribe", { type, totalListeners: fallbackListeners.get(type).size });
66
+ return () => {
67
+ const handlers = fallbackListeners.get(type);
68
+ if (handlers) {
69
+ handlers.delete(listener);
70
+ if (handlers.size === 0) {
71
+ fallbackListeners.delete(type);
99
72
  }
100
- },
101
- lookAt: (x, y, z) => {
102
- camera.lookAt(x, y, z);
103
- if (controlsRef.current) {
104
- controlsRef.current.target.set(x, y, z);
105
- controlsRef.current.update();
73
+ }
74
+ };
75
+ },
76
+ once: (type, listener) => {
77
+ const wrappedListener = (event) => {
78
+ fallbackListeners.get(type)?.delete(wrappedListener);
79
+ listener(event);
80
+ };
81
+ return fallbackEventBus.on(type, wrappedListener);
82
+ },
83
+ hasListeners: (type) => {
84
+ const handlers = fallbackListeners.get(type);
85
+ return handlers !== void 0 && handlers.size > 0;
86
+ },
87
+ onAny: (listener) => {
88
+ fallbackAnyListeners.add(listener);
89
+ subLog.debug("subscribe:any", { totalAnyListeners: fallbackAnyListeners.size });
90
+ return () => {
91
+ fallbackAnyListeners.delete(listener);
92
+ };
93
+ }
94
+ };
95
+ function useEventBus() {
96
+ const context = useContext(EventBusContext);
97
+ const baseBus = context ?? getGlobalEventBus() ?? fallbackEventBus;
98
+ const scope = useTraitScope();
99
+ return useMemo(() => {
100
+ if (!scope) {
101
+ return {
102
+ ...baseBus,
103
+ emit: (type, payload, source) => {
104
+ if (typeof type === "string" && type.startsWith("UI:") && !type.slice(3).includes(".")) {
105
+ scopeLog.warn("emit:bare-key-no-scope", { type });
106
+ }
107
+ baseBus.emit(type, payload, source);
106
108
  }
107
- },
108
- reset: () => {
109
- camera.position.copy(initialPosition.current);
110
- camera.lookAt(initialTarget.current.x, initialTarget.current.y, initialTarget.current.z);
111
- if (controlsRef.current) {
112
- controlsRef.current.target.copy(initialTarget.current);
113
- controlsRef.current.update();
109
+ };
110
+ }
111
+ return {
112
+ ...baseBus,
113
+ emit: (type, payload, source) => {
114
+ if (typeof type === "string" && type.startsWith("UI:")) {
115
+ const tail = type.slice(3);
116
+ const qualified = tail.includes(".") ? type : `UI:${scope.orbital}.${scope.trait}.${tail}`;
117
+ if (qualified !== type) {
118
+ scopeLog.info("emit:qualified", {
119
+ from: type,
120
+ to: qualified,
121
+ scopeOrbital: scope.orbital,
122
+ scopeTrait: scope.trait
123
+ });
124
+ }
125
+ baseBus.emit(qualified, payload, source);
126
+ return;
114
127
  }
115
- },
116
- getViewBounds: () => {
117
- const min = new THREE6.Vector3(-10, -10, -10);
118
- const max = new THREE6.Vector3(10, 10, 10);
119
- return { min, max };
120
- }
121
- }));
122
- const maxPolarAngle = mode === "top-down" ? 0.1 : Math.PI / 2 - 0.1;
123
- return /* @__PURE__ */ jsx(
124
- OrbitControls,
125
- {
126
- ref: controlsRef,
127
- camera,
128
- enabled: enableOrbit,
129
- target: [initialTarget.current.x, initialTarget.current.y, initialTarget.current.z],
130
- minDistance,
131
- maxDistance,
132
- maxPolarAngle,
133
- enableDamping: true,
134
- dampingFactor: 0.05
128
+ baseBus.emit(type, payload, source);
135
129
  }
136
- );
130
+ };
131
+ }, [baseBus, scope]);
132
+ }
133
+ function useEmitEvent() {
134
+ const eventBus = useEventBus();
135
+ return useCallback(
136
+ (type, payload, source) => {
137
+ eventBus.emit(type, payload, source);
138
+ },
139
+ [eventBus]
140
+ );
141
+ }
142
+
143
+ // lib/drawable/hitTest.ts
144
+ function collectDrawnItems(nodes) {
145
+ const out = [];
146
+ for (const n of nodes) {
147
+ switch (n.type) {
148
+ case "draw-sprite":
149
+ case "draw-shape":
150
+ case "draw-text":
151
+ out.push({ pos: n.position, id: n.id });
152
+ break;
153
+ case "draw-sprite-layer":
154
+ case "draw-shape-layer":
155
+ case "draw-text-layer":
156
+ for (const it of n.items) out.push({ pos: it.position, id: it.id });
157
+ break;
158
+ }
137
159
  }
138
- );
139
- Camera3D.displayName = "Camera3D";
140
- function Lighting3D({
141
- ambientIntensity = 0.6,
142
- ambientColor = "#ffffff",
143
- directionalIntensity = 0.8,
144
- directionalColor = "#ffffff",
145
- directionalPosition = [10, 20, 10],
146
- shadows = true,
147
- shadowMapSize = 2048,
148
- shadowCameraSize = 20,
149
- shadowBias = -1e-3,
150
- shadowNormalBias,
151
- shadowCameraNear = 0.1,
152
- shadowCameraFar = 100,
153
- showHelpers = false
154
- }) {
155
- return /* @__PURE__ */ jsxs(Fragment, { children: [
156
- /* @__PURE__ */ jsx("ambientLight", { intensity: ambientIntensity, color: ambientColor }),
157
- /* @__PURE__ */ jsx(
158
- "directionalLight",
159
- {
160
- position: directionalPosition,
161
- intensity: directionalIntensity,
162
- color: directionalColor,
163
- castShadow: shadows,
164
- "shadow-mapSize": [shadowMapSize, shadowMapSize],
165
- "shadow-camera-left": -shadowCameraSize,
166
- "shadow-camera-right": shadowCameraSize,
167
- "shadow-camera-top": shadowCameraSize,
168
- "shadow-camera-bottom": -shadowCameraSize,
169
- "shadow-camera-near": shadowCameraNear,
170
- "shadow-camera-far": shadowCameraFar,
171
- "shadow-bias": shadowBias,
172
- "shadow-normalBias": shadowNormalBias
160
+ return out;
161
+ }
162
+ function buildHitIndex(items) {
163
+ const m = /* @__PURE__ */ new Map();
164
+ for (const it of items) {
165
+ if (it.id !== void 0) m.set(`${it.pos.x},${it.pos.y}`, it.id);
166
+ }
167
+ return m;
168
+ }
169
+ function useGameCanvas3DEvents(options) {
170
+ const {
171
+ tileClickEvent,
172
+ unitClickEvent,
173
+ featureClickEvent,
174
+ canvasClickEvent,
175
+ tileHoverEvent,
176
+ tileLeaveEvent,
177
+ unitAnimationEvent,
178
+ cameraChangeEvent,
179
+ onTileClick,
180
+ onUnitClick,
181
+ onFeatureClick,
182
+ onCanvasClick,
183
+ onTileHover,
184
+ onUnitAnimation
185
+ } = options;
186
+ const emit = useEmitEvent();
187
+ const optionsRef = useRef(options);
188
+ optionsRef.current = options;
189
+ const handleTileClick = useCallback(
190
+ (tile, event) => {
191
+ if (tileClickEvent) {
192
+ emit(tileClickEvent, {
193
+ tileId: tile.id,
194
+ x: tile.x,
195
+ z: tile.z ?? tile.y ?? 0,
196
+ type: tile.type,
197
+ terrain: tile.terrain,
198
+ elevation: tile.elevation
199
+ });
173
200
  }
174
- ),
175
- /* @__PURE__ */ jsx(
176
- "hemisphereLight",
177
- {
178
- intensity: 0.3,
179
- color: "#87ceeb",
180
- groundColor: "#362d1d"
201
+ optionsRef.current.onTileClick?.(tile, event);
202
+ },
203
+ [tileClickEvent, emit]
204
+ );
205
+ const handleUnitClick = useCallback(
206
+ (unit, event) => {
207
+ if (unitClickEvent) {
208
+ emit(unitClickEvent, {
209
+ unitId: unit.id,
210
+ x: unit.x,
211
+ z: unit.z ?? unit.y ?? 0,
212
+ unitType: unit.unitType,
213
+ name: unit.name,
214
+ team: unit.team,
215
+ faction: unit.faction,
216
+ health: unit.health,
217
+ maxHealth: unit.maxHealth
218
+ });
181
219
  }
182
- ),
183
- showHelpers && /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx(
184
- "directionalLightHelper",
185
- {
186
- args: [
187
- new THREE6.DirectionalLight(directionalColor, directionalIntensity),
188
- 5
189
- ]
220
+ optionsRef.current.onUnitClick?.(unit, event);
221
+ },
222
+ [unitClickEvent, emit]
223
+ );
224
+ const handleFeatureClick = useCallback(
225
+ (feature, event) => {
226
+ if (featureClickEvent) {
227
+ emit(featureClickEvent, {
228
+ featureId: feature.id,
229
+ x: feature.x,
230
+ z: feature.z ?? feature.y ?? 0,
231
+ type: feature.type,
232
+ elevation: feature.elevation
233
+ });
190
234
  }
191
- ) })
192
- ] });
235
+ optionsRef.current.onFeatureClick?.(feature, event);
236
+ },
237
+ [featureClickEvent, emit]
238
+ );
239
+ const handleCanvasClick = useCallback(
240
+ (event) => {
241
+ if (canvasClickEvent) {
242
+ emit(canvasClickEvent, {
243
+ clientX: event.clientX,
244
+ clientY: event.clientY,
245
+ button: event.button
246
+ });
247
+ }
248
+ optionsRef.current.onCanvasClick?.(event);
249
+ },
250
+ [canvasClickEvent, emit]
251
+ );
252
+ const handleTileHover = useCallback(
253
+ (tile, event) => {
254
+ if (tile) {
255
+ if (tileHoverEvent) {
256
+ emit(tileHoverEvent, {
257
+ tileId: tile.id,
258
+ x: tile.x,
259
+ z: tile.z ?? tile.y ?? 0,
260
+ type: tile.type
261
+ });
262
+ }
263
+ } else {
264
+ if (tileLeaveEvent) {
265
+ emit(tileLeaveEvent, {});
266
+ }
267
+ }
268
+ optionsRef.current.onTileHover?.(tile, event);
269
+ },
270
+ [tileHoverEvent, tileLeaveEvent, emit]
271
+ );
272
+ const handleUnitAnimation = useCallback(
273
+ (unitId, state) => {
274
+ if (unitAnimationEvent) {
275
+ emit(unitAnimationEvent, {
276
+ unitId,
277
+ state,
278
+ timestamp: Date.now()
279
+ });
280
+ }
281
+ optionsRef.current.onUnitAnimation?.(unitId, state);
282
+ },
283
+ [unitAnimationEvent, emit]
284
+ );
285
+ const handleCameraChange = useCallback(
286
+ (position) => {
287
+ if (cameraChangeEvent) {
288
+ emit(cameraChangeEvent, {
289
+ position,
290
+ timestamp: Date.now()
291
+ });
292
+ }
293
+ },
294
+ [cameraChangeEvent, emit]
295
+ );
296
+ return {
297
+ handleTileClick,
298
+ handleUnitClick,
299
+ handleFeatureClick,
300
+ handleCanvasClick,
301
+ handleTileHover,
302
+ handleUnitAnimation,
303
+ handleCameraChange
304
+ };
193
305
  }
194
306
  function Canvas3DLoadingState({
195
307
  progress = 0,
@@ -236,7 +348,7 @@ function Canvas3DLoadingState({
236
348
  /* @__PURE__ */ jsx("div", { className: "canvas-3d-loading__background", children: /* @__PURE__ */ jsx("div", { className: "bg__grid" }) })
237
349
  ] });
238
350
  }
239
- var log = createLogger("almadar:ui:game:canvas3d:error-boundary");
351
+ var log2 = createLogger("almadar:ui:game:canvas3d:error-boundary");
240
352
  var Canvas3DErrorBoundary = class extends Component {
241
353
  constructor(props) {
242
354
  super(props);
@@ -264,8 +376,8 @@ var Canvas3DErrorBoundary = class extends Component {
264
376
  componentDidCatch(error, errorInfo) {
265
377
  this.setState({ errorInfo });
266
378
  this.props.onError?.(error, errorInfo);
267
- log.error("Error caught", { error });
268
- log.error("Component stack", { componentStack: errorInfo.componentStack ?? "<none>" });
379
+ log2.error("Error caught", { error });
380
+ log2.error("Component stack", { componentStack: errorInfo.componentStack ?? "<none>" });
269
381
  }
270
382
  render() {
271
383
  if (this.state.hasError) {
@@ -308,7 +420,92 @@ var Canvas3DErrorBoundary = class extends Component {
308
420
  return this.props.children;
309
421
  }
310
422
  };
311
- var log2 = createLogger("almadar:ui:game:model-loader");
423
+ function Lighting3D({
424
+ ambientIntensity = 0.6,
425
+ ambientColor = "#ffffff",
426
+ directionalIntensity = 0.8,
427
+ directionalColor = "#ffffff",
428
+ directionalPosition = [10, 20, 10],
429
+ shadows = true,
430
+ shadowMapSize = 2048,
431
+ shadowCameraSize = 20,
432
+ shadowBias = -1e-3,
433
+ shadowNormalBias,
434
+ shadowCameraNear = 0.1,
435
+ shadowCameraFar = 100,
436
+ showHelpers = false
437
+ }) {
438
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
439
+ /* @__PURE__ */ jsx("ambientLight", { intensity: ambientIntensity, color: ambientColor }),
440
+ /* @__PURE__ */ jsx(
441
+ "directionalLight",
442
+ {
443
+ position: directionalPosition,
444
+ intensity: directionalIntensity,
445
+ color: directionalColor,
446
+ castShadow: shadows,
447
+ "shadow-mapSize": [shadowMapSize, shadowMapSize],
448
+ "shadow-camera-left": -shadowCameraSize,
449
+ "shadow-camera-right": shadowCameraSize,
450
+ "shadow-camera-top": shadowCameraSize,
451
+ "shadow-camera-bottom": -shadowCameraSize,
452
+ "shadow-camera-near": shadowCameraNear,
453
+ "shadow-camera-far": shadowCameraFar,
454
+ "shadow-bias": shadowBias,
455
+ "shadow-normalBias": shadowNormalBias
456
+ }
457
+ ),
458
+ /* @__PURE__ */ jsx(
459
+ "hemisphereLight",
460
+ {
461
+ intensity: 0.3,
462
+ color: "#87ceeb",
463
+ groundColor: "#362d1d"
464
+ }
465
+ ),
466
+ showHelpers && /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx(
467
+ "directionalLightHelper",
468
+ {
469
+ args: [
470
+ new THREE9.DirectionalLight(directionalColor, directionalIntensity),
471
+ 5
472
+ ]
473
+ }
474
+ ) })
475
+ ] });
476
+ }
477
+ function CameraController3D({
478
+ onCameraChange
479
+ }) {
480
+ const { camera } = useThree();
481
+ useEffect(() => {
482
+ if (onCameraChange) {
483
+ onCameraChange({
484
+ x: camera.position.x,
485
+ y: camera.position.y,
486
+ z: camera.position.z
487
+ });
488
+ }
489
+ }, [camera.position, onCameraChange]);
490
+ return null;
491
+ }
492
+ function FollowCamera3D({
493
+ target,
494
+ offset
495
+ }) {
496
+ const { camera } = useThree();
497
+ const look = useRef(new THREE9.Vector3(target[0], target[1], target[2]));
498
+ const goal = useRef(new THREE9.Vector3());
499
+ useFrame((_, delta) => {
500
+ const t = Math.min(1, delta * 5);
501
+ goal.current.set(target[0] + offset[0], target[1] + offset[1], target[2] + offset[2]);
502
+ camera.position.lerp(goal.current, t);
503
+ look.current.lerp(goal.current.set(target[0], target[1], target[2]), t);
504
+ camera.lookAt(look.current);
505
+ });
506
+ return null;
507
+ }
508
+ var log3 = createLogger("almadar:ui:game:model-loader");
312
509
  var gltfCache = /* @__PURE__ */ new Map();
313
510
  function loadGltfCached(url, assetRoot) {
314
511
  const key = `${assetRoot}|${url}`;
@@ -321,13 +518,13 @@ function loadGltfCached(url, assetRoot) {
321
518
  loader.load(
322
519
  url,
323
520
  (gltf) => {
324
- log2.debug("Loaded", { url, clips: gltf.animations.length });
521
+ log3.debug("Loaded", { url, clips: gltf.animations.length });
325
522
  pending.gltf = { scene: gltf.scene, animations: gltf.animations };
326
523
  resolveDone();
327
524
  },
328
525
  void 0,
329
526
  (err) => {
330
- log2.warn("Failed", { url, error: err instanceof Error ? err : String(err) });
527
+ log3.warn("Failed", { url, error: err instanceof Error ? err : String(err) });
331
528
  pending.error = err instanceof Error ? err : new Error(String(err));
332
529
  resolveDone();
333
530
  }
@@ -394,12 +591,12 @@ function ModelLoader({
394
591
  if (!loadedModel) return null;
395
592
  const cloned = clone(loadedModel);
396
593
  cloned.updateMatrixWorld(true);
397
- const tintColor = tint ? new THREE6.Color(tint) : null;
594
+ const tintColor = tint ? new THREE9.Color(tint) : null;
398
595
  cloned.traverse((child) => {
399
- if (child instanceof THREE6.Mesh) {
596
+ if (child instanceof THREE9.Mesh) {
400
597
  child.castShadow = castShadow;
401
598
  child.receiveShadow = receiveShadow;
402
- if (tintColor && child.material instanceof THREE6.MeshStandardMaterial) {
599
+ if (tintColor && child.material instanceof THREE9.MeshStandardMaterial) {
403
600
  const mat = child.material.clone();
404
601
  mat.color.multiply(tintColor);
405
602
  child.material = mat;
@@ -408,7 +605,7 @@ function ModelLoader({
408
605
  });
409
606
  return cloned;
410
607
  }, [loadedModel, castShadow, receiveShadow, tint]);
411
- const mixer = useMemo(() => model ? new THREE6.AnimationMixer(model) : null, [model]);
608
+ const mixer = useMemo(() => model ? new THREE9.AnimationMixer(model) : null, [model]);
412
609
  useEffect(() => {
413
610
  if (!mixer || !animation || clips.length === 0) return;
414
611
  const wanted = animation.toLowerCase();
@@ -425,8 +622,8 @@ function ModelLoader({
425
622
  });
426
623
  const normFactor = useMemo(() => {
427
624
  if (!model) return 1;
428
- const box = new THREE6.Box3().setFromObject(model);
429
- const size = new THREE6.Vector3();
625
+ const box = new THREE9.Box3().setFromObject(model);
626
+ const size = new THREE9.Vector3();
430
627
  box.getSize(size);
431
628
  const maxDim = Math.max(size.x, size.y, size.z);
432
629
  if (!Number.isFinite(maxDim) || maxDim < 0.05) return 1;
@@ -509,1576 +706,1377 @@ function ModelLoader({
509
706
  }
510
707
  );
511
708
  }
512
- function detectAssetRoot2(modelUrl) {
513
- const idx = modelUrl.indexOf("/3d/");
514
- if (idx !== -1) {
515
- return modelUrl.substring(0, idx + 4);
516
- }
517
- return modelUrl.substring(0, modelUrl.lastIndexOf("/") + 1);
518
- }
519
- function createGLTFLoaderForUrl(url) {
520
- const loader = new GLTFLoader();
521
- loader.setResourcePath(detectAssetRoot2(url));
522
- return loader;
523
- }
524
- var AssetLoader = class {
525
- constructor() {
526
- __publicField(this, "objLoader");
527
- __publicField(this, "textureLoader");
528
- __publicField(this, "modelCache");
529
- __publicField(this, "textureCache");
530
- __publicField(this, "loadingPromises");
531
- this.objLoader = new OBJLoader();
532
- this.textureLoader = new THREE6.TextureLoader();
533
- this.modelCache = /* @__PURE__ */ new Map();
534
- this.textureCache = /* @__PURE__ */ new Map();
535
- this.loadingPromises = /* @__PURE__ */ new Map();
536
- }
537
- /**
538
- * Load a GLB/GLTF model
539
- * @param url - URL to the .glb or .gltf file
540
- * @returns Promise with loaded model scene and animations
541
- */
542
- async loadModel(url) {
543
- if (this.modelCache.has(url)) {
544
- return this.modelCache.get(url);
545
- }
546
- if (this.loadingPromises.has(url)) {
547
- return this.loadingPromises.get(url);
548
- }
549
- const loader = createGLTFLoaderForUrl(url);
550
- const loadPromise = loader.loadAsync(url).then((gltf) => {
551
- const result = {
552
- scene: gltf.scene,
553
- animations: gltf.animations || []
554
- };
555
- this.modelCache.set(url, result);
556
- this.loadingPromises.delete(url);
557
- return result;
558
- }).catch((error) => {
559
- this.loadingPromises.delete(url);
560
- throw new Error(`Failed to load model ${url}: ${error.message}`);
561
- });
562
- this.loadingPromises.set(url, loadPromise);
563
- return loadPromise;
709
+ function SpriteBillboard({ node, world }) {
710
+ const texture = useLoader(THREE9.TextureLoader, node.asset.url);
711
+ const img = texture.image;
712
+ const imgW = img?.width || 1;
713
+ const imgH = img?.height || 1;
714
+ if (node.frame) {
715
+ texture.repeat.set(node.frame.w / imgW, node.frame.h / imgH);
716
+ texture.offset.set(node.frame.x / imgW, 1 - (node.frame.y + node.frame.h) / imgH);
717
+ texture.magFilter = texture.minFilter = THREE9.NearestFilter;
718
+ texture.needsUpdate = true;
564
719
  }
565
- /**
566
- * Load an OBJ model (fallback for non-GLB assets)
567
- * @param url - URL to the .obj file
568
- * @returns Promise with loaded object group
569
- */
570
- async loadOBJ(url) {
571
- if (this.modelCache.has(url)) {
572
- return this.modelCache.get(url).scene;
573
- }
574
- if (this.loadingPromises.has(url)) {
575
- const result = await this.loadingPromises.get(url);
576
- return result.scene;
577
- }
578
- const loadPromise = this.objLoader.loadAsync(url).then((group) => {
579
- const result = {
580
- scene: group,
581
- animations: []
582
- };
583
- this.modelCache.set(url, result);
584
- this.loadingPromises.delete(url);
585
- return result;
586
- }).catch((error) => {
587
- this.loadingPromises.delete(url);
588
- throw new Error(`Failed to load OBJ ${url}: ${error.message}`);
589
- });
590
- this.loadingPromises.set(url, loadPromise);
591
- return (await loadPromise).scene;
720
+ const aspect = node.frame ? node.frame.w / node.frame.h : imgW / imgH;
721
+ const height = node.height ?? 1;
722
+ const width = node.width ?? height * aspect;
723
+ return /* @__PURE__ */ jsx(Billboard, { position: [world[0], world[1] + height / 2, world[2]], children: /* @__PURE__ */ jsxs("mesh", { children: [
724
+ /* @__PURE__ */ jsx("planeGeometry", { args: [width, height] }),
725
+ /* @__PURE__ */ jsx(
726
+ "meshBasicMaterial",
727
+ {
728
+ map: texture,
729
+ transparent: true,
730
+ alphaTest: 0.1,
731
+ side: THREE9.DoubleSide,
732
+ opacity: node.opacity ?? 1
733
+ }
734
+ )
735
+ ] }) });
736
+ }
737
+ function Sprite3D({ node, projector }) {
738
+ if (node.asset.dimension === "3d" && node.asset.url) {
739
+ return /* @__PURE__ */ jsx("group", { position: projector.toWorld(node.position), children: /* @__PURE__ */ jsx(
740
+ ModelLoader,
741
+ {
742
+ url: node.asset.url,
743
+ scale: node.width ?? projector.cellSize,
744
+ rotation: [0, node.rotation ?? 0, 0],
745
+ fallbackGeometry: "box",
746
+ castShadow: true,
747
+ receiveShadow: true
748
+ }
749
+ ) });
592
750
  }
593
- /**
594
- * Load a texture
595
- * @param url - URL to the texture image
596
- * @returns Promise with loaded texture
597
- */
598
- async loadTexture(url) {
599
- if (this.textureCache.has(url)) {
600
- return this.textureCache.get(url);
751
+ if (!node.asset.url) return null;
752
+ return /* @__PURE__ */ jsx(SpriteBillboard, { node, world: projector.toWorld(node.position) });
753
+ }
754
+ function Shape3D({ node, projector }) {
755
+ const world = projector.toWorld(node.position);
756
+ const color = node.fill ?? node.stroke ?? "#ffffff";
757
+ let geometry;
758
+ switch (node.shape) {
759
+ case "cell":
760
+ case "rect": {
761
+ const w = node.shape === "cell" ? projector.cellSize * 0.95 : node.width ?? projector.cellSize;
762
+ const h = node.shape === "cell" ? projector.cellSize * 0.95 : node.height ?? projector.cellSize;
763
+ geometry = /* @__PURE__ */ jsx("planeGeometry", { args: [w, h] });
764
+ break;
601
765
  }
602
- if (this.loadingPromises.has(`texture:${url}`)) {
603
- return this.loadingPromises.get(`texture:${url}`);
766
+ case "ellipse": {
767
+ const radiusX = node.radiusX ?? 0.4;
768
+ geometry = node.stroke && !node.fill ? /* @__PURE__ */ jsx("ringGeometry", { args: [Math.max(0, radiusX - 0.08), radiusX, 32] }) : /* @__PURE__ */ jsx("circleGeometry", { args: [radiusX, 32] });
769
+ break;
604
770
  }
605
- const loadPromise = this.textureLoader.loadAsync(url).then((texture) => {
606
- texture.colorSpace = THREE6.SRGBColorSpace;
607
- this.textureCache.set(url, texture);
608
- this.loadingPromises.delete(`texture:${url}`);
609
- return texture;
610
- }).catch((error) => {
611
- this.loadingPromises.delete(`texture:${url}`);
612
- throw new Error(`Failed to load texture ${url}: ${error.message}`);
613
- });
614
- this.loadingPromises.set(`texture:${url}`, loadPromise);
615
- return loadPromise;
616
- }
617
- /**
618
- * Preload multiple assets
619
- * @param urls - Array of asset URLs to preload
620
- * @returns Promise that resolves when all assets are loaded
621
- */
622
- async preload(urls) {
623
- const promises = urls.map((url) => {
624
- if (url.endsWith(".glb") || url.endsWith(".gltf")) {
625
- return this.loadModel(url).catch(() => null);
626
- } else if (url.endsWith(".obj")) {
627
- return this.loadOBJ(url).catch(() => null);
628
- } else if (/\.(png|jpg|jpeg|webp)$/i.test(url)) {
629
- return this.loadTexture(url).catch(() => null);
630
- }
631
- return Promise.resolve(null);
632
- });
633
- await Promise.all(promises);
634
- }
635
- /**
636
- * Check if a model is cached
637
- * @param url - Model URL
638
- */
639
- hasModel(url) {
640
- return this.modelCache.has(url);
641
- }
642
- /**
643
- * Check if a texture is cached
644
- * @param url - Texture URL
645
- */
646
- hasTexture(url) {
647
- return this.textureCache.has(url);
648
- }
649
- /**
650
- * Get cached model (throws if not cached)
651
- * @param url - Model URL
652
- */
653
- getModel(url) {
654
- const model = this.modelCache.get(url);
655
- if (!model) {
656
- throw new Error(`Model ${url} not in cache`);
771
+ case "poly": {
772
+ if (!node.points || node.points.length === 0) return null;
773
+ const s = new THREE9.Shape();
774
+ node.points.forEach((p, i) => {
775
+ if (i === 0) s.moveTo(p.x, p.y);
776
+ else s.lineTo(p.x, p.y);
777
+ });
778
+ s.closePath();
779
+ geometry = /* @__PURE__ */ jsx("shapeGeometry", { args: [s] });
780
+ break;
657
781
  }
658
- return model;
782
+ default:
783
+ return null;
659
784
  }
660
- /**
661
- * Get cached texture (throws if not cached)
662
- * @param url - Texture URL
663
- */
664
- getTexture(url) {
665
- const texture = this.textureCache.get(url);
666
- if (!texture) {
667
- throw new Error(`Texture ${url} not in cache`);
785
+ return /* @__PURE__ */ jsxs("mesh", { rotation: [-Math.PI / 2, 0, 0], position: [world[0], world[1] + 0.02, world[2]], children: [
786
+ geometry,
787
+ /* @__PURE__ */ jsx("meshBasicMaterial", { color, transparent: true, opacity: node.opacity ?? 1, side: THREE9.DoubleSide })
788
+ ] });
789
+ }
790
+ function Text3D({ node, projector }) {
791
+ const world = projector.toWorld(node.position);
792
+ return /* @__PURE__ */ jsx(Billboard, { position: [world[0], world[1] + 1.2, world[2]], children: /* @__PURE__ */ jsx(
793
+ Text,
794
+ {
795
+ fontSize: 0.32,
796
+ color: node.color,
797
+ anchorX: "center",
798
+ anchorY: "middle",
799
+ outlineWidth: 0.02,
800
+ outlineColor: "#000000",
801
+ children: node.text
668
802
  }
669
- return texture;
803
+ ) });
804
+ }
805
+ function Drawable3D({ node, projector }) {
806
+ switch (node.type) {
807
+ case "draw-sprite":
808
+ return /* @__PURE__ */ jsx(Sprite3D, { node, projector });
809
+ case "draw-shape":
810
+ return /* @__PURE__ */ jsx(Shape3D, { node, projector });
811
+ case "draw-text":
812
+ return /* @__PURE__ */ jsx(Text3D, { node, projector });
813
+ case "draw-sprite-layer":
814
+ return /* @__PURE__ */ jsx(Fragment, { children: node.items.map((item, i) => /* @__PURE__ */ jsx(Sprite3D, { node: item, projector }, i)) });
815
+ case "draw-shape-layer":
816
+ return /* @__PURE__ */ jsx(Fragment, { children: node.items.map((item, i) => /* @__PURE__ */ jsx(Shape3D, { node: item, projector }, i)) });
817
+ case "draw-text-layer":
818
+ return /* @__PURE__ */ jsx(Fragment, { children: node.items.map((item, i) => /* @__PURE__ */ jsx(Text3D, { node: item, projector }, i)) });
670
819
  }
671
- /**
672
- * Clear all caches
673
- */
674
- clearCache() {
675
- this.textureCache.forEach((texture) => {
676
- texture.dispose();
677
- });
678
- this.modelCache.forEach((model) => {
679
- model.scene.traverse((child) => {
680
- if (child instanceof THREE6.Mesh) {
681
- child.geometry.dispose();
682
- if (Array.isArray(child.material)) {
683
- child.material.forEach((m) => m.dispose());
684
- } else {
685
- child.material.dispose();
686
- }
687
- }
688
- });
689
- });
690
- this.modelCache.clear();
691
- this.textureCache.clear();
692
- this.loadingPromises.clear();
693
- }
694
- /**
695
- * Get cache statistics
696
- */
697
- getStats() {
698
- return {
699
- models: this.modelCache.size,
700
- textures: this.textureCache.size,
701
- loading: this.loadingPromises.size
702
- };
703
- }
704
- };
705
- var assetLoader = new AssetLoader();
820
+ }
706
821
 
707
- // components/game/three/hooks/useThree.ts
708
- var DEFAULT_OPTIONS = {
709
- cameraMode: "isometric",
710
- cameraPosition: [10, 10, 10],
711
- backgroundColor: "#1a1a2e",
712
- shadows: true,
713
- showGrid: true,
714
- gridSize: 20,
715
- assetLoader: new AssetLoader()
716
- };
717
- function useThree3(options = {}) {
718
- const opts = { ...DEFAULT_OPTIONS, ...options };
719
- const containerRef = useRef(null);
720
- const canvasRef = useRef(null);
721
- const rendererRef = useRef(null);
722
- const sceneRef = useRef(null);
723
- const cameraRef = useRef(null);
724
- const controlsRef = useRef(null);
725
- const gridHelperRef = useRef(null);
726
- const rafRef = useRef(0);
727
- const [isReady, setIsReady] = useState(false);
728
- const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
729
- const initialCameraPosition = useMemo(
730
- () => new THREE6.Vector3(...opts.cameraPosition),
731
- []
732
- );
733
- useEffect(() => {
734
- if (!containerRef.current) return;
735
- const container = containerRef.current;
736
- const { clientWidth, clientHeight } = container;
737
- const scene = new THREE6.Scene();
738
- scene.background = new THREE6.Color(opts.backgroundColor);
739
- sceneRef.current = scene;
740
- let camera;
741
- const aspect = clientWidth / clientHeight;
742
- if (opts.cameraMode === "isometric") {
743
- const size = 10;
744
- camera = new THREE6.OrthographicCamera(
745
- -size * aspect,
746
- size * aspect,
747
- size,
748
- -size,
749
- 0.1,
750
- 1e3
751
- );
752
- } else {
753
- camera = new THREE6.PerspectiveCamera(45, aspect, 0.1, 1e3);
754
- }
755
- camera.position.copy(initialCameraPosition);
756
- cameraRef.current = camera;
757
- const renderer = new THREE6.WebGLRenderer({
758
- antialias: true,
759
- alpha: true,
760
- canvas: canvasRef.current || void 0
761
- });
762
- renderer.setSize(clientWidth, clientHeight);
763
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
764
- renderer.shadowMap.enabled = opts.shadows;
765
- renderer.shadowMap.type = THREE6.PCFSoftShadowMap;
766
- rendererRef.current = renderer;
767
- const controls = new OrbitControls$1(camera, renderer.domElement);
768
- controls.enableDamping = true;
769
- controls.dampingFactor = 0.05;
770
- controls.minDistance = 2;
771
- controls.maxDistance = 100;
772
- controls.maxPolarAngle = Math.PI / 2 - 0.1;
773
- controlsRef.current = controls;
774
- const ambientLight = new THREE6.AmbientLight(16777215, 0.6);
775
- scene.add(ambientLight);
776
- const directionalLight = new THREE6.DirectionalLight(16777215, 0.8);
777
- directionalLight.position.set(10, 20, 10);
778
- directionalLight.castShadow = opts.shadows;
779
- directionalLight.shadow.mapSize.width = 2048;
780
- directionalLight.shadow.mapSize.height = 2048;
781
- scene.add(directionalLight);
782
- if (opts.showGrid) {
783
- const gridHelper = new THREE6.GridHelper(
784
- opts.gridSize,
785
- opts.gridSize,
786
- 4473924,
787
- 2236962
788
- );
789
- scene.add(gridHelper);
790
- gridHelperRef.current = gridHelper;
791
- }
792
- const animate = () => {
793
- rafRef.current = requestAnimationFrame(animate);
794
- controls.update();
795
- renderer.render(scene, camera);
796
- };
797
- animate();
798
- const handleResize = () => {
799
- const { clientWidth: width, clientHeight: height } = container;
800
- setDimensions({ width, height });
801
- if (camera instanceof THREE6.PerspectiveCamera) {
802
- camera.aspect = width / height;
803
- camera.updateProjectionMatrix();
804
- } else if (camera instanceof THREE6.OrthographicCamera) {
805
- const aspect2 = width / height;
806
- const size = 10;
807
- camera.left = -size * aspect2;
808
- camera.right = size * aspect2;
809
- camera.top = size;
810
- camera.bottom = -size;
811
- camera.updateProjectionMatrix();
812
- }
813
- renderer.setSize(width, height);
814
- };
815
- window.addEventListener("resize", handleResize);
816
- handleResize();
817
- setIsReady(true);
818
- return () => {
819
- window.removeEventListener("resize", handleResize);
820
- cancelAnimationFrame(rafRef.current);
821
- controls.dispose();
822
- renderer.dispose();
823
- scene.clear();
824
- };
825
- }, []);
826
- useEffect(() => {
827
- if (!cameraRef.current || !containerRef.current) return;
828
- const container = containerRef.current;
829
- const { clientWidth, clientHeight } = container;
830
- const aspect = clientWidth / clientHeight;
831
- const currentPos = cameraRef.current.position.clone();
832
- let newCamera;
833
- if (opts.cameraMode === "isometric") {
834
- const size = 10;
835
- newCamera = new THREE6.OrthographicCamera(
836
- -size * aspect,
837
- size * aspect,
838
- size,
839
- -size,
840
- 0.1,
841
- 1e3
842
- );
843
- } else {
844
- newCamera = new THREE6.PerspectiveCamera(45, aspect, 0.1, 1e3);
845
- }
846
- newCamera.position.copy(currentPos);
847
- cameraRef.current = newCamera;
848
- if (controlsRef.current) {
849
- controlsRef.current.object = newCamera;
850
- }
851
- if (rendererRef.current) {
852
- const animate = () => {
853
- rafRef.current = requestAnimationFrame(animate);
854
- controlsRef.current?.update();
855
- rendererRef.current?.render(sceneRef.current, newCamera);
856
- };
857
- cancelAnimationFrame(rafRef.current);
858
- animate();
859
- }
860
- }, [opts.cameraMode]);
861
- const setCameraPosition = useCallback((x, y, z) => {
862
- if (cameraRef.current) {
863
- cameraRef.current.position.set(x, y, z);
864
- controlsRef.current?.update();
865
- }
866
- }, []);
867
- const lookAt = useCallback((x, y, z) => {
868
- if (cameraRef.current) {
869
- cameraRef.current.lookAt(x, y, z);
870
- controlsRef.current?.target.set(x, y, z);
871
- controlsRef.current?.update();
872
- }
873
- }, []);
874
- const resetCamera = useCallback(() => {
875
- if (cameraRef.current) {
876
- cameraRef.current.position.copy(initialCameraPosition);
877
- cameraRef.current.lookAt(0, 0, 0);
878
- if (controlsRef.current) {
879
- controlsRef.current.target.set(0, 0, 0);
880
- controlsRef.current.update();
881
- }
882
- }
883
- }, [initialCameraPosition]);
884
- const fitView = useCallback(
885
- (bounds) => {
886
- if (!cameraRef.current) return;
887
- const centerX = (bounds.minX + bounds.maxX) / 2;
888
- const centerZ = (bounds.minZ + bounds.maxZ) / 2;
889
- const width = bounds.maxX - bounds.minX;
890
- const depth = bounds.maxZ - bounds.minZ;
891
- const maxDim = Math.max(width, depth);
892
- const distance = maxDim * 1.5;
893
- const height = distance * 0.8;
894
- cameraRef.current.position.set(centerX + distance, height, centerZ + distance);
895
- lookAt(centerX, 0, centerZ);
896
- },
897
- [lookAt]
898
- );
822
+ // lib/drawable/projector3d.ts
823
+ function create3DProjector(opts = {}) {
824
+ const cellSize = opts.cellSize ?? 1;
825
+ const offsetX = opts.offsetX ?? 0;
826
+ const offsetZ = opts.offsetZ ?? 0;
899
827
  return {
900
- canvasRef,
901
- renderer: rendererRef.current,
902
- scene: sceneRef.current,
903
- camera: cameraRef.current,
904
- controls: controlsRef.current,
905
- isReady,
906
- dimensions,
907
- setCameraPosition,
908
- lookAt,
909
- resetCamera,
910
- fitView
828
+ cellSize,
829
+ toWorld: (pos) => [pos.x * cellSize + offsetX, pos.z ?? 0, pos.y * cellSize + offsetZ]
911
830
  };
912
831
  }
913
- function useAssetLoader(options = {}) {
914
- const { preloadUrls = [], loader: customLoader } = options;
915
- const loaderRef = useRef(customLoader || new AssetLoader());
916
- const [state, setState] = useState({
917
- isLoading: false,
918
- progress: 0,
919
- loaded: 0,
920
- total: 0,
921
- errors: []
922
- });
923
- useEffect(() => {
924
- if (preloadUrls.length > 0) {
925
- preload(preloadUrls);
926
- }
927
- }, []);
928
- const updateProgress = useCallback((loaded, total) => {
929
- setState((prev) => ({
930
- ...prev,
931
- loaded,
932
- total,
933
- progress: total > 0 ? Math.round(loaded / total * 100) : 0
934
- }));
935
- }, []);
936
- const loadModel = useCallback(
937
- async (url) => {
938
- setState((prev) => ({ ...prev, isLoading: true }));
939
- try {
940
- const model = await loaderRef.current.loadModel(url);
941
- setState((prev) => ({
942
- ...prev,
943
- isLoading: false,
944
- loaded: prev.loaded + 1
945
- }));
946
- return model;
947
- } catch (error) {
948
- const errorMsg = error instanceof Error ? error.message : String(error);
949
- setState((prev) => ({
950
- ...prev,
951
- isLoading: false,
952
- errors: [...prev.errors, errorMsg]
953
- }));
954
- throw error;
832
+
833
+ // lib/drawable/three/game3dTheme.ts
834
+ var GRID_COLORS_3D = {
835
+ cell: "#444444",
836
+ section: "#666666"
837
+ };
838
+ var DEFAULT_BACKGROUND_3D = "#1a1a2e";
839
+ function cn(...inputs) {
840
+ return twMerge(clsx(inputs));
841
+ }
842
+ var DEFAULT_GRID_CONFIG = {
843
+ cellSize: 1,
844
+ offsetX: 0,
845
+ offsetZ: 0
846
+ };
847
+ var Canvas3DHost = forwardRef(
848
+ ({
849
+ cameraMode = "isometric",
850
+ followTarget,
851
+ showGrid = true,
852
+ orientation = "standard",
853
+ overlay = "default",
854
+ shadows = true,
855
+ backgroundColor = DEFAULT_BACKGROUND_3D,
856
+ className,
857
+ isLoading: externalLoading,
858
+ error: externalError,
859
+ tileClickEvent,
860
+ unitClickEvent,
861
+ featureClickEvent,
862
+ canvasClickEvent,
863
+ tileHoverEvent,
864
+ tileLeaveEvent,
865
+ unitAnimationEvent,
866
+ cameraChangeEvent,
867
+ loadingMessage = "Loading 3D Scene...",
868
+ keyMap,
869
+ keyUpMap,
870
+ children,
871
+ drawables
872
+ }, ref) => {
873
+ const containerRef = useRef(null);
874
+ const controlsRef = useRef(null);
875
+ const [internalError, setInternalError] = useState(null);
876
+ const eventBus = useEventBus();
877
+ const keysRef = useRef(/* @__PURE__ */ new Set());
878
+ useEffect(() => {
879
+ if (!keyMap && !keyUpMap) return;
880
+ const down = (e) => {
881
+ if (keysRef.current.has(e.code)) return;
882
+ keysRef.current.add(e.code);
883
+ const ev = keyMap?.[e.code];
884
+ if (ev) {
885
+ eventBus.emit(`UI:${ev}`, {});
886
+ e.preventDefault();
887
+ }
888
+ };
889
+ const up = (e) => {
890
+ keysRef.current.delete(e.code);
891
+ const ev = keyUpMap?.[e.code];
892
+ if (ev) eventBus.emit(`UI:${ev}`, {});
893
+ };
894
+ window.addEventListener("keydown", down);
895
+ window.addEventListener("keyup", up);
896
+ return () => {
897
+ window.removeEventListener("keydown", down);
898
+ window.removeEventListener("keyup", up);
899
+ };
900
+ }, [keyMap, keyUpMap, eventBus]);
901
+ const eventHandlers = useGameCanvas3DEvents({
902
+ tileClickEvent,
903
+ unitClickEvent,
904
+ featureClickEvent,
905
+ canvasClickEvent,
906
+ tileHoverEvent,
907
+ tileLeaveEvent,
908
+ unitAnimationEvent,
909
+ cameraChangeEvent
910
+ });
911
+ const drawnItems = useMemo(() => collectDrawnItems(drawables ?? []), [drawables]);
912
+ const scenePositions = useMemo(() => drawnItems.map((i) => i.pos), [drawnItems]);
913
+ const hitIndex = useMemo(() => buildHitIndex(drawnItems), [drawnItems]);
914
+ const gridBounds = useMemo(() => {
915
+ if (scenePositions.length === 0) {
916
+ return { minX: 0, maxX: 10, minZ: 0, maxZ: 10 };
955
917
  }
956
- },
957
- []
958
- );
959
- const loadOBJ = useCallback(
960
- async (url) => {
961
- setState((prev) => ({ ...prev, isLoading: true }));
962
- try {
963
- const model = await loaderRef.current.loadOBJ(url);
964
- setState((prev) => ({
965
- ...prev,
966
- isLoading: false,
967
- loaded: prev.loaded + 1
968
- }));
969
- return model;
970
- } catch (error) {
971
- const errorMsg = error instanceof Error ? error.message : String(error);
972
- setState((prev) => ({
973
- ...prev,
974
- isLoading: false,
975
- errors: [...prev.errors, errorMsg]
976
- }));
977
- throw error;
918
+ let minX = Infinity;
919
+ let maxX = -Infinity;
920
+ let minZ = Infinity;
921
+ let maxZ = -Infinity;
922
+ for (const p of scenePositions) {
923
+ if (p.x < minX) minX = p.x;
924
+ if (p.x > maxX) maxX = p.x;
925
+ if (p.y < minZ) minZ = p.y;
926
+ if (p.y > maxZ) maxZ = p.y;
978
927
  }
979
- },
980
- []
981
- );
982
- const loadTexture = useCallback(
983
- async (url) => {
984
- setState((prev) => ({ ...prev, isLoading: true }));
985
- try {
986
- const texture = await loaderRef.current.loadTexture(url);
987
- setState((prev) => ({
988
- ...prev,
989
- isLoading: false,
990
- loaded: prev.loaded + 1
991
- }));
992
- return texture;
993
- } catch (error) {
994
- const errorMsg = error instanceof Error ? error.message : String(error);
995
- setState((prev) => ({
996
- ...prev,
997
- isLoading: false,
998
- errors: [...prev.errors, errorMsg]
999
- }));
1000
- throw error;
928
+ return { minX, maxX, minZ, maxZ };
929
+ }, [scenePositions]);
930
+ const cameraTarget = useMemo(
931
+ () => [
932
+ (gridBounds.minX + gridBounds.maxX) / 2,
933
+ 0,
934
+ (gridBounds.minZ + gridBounds.maxZ) / 2
935
+ ],
936
+ [gridBounds]
937
+ );
938
+ const gridConfig = useMemo(
939
+ () => ({
940
+ ...DEFAULT_GRID_CONFIG,
941
+ offsetX: -(gridBounds.maxX - gridBounds.minX) / 2,
942
+ offsetZ: -(gridBounds.maxZ - gridBounds.minZ) / 2
943
+ }),
944
+ [gridBounds]
945
+ );
946
+ const drawableProjector = useMemo(
947
+ () => create3DProjector({
948
+ cellSize: gridConfig.cellSize,
949
+ offsetX: -gridBounds.minX * gridConfig.cellSize,
950
+ offsetZ: -gridBounds.minZ * gridConfig.cellSize
951
+ }),
952
+ [gridBounds, gridConfig]
953
+ );
954
+ useImperativeHandle(ref, () => ({
955
+ getCameraPosition: () => {
956
+ if (controlsRef.current) {
957
+ const pos = controlsRef.current.object.position;
958
+ return new THREE9.Vector3(pos.x, pos.y, pos.z);
959
+ }
960
+ return null;
961
+ },
962
+ setCameraPosition: (x, y, z) => {
963
+ if (controlsRef.current) {
964
+ controlsRef.current.object.position.set(x, y, z);
965
+ controlsRef.current.update();
966
+ }
967
+ },
968
+ lookAt: (x, y, z) => {
969
+ if (controlsRef.current) {
970
+ controlsRef.current.target.set(x, y, z);
971
+ controlsRef.current.update();
972
+ }
973
+ },
974
+ resetCamera: () => {
975
+ if (controlsRef.current) {
976
+ controlsRef.current.reset();
977
+ }
978
+ },
979
+ screenshot: () => {
980
+ const canvas = containerRef.current?.querySelector("canvas");
981
+ if (canvas) {
982
+ return canvas.toDataURL("image/png");
983
+ }
984
+ return null;
1001
985
  }
1002
- },
1003
- []
1004
- );
1005
- const preload = useCallback(
1006
- async (urls) => {
1007
- setState((prev) => ({
1008
- ...prev,
1009
- isLoading: true,
1010
- total: urls.length,
1011
- loaded: 0,
1012
- errors: []
1013
- }));
1014
- let completed = 0;
1015
- const errors = [];
1016
- await Promise.all(
1017
- urls.map(async (url) => {
1018
- try {
1019
- if (url.endsWith(".glb") || url.endsWith(".gltf")) {
1020
- await loaderRef.current.loadModel(url);
1021
- } else if (url.endsWith(".obj")) {
1022
- await loaderRef.current.loadOBJ(url);
1023
- } else if (/\.(png|jpg|jpeg|webp)$/i.test(url)) {
1024
- await loaderRef.current.loadTexture(url);
1025
- }
1026
- completed++;
1027
- updateProgress(completed, urls.length);
1028
- } catch (error) {
1029
- const errorMsg = error instanceof Error ? error.message : String(error);
1030
- errors.push(`${url}: ${errorMsg}`);
1031
- completed++;
1032
- updateProgress(completed, urls.length);
1033
- }
1034
- })
986
+ }));
987
+ const cameraConfig = useMemo(() => {
988
+ const size = Math.max(
989
+ gridBounds.maxX - gridBounds.minX,
990
+ gridBounds.maxZ - gridBounds.minZ,
991
+ 4
992
+ // minimum framing distance so a tiny board isn't zoomed in
1035
993
  );
1036
- setState((prev) => ({
1037
- ...prev,
1038
- isLoading: false,
1039
- errors
1040
- }));
1041
- },
1042
- [updateProgress]
1043
- );
1044
- const hasModel = useCallback((url) => {
1045
- return loaderRef.current.hasModel(url);
1046
- }, []);
1047
- const hasTexture = useCallback((url) => {
1048
- return loaderRef.current.hasTexture(url);
1049
- }, []);
1050
- const getModel = useCallback((url) => {
1051
- try {
1052
- return loaderRef.current.getModel(url);
1053
- } catch {
1054
- return void 0;
1055
- }
1056
- }, []);
1057
- const getTexture = useCallback((url) => {
1058
- try {
1059
- return loaderRef.current.getTexture(url);
1060
- } catch {
1061
- return void 0;
1062
- }
1063
- }, []);
1064
- const clearCache = useCallback(() => {
1065
- loaderRef.current.clearCache();
1066
- setState({
1067
- isLoading: false,
1068
- progress: 0,
1069
- loaded: 0,
1070
- total: 0,
1071
- errors: []
1072
- });
1073
- }, []);
1074
- return {
1075
- ...state,
1076
- loadModel,
1077
- loadOBJ,
1078
- loadTexture,
1079
- preload,
1080
- hasModel,
1081
- hasTexture,
1082
- getModel,
1083
- getTexture,
1084
- clearCache
1085
- };
1086
- }
1087
- function useSceneGraph() {
1088
- const nodesRef = useRef(/* @__PURE__ */ new Map());
1089
- const addNode = useCallback((node) => {
1090
- const existing = nodesRef.current.get(node.id);
1091
- if (existing) {
1092
- existing.mesh.removeFromParent();
1093
- }
1094
- nodesRef.current.set(node.id, node);
1095
- }, []);
1096
- const removeNode = useCallback((id) => {
1097
- const node = nodesRef.current.get(id);
1098
- if (node) {
1099
- node.mesh.removeFromParent();
1100
- nodesRef.current.delete(id);
1101
- }
1102
- }, []);
1103
- const getNode = useCallback((id) => {
1104
- return nodesRef.current.get(id);
1105
- }, []);
1106
- const updateNodePosition = useCallback(
1107
- (id, x, y, z) => {
1108
- const node = nodesRef.current.get(id);
1109
- if (node) {
1110
- node.mesh.position.set(x, y, z);
1111
- node.position = { x, y, z };
1112
- }
1113
- },
1114
- []
1115
- );
1116
- const updateNodeGridPosition = useCallback(
1117
- (id, gridX, gridZ) => {
1118
- const node = nodesRef.current.get(id);
1119
- if (node) {
1120
- node.gridPosition = { x: gridX, z: gridZ };
1121
- }
1122
- },
1123
- []
1124
- );
1125
- const getNodeAtGrid = useCallback(
1126
- (x, z, type) => {
1127
- return Array.from(nodesRef.current.values()).find((node) => {
1128
- const matchesGrid = node.gridPosition.x === x && node.gridPosition.z === z;
1129
- return type ? matchesGrid && node.type === type : matchesGrid;
1130
- });
1131
- },
1132
- []
1133
- );
1134
- const getNodesByType = useCallback((type) => {
1135
- return Array.from(nodesRef.current.values()).filter((node) => node.type === type);
1136
- }, []);
1137
- const getNodesInBounds = useCallback(
1138
- (minX, maxX, minZ, maxZ) => {
1139
- return Array.from(nodesRef.current.values()).filter((node) => {
1140
- const { x, z } = node.gridPosition;
1141
- return x >= minX && x <= maxX && z >= minZ && z <= maxZ;
1142
- });
1143
- },
1144
- []
1145
- );
1146
- const clearNodes = useCallback(() => {
1147
- nodesRef.current.forEach((node) => {
1148
- node.mesh.removeFromParent();
1149
- });
1150
- nodesRef.current.clear();
1151
- }, []);
1152
- const countNodes = useCallback((type) => {
1153
- if (!type) {
1154
- return nodesRef.current.size;
1155
- }
1156
- return Array.from(nodesRef.current.values()).filter((node) => node.type === type).length;
1157
- }, []);
1158
- return {
1159
- nodesRef,
1160
- addNode,
1161
- removeNode,
1162
- getNode,
1163
- updateNodePosition,
1164
- updateNodeGridPosition,
1165
- getNodeAtGrid,
1166
- getNodesByType,
1167
- getNodesInBounds,
1168
- clearNodes,
1169
- countNodes
1170
- };
1171
- }
1172
- function useRaycaster(options) {
1173
- const { camera, canvas, cellSize = 1, offsetX = 0, offsetZ = 0 } = options;
1174
- const raycaster = useRef(new THREE6.Raycaster());
1175
- const mouse = useRef(new THREE6.Vector2());
1176
- const clientToNDC = useCallback(
1177
- (clientX, clientY) => {
1178
- if (!canvas) {
1179
- return { x: 0, y: 0 };
994
+ const cx = (gridBounds.minX + gridBounds.maxX) / 2;
995
+ const cz = (gridBounds.minZ + gridBounds.maxZ) / 2;
996
+ const d = size * 1;
997
+ switch (cameraMode) {
998
+ case "isometric":
999
+ return { position: [cx + d, d * 0.8, cz + d], fov: 45 };
1000
+ case "top-down":
1001
+ return { position: [cx, d * 2, cz + d * 0.35], fov: 45 };
1002
+ case "follow":
1003
+ return { position: [cx, d * 0.5, cz + d], fov: 45 };
1004
+ case "perspective":
1005
+ default:
1006
+ return { position: [cx + d, d, cz + d], fov: 45 };
1180
1007
  }
1181
- const rect = canvas.getBoundingClientRect();
1182
- return {
1183
- x: (clientX - rect.left) / rect.width * 2 - 1,
1184
- y: -((clientY - rect.top) / rect.height) * 2 + 1
1008
+ }, [cameraMode, gridBounds]);
1009
+ const followWorld = useMemo(() => {
1010
+ if (followTarget) return drawableProjector.toWorld(followTarget);
1011
+ return cameraTarget;
1012
+ }, [followTarget, drawableProjector, cameraTarget]);
1013
+ const followOffset = useMemo(
1014
+ () => cameraMode === "chase" ? [0, 4, -7] : [5, 7, 5],
1015
+ [cameraMode]
1016
+ );
1017
+ const handleGroundClick = useCallback((e) => {
1018
+ if (!tileClickEvent && !unitClickEvent) return;
1019
+ e.stopPropagation();
1020
+ const cell = {
1021
+ x: Math.round(e.point.x / gridConfig.cellSize + gridBounds.minX),
1022
+ y: Math.round(e.point.z / gridConfig.cellSize + gridBounds.minZ)
1185
1023
  };
1186
- },
1187
- [canvas]
1188
- );
1189
- const isWithinCanvas = useCallback(
1190
- (clientX, clientY) => {
1191
- if (!canvas) return false;
1192
- const rect = canvas.getBoundingClientRect();
1193
- return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
1194
- },
1195
- [canvas]
1196
- );
1197
- const getIntersection = useCallback(
1198
- (clientX, clientY, objects) => {
1199
- if (!camera || !canvas) return null;
1200
- const ndc = clientToNDC(clientX, clientY);
1201
- mouse.current.set(ndc.x, ndc.y);
1202
- raycaster.current.setFromCamera(mouse.current, camera);
1203
- const intersects = raycaster.current.intersectObjects(objects, true);
1204
- if (intersects.length > 0) {
1205
- const hit = intersects[0];
1206
- return {
1207
- object: hit.object,
1208
- point: hit.point,
1209
- distance: hit.distance,
1210
- uv: hit.uv,
1211
- face: hit.face,
1212
- faceIndex: hit.faceIndex,
1213
- instanceId: hit.instanceId
1214
- };
1215
- }
1216
- return null;
1217
- },
1218
- [camera, canvas, clientToNDC]
1219
- );
1220
- const getAllIntersections = useCallback(
1221
- (clientX, clientY, objects) => {
1222
- if (!camera || !canvas) return [];
1223
- const ndc = clientToNDC(clientX, clientY);
1224
- mouse.current.set(ndc.x, ndc.y);
1225
- raycaster.current.setFromCamera(mouse.current, camera);
1226
- const intersects = raycaster.current.intersectObjects(objects, true);
1227
- return intersects.map((hit) => ({
1228
- object: hit.object,
1229
- point: hit.point,
1230
- distance: hit.distance,
1231
- uv: hit.uv,
1232
- face: hit.face,
1233
- faceIndex: hit.faceIndex,
1234
- instanceId: hit.instanceId
1235
- }));
1236
- },
1237
- [camera, canvas, clientToNDC]
1238
- );
1239
- const getGridCoordinates = useCallback(
1240
- (clientX, clientY) => {
1241
- if (!camera || !canvas) return null;
1242
- const ndc = clientToNDC(clientX, clientY);
1243
- mouse.current.set(ndc.x, ndc.y);
1244
- raycaster.current.setFromCamera(mouse.current, camera);
1245
- const plane = new THREE6.Plane(new THREE6.Vector3(0, 1, 0), 0);
1246
- const target = new THREE6.Vector3();
1247
- const intersection = raycaster.current.ray.intersectPlane(plane, target);
1248
- if (intersection) {
1249
- const gridX = Math.round((target.x - offsetX) / cellSize);
1250
- const gridZ = Math.round((target.z - offsetZ) / cellSize);
1251
- return { x: gridX, z: gridZ };
1024
+ const hitId = hitIndex.get(`${cell.x},${cell.y}`);
1025
+ if (hitId !== void 0 && unitClickEvent) {
1026
+ eventBus.emit(`UI:${unitClickEvent}`, { unitId: hitId, x: cell.x, z: cell.y });
1027
+ } else if (tileClickEvent) {
1028
+ eventBus.emit(`UI:${tileClickEvent}`, { x: cell.x, z: cell.y });
1252
1029
  }
1253
- return null;
1254
- },
1255
- [camera, canvas, cellSize, offsetX, offsetZ, clientToNDC]
1256
- );
1257
- const getTileAtPosition = useCallback(
1258
- (clientX, clientY, scene) => {
1259
- if (!camera || !canvas) return null;
1260
- const tileMeshes = [];
1261
- scene.traverse((obj) => {
1262
- if (obj.userData.type === "tile" || obj.userData.isTile) {
1263
- tileMeshes.push(obj);
1264
- }
1265
- });
1266
- const hit = getIntersection(clientX, clientY, tileMeshes);
1267
- if (hit) {
1268
- const gridCoords2 = getGridCoordinates(clientX, clientY);
1269
- if (gridCoords2) {
1270
- return {
1271
- gridX: gridCoords2.x,
1272
- gridZ: gridCoords2.z,
1273
- worldPosition: hit.point,
1274
- objectType: hit.object.userData.type || "tile",
1275
- objectId: hit.object.userData.id || hit.object.userData.tileId
1276
- };
1030
+ }, [tileClickEvent, unitClickEvent, gridConfig, gridBounds, hitIndex, eventBus]);
1031
+ if (externalLoading) {
1032
+ return /* @__PURE__ */ jsx(
1033
+ Canvas3DLoadingState,
1034
+ {
1035
+ progress: 1,
1036
+ loaded: 1,
1037
+ total: 1,
1038
+ message: loadingMessage,
1039
+ className
1277
1040
  }
1041
+ );
1042
+ }
1043
+ const displayError = externalError || internalError;
1044
+ if (displayError) {
1045
+ return /* @__PURE__ */ jsx(Canvas3DErrorBoundary, { children: /* @__PURE__ */ jsx("div", { className: "game-canvas-3d game-canvas-3d--error", children: /* @__PURE__ */ jsxs("div", { className: "game-canvas-3d__error", children: [
1046
+ "Error: ",
1047
+ displayError
1048
+ ] }) }) });
1049
+ }
1050
+ return /* @__PURE__ */ jsx(
1051
+ Canvas3DErrorBoundary,
1052
+ {
1053
+ onError: (err) => setInternalError(err.message),
1054
+ onReset: () => setInternalError(null),
1055
+ children: /* @__PURE__ */ jsx(
1056
+ "div",
1057
+ {
1058
+ ref: containerRef,
1059
+ className: cn("game-canvas-3d relative w-full overflow-hidden", className),
1060
+ style: { flex: "1 1 0", minHeight: "400px" },
1061
+ "data-orientation": orientation,
1062
+ "data-camera-mode": cameraMode,
1063
+ "data-overlay": overlay,
1064
+ children: /* @__PURE__ */ jsxs(
1065
+ Canvas,
1066
+ {
1067
+ shadows,
1068
+ camera: {
1069
+ position: cameraConfig.position,
1070
+ fov: cameraConfig.fov,
1071
+ near: 0.1,
1072
+ far: 1e3
1073
+ },
1074
+ style: { background: backgroundColor, position: "absolute", inset: 0 },
1075
+ onClick: (e) => {
1076
+ if (e.target === e.currentTarget) {
1077
+ eventHandlers.handleCanvasClick(e);
1078
+ }
1079
+ },
1080
+ children: [
1081
+ /* @__PURE__ */ jsx(CameraController3D, { onCameraChange: eventHandlers.handleCameraChange }),
1082
+ (cameraMode === "follow" || cameraMode === "chase") && /* @__PURE__ */ jsx(FollowCamera3D, { target: followWorld, offset: followOffset }),
1083
+ /* @__PURE__ */ jsx(
1084
+ Lighting3D,
1085
+ {
1086
+ shadows,
1087
+ shadowBias: -4e-4,
1088
+ shadowNormalBias: 0.04,
1089
+ shadowCameraSize: 5,
1090
+ shadowCameraNear: 0.5,
1091
+ shadowCameraFar: 500
1092
+ }
1093
+ ),
1094
+ showGrid && /* @__PURE__ */ jsx(
1095
+ Grid,
1096
+ {
1097
+ args: [
1098
+ Math.max(gridBounds.maxX - gridBounds.minX + 2, 10),
1099
+ Math.max(gridBounds.maxZ - gridBounds.minZ + 2, 10)
1100
+ ],
1101
+ position: [
1102
+ (gridBounds.maxX - gridBounds.minX) / 2 - 0.5,
1103
+ 0,
1104
+ (gridBounds.maxZ - gridBounds.minZ) / 2 - 0.5
1105
+ ],
1106
+ cellSize: 1,
1107
+ cellThickness: 1,
1108
+ cellColor: GRID_COLORS_3D.cell,
1109
+ sectionSize: 5,
1110
+ sectionThickness: 1.5,
1111
+ sectionColor: GRID_COLORS_3D.section,
1112
+ fadeDistance: 50,
1113
+ fadeStrength: 1
1114
+ }
1115
+ ),
1116
+ drawables && drawables.length > 0 && /* @__PURE__ */ jsx("group", { children: drawables.map((node, i) => /* @__PURE__ */ jsx(Drawable3D, { node, projector: drawableProjector }, i)) }),
1117
+ (tileClickEvent || unitClickEvent) && /* @__PURE__ */ jsxs(
1118
+ "mesh",
1119
+ {
1120
+ rotation: [-Math.PI / 2, 0, 0],
1121
+ position: [
1122
+ (gridBounds.maxX - gridBounds.minX) / 2 * gridConfig.cellSize,
1123
+ 0,
1124
+ (gridBounds.maxZ - gridBounds.minZ) / 2 * gridConfig.cellSize
1125
+ ],
1126
+ onClick: handleGroundClick,
1127
+ children: [
1128
+ /* @__PURE__ */ jsx(
1129
+ "planeGeometry",
1130
+ {
1131
+ args: [
1132
+ (gridBounds.maxX - gridBounds.minX + 4) * gridConfig.cellSize,
1133
+ (gridBounds.maxZ - gridBounds.minZ + 4) * gridConfig.cellSize
1134
+ ]
1135
+ }
1136
+ ),
1137
+ /* @__PURE__ */ jsx("meshBasicMaterial", { transparent: true, opacity: 0, depthWrite: false })
1138
+ ]
1139
+ }
1140
+ ),
1141
+ children,
1142
+ /* @__PURE__ */ jsx(
1143
+ OrbitControls,
1144
+ {
1145
+ ref: controlsRef,
1146
+ enabled: cameraMode !== "follow" && cameraMode !== "chase",
1147
+ target: cameraTarget,
1148
+ enableDamping: true,
1149
+ dampingFactor: 0.05,
1150
+ enableZoom: true,
1151
+ enablePan: true,
1152
+ touches: { ONE: THREE9.TOUCH.ROTATE, TWO: THREE9.TOUCH.DOLLY_PAN },
1153
+ minDistance: 2,
1154
+ maxDistance: 100,
1155
+ maxPolarAngle: Math.PI / 2 - 0.1
1156
+ }
1157
+ )
1158
+ ]
1159
+ }
1160
+ )
1161
+ }
1162
+ )
1278
1163
  }
1279
- const gridCoords = getGridCoordinates(clientX, clientY);
1280
- if (gridCoords) {
1281
- return {
1282
- gridX: gridCoords.x,
1283
- gridZ: gridCoords.z,
1284
- worldPosition: new THREE6.Vector3(
1285
- gridCoords.x * cellSize + offsetX,
1286
- 0,
1287
- gridCoords.z * cellSize + offsetZ
1288
- )
1289
- };
1290
- }
1291
- return null;
1292
- },
1293
- [camera, canvas, getIntersection, getGridCoordinates, cellSize, offsetX, offsetZ]
1294
- );
1295
- return {
1296
- raycaster,
1297
- mouse,
1298
- getIntersection,
1299
- getAllIntersections,
1300
- getGridCoordinates,
1301
- getTileAtPosition,
1302
- clientToNDC,
1303
- isWithinCanvas
1304
- };
1305
- }
1306
- var log3 = createLogger("almadar:eventbus");
1307
- var subLog = createLogger("almadar:eventbus:subscribe");
1308
- var scopeLog = createLogger("almadar:ui:trait-scope");
1309
- function getGlobalEventBus() {
1310
- if (typeof window !== "undefined") {
1311
- return window.__kflowEventBus ?? null;
1164
+ );
1312
1165
  }
1313
- return null;
1314
- }
1315
- var fallbackListeners = /* @__PURE__ */ new Map();
1316
- var fallbackAnyListeners = /* @__PURE__ */ new Set();
1317
- var fallbackEventBus = {
1318
- emit: (type, payload, source) => {
1319
- const event = {
1320
- type,
1321
- payload,
1322
- timestamp: Date.now(),
1323
- source
1324
- };
1325
- const handlers = fallbackListeners.get(type);
1326
- log3.debug("emit", { type, payloadKeys: payload ? Object.keys(payload).length : 0, listenerCount: (handlers?.size ?? 0) + fallbackAnyListeners.size });
1327
- if (handlers) {
1328
- handlers.forEach((handler) => {
1329
- try {
1330
- handler(event);
1331
- } catch (error) {
1332
- log3.error("Error in listener", { type, error: error instanceof Error ? error : String(error) });
1333
- }
1166
+ );
1167
+ Canvas3DHost.displayName = "Canvas3DHost";
1168
+ function Scene3D({ background = "#1a1a2e", fog, children }) {
1169
+ const { scene } = useThree();
1170
+ const initializedRef = useRef(false);
1171
+ useEffect(() => {
1172
+ if (initializedRef.current) return;
1173
+ initializedRef.current = true;
1174
+ if (background.startsWith("#") || background.startsWith("rgb")) {
1175
+ scene.background = new THREE9.Color(background);
1176
+ } else {
1177
+ const loader = new THREE9.TextureLoader();
1178
+ loader.load(background, (texture) => {
1179
+ scene.background = texture;
1334
1180
  });
1335
1181
  }
1336
- fallbackAnyListeners.forEach((handler) => {
1337
- try {
1338
- handler(event);
1339
- } catch (error) {
1340
- log3.error("Error in onAny listener", { type, error: error instanceof Error ? error : String(error) });
1341
- }
1342
- });
1343
- },
1344
- on: (type, listener) => {
1345
- if (!fallbackListeners.has(type)) {
1346
- fallbackListeners.set(type, /* @__PURE__ */ new Set());
1182
+ if (fog) {
1183
+ scene.fog = new THREE9.Fog(fog.color, fog.near, fog.far);
1347
1184
  }
1348
- fallbackListeners.get(type).add(listener);
1349
- subLog.debug("subscribe", { type, totalListeners: fallbackListeners.get(type).size });
1350
- return () => {
1351
- const handlers = fallbackListeners.get(type);
1352
- if (handlers) {
1353
- handlers.delete(listener);
1354
- if (handlers.size === 0) {
1355
- fallbackListeners.delete(type);
1356
- }
1357
- }
1358
- };
1359
- },
1360
- once: (type, listener) => {
1361
- const wrappedListener = (event) => {
1362
- fallbackListeners.get(type)?.delete(wrappedListener);
1363
- listener(event);
1364
- };
1365
- return fallbackEventBus.on(type, wrappedListener);
1366
- },
1367
- hasListeners: (type) => {
1368
- const handlers = fallbackListeners.get(type);
1369
- return handlers !== void 0 && handlers.size > 0;
1370
- },
1371
- onAny: (listener) => {
1372
- fallbackAnyListeners.add(listener);
1373
- subLog.debug("subscribe:any", { totalAnyListeners: fallbackAnyListeners.size });
1374
1185
  return () => {
1375
- fallbackAnyListeners.delete(listener);
1186
+ scene.background = null;
1187
+ scene.fog = null;
1376
1188
  };
1377
- }
1378
- };
1379
- function useEventBus() {
1380
- const context = useContext(EventBusContext);
1381
- const baseBus = context ?? getGlobalEventBus() ?? fallbackEventBus;
1382
- const scope = useTraitScope();
1383
- return useMemo(() => {
1384
- if (!scope) {
1385
- return {
1386
- ...baseBus,
1387
- emit: (type, payload, source) => {
1388
- if (typeof type === "string" && type.startsWith("UI:") && !type.slice(3).includes(".")) {
1389
- scopeLog.warn("emit:bare-key-no-scope", { type });
1390
- }
1391
- baseBus.emit(type, payload, source);
1392
- }
1393
- };
1394
- }
1395
- return {
1396
- ...baseBus,
1397
- emit: (type, payload, source) => {
1398
- if (typeof type === "string" && type.startsWith("UI:")) {
1399
- const tail = type.slice(3);
1400
- const qualified = tail.includes(".") ? type : `UI:${scope.orbital}.${scope.trait}.${tail}`;
1401
- if (qualified !== type) {
1402
- scopeLog.info("emit:qualified", {
1403
- from: type,
1404
- to: qualified,
1405
- scopeOrbital: scope.orbital,
1406
- scopeTrait: scope.trait
1407
- });
1408
- }
1409
- baseBus.emit(qualified, payload, source);
1410
- return;
1411
- }
1412
- baseBus.emit(type, payload, source);
1413
- }
1414
- };
1415
- }, [baseBus, scope]);
1416
- }
1417
- function useEmitEvent() {
1418
- const eventBus = useEventBus();
1419
- return useCallback(
1420
- (type, payload, source) => {
1421
- eventBus.emit(type, payload, source);
1422
- },
1423
- [eventBus]
1424
- );
1189
+ }, [scene, background, fog]);
1190
+ return /* @__PURE__ */ jsx(Fragment, { children });
1425
1191
  }
1426
-
1427
- // components/game/three/hooks/useGameCanvas3DEvents.ts
1428
- function useGameCanvas3DEvents(options) {
1429
- const {
1430
- tileClickEvent,
1431
- unitClickEvent,
1432
- featureClickEvent,
1433
- canvasClickEvent,
1434
- tileHoverEvent,
1435
- tileLeaveEvent,
1436
- unitAnimationEvent,
1437
- cameraChangeEvent,
1438
- onTileClick,
1439
- onUnitClick,
1440
- onFeatureClick,
1441
- onCanvasClick,
1442
- onTileHover,
1443
- onUnitAnimation
1444
- } = options;
1445
- const emit = useEmitEvent();
1446
- const optionsRef = useRef(options);
1447
- optionsRef.current = options;
1448
- const handleTileClick = useCallback(
1449
- (tile, event) => {
1450
- if (tileClickEvent) {
1451
- emit(tileClickEvent, {
1452
- tileId: tile.id,
1453
- x: tile.x,
1454
- z: tile.z ?? tile.y ?? 0,
1455
- type: tile.type,
1456
- terrain: tile.terrain,
1457
- elevation: tile.elevation
1458
- });
1459
- }
1460
- optionsRef.current.onTileClick?.(tile, event);
1461
- },
1462
- [tileClickEvent, emit]
1463
- );
1464
- const handleUnitClick = useCallback(
1465
- (unit, event) => {
1466
- if (unitClickEvent) {
1467
- emit(unitClickEvent, {
1468
- unitId: unit.id,
1469
- x: unit.x,
1470
- z: unit.z ?? unit.y ?? 0,
1471
- unitType: unit.unitType,
1472
- name: unit.name,
1473
- team: unit.team,
1474
- faction: unit.faction,
1475
- health: unit.health,
1476
- maxHealth: unit.maxHealth
1477
- });
1192
+ var Camera3D = forwardRef(
1193
+ ({
1194
+ mode = "isometric",
1195
+ position = [10, 10, 10],
1196
+ target = [0, 0, 0],
1197
+ zoom = 1,
1198
+ fov = 45,
1199
+ enableOrbit = true,
1200
+ minDistance = 2,
1201
+ maxDistance = 100,
1202
+ onChange
1203
+ }, ref) => {
1204
+ const { camera, set, viewport } = useThree();
1205
+ const controlsRef = useRef(null);
1206
+ const initialPosition = useRef(new THREE9.Vector3(...position));
1207
+ const initialTarget = useRef(new THREE9.Vector3(...target));
1208
+ useEffect(() => {
1209
+ let newCamera;
1210
+ if (mode === "isometric") {
1211
+ const aspect = viewport.aspect;
1212
+ const size = 10 / zoom;
1213
+ newCamera = new THREE9.OrthographicCamera(
1214
+ -size * aspect,
1215
+ size * aspect,
1216
+ size,
1217
+ -size,
1218
+ 0.1,
1219
+ 1e3
1220
+ );
1221
+ } else {
1222
+ newCamera = new THREE9.PerspectiveCamera(fov, viewport.aspect, 0.1, 1e3);
1478
1223
  }
1479
- optionsRef.current.onUnitClick?.(unit, event);
1480
- },
1481
- [unitClickEvent, emit]
1482
- );
1483
- const handleFeatureClick = useCallback(
1484
- (feature, event) => {
1485
- if (featureClickEvent) {
1486
- emit(featureClickEvent, {
1487
- featureId: feature.id,
1488
- x: feature.x,
1489
- z: feature.z ?? feature.y ?? 0,
1490
- type: feature.type,
1491
- elevation: feature.elevation
1492
- });
1224
+ newCamera.position.copy(initialPosition.current);
1225
+ newCamera.lookAt(initialTarget.current.x, initialTarget.current.y, initialTarget.current.z);
1226
+ set({ camera: newCamera });
1227
+ if (mode === "top-down") {
1228
+ newCamera.position.set(0, 20 / zoom, 0);
1229
+ newCamera.lookAt(0, 0, 0);
1493
1230
  }
1494
- optionsRef.current.onFeatureClick?.(feature, event);
1495
- },
1496
- [featureClickEvent, emit]
1497
- );
1498
- const handleCanvasClick = useCallback(
1499
- (event) => {
1500
- if (canvasClickEvent) {
1501
- emit(canvasClickEvent, {
1502
- clientX: event.clientX,
1503
- clientY: event.clientY,
1504
- button: event.button
1505
- });
1231
+ return () => {
1232
+ };
1233
+ }, [mode, fov, zoom, viewport.aspect, set]);
1234
+ useFrame(() => {
1235
+ if (onChange) {
1236
+ onChange(camera);
1506
1237
  }
1507
- optionsRef.current.onCanvasClick?.(event);
1508
- },
1509
- [canvasClickEvent, emit]
1510
- );
1511
- const handleTileHover = useCallback(
1512
- (tile, event) => {
1513
- if (tile) {
1514
- if (tileHoverEvent) {
1515
- emit(tileHoverEvent, {
1516
- tileId: tile.id,
1517
- x: tile.x,
1518
- z: tile.z ?? tile.y ?? 0,
1519
- type: tile.type
1520
- });
1238
+ });
1239
+ useImperativeHandle(ref, () => ({
1240
+ getCamera: () => camera,
1241
+ setPosition: (x, y, z) => {
1242
+ camera.position.set(x, y, z);
1243
+ if (controlsRef.current) {
1244
+ controlsRef.current.update();
1521
1245
  }
1522
- } else {
1523
- if (tileLeaveEvent) {
1524
- emit(tileLeaveEvent, {});
1246
+ },
1247
+ lookAt: (x, y, z) => {
1248
+ camera.lookAt(x, y, z);
1249
+ if (controlsRef.current) {
1250
+ controlsRef.current.target.set(x, y, z);
1251
+ controlsRef.current.update();
1525
1252
  }
1253
+ },
1254
+ reset: () => {
1255
+ camera.position.copy(initialPosition.current);
1256
+ camera.lookAt(initialTarget.current.x, initialTarget.current.y, initialTarget.current.z);
1257
+ if (controlsRef.current) {
1258
+ controlsRef.current.target.copy(initialTarget.current);
1259
+ controlsRef.current.update();
1260
+ }
1261
+ },
1262
+ getViewBounds: () => {
1263
+ const min = new THREE9.Vector3(-10, -10, -10);
1264
+ const max = new THREE9.Vector3(10, 10, 10);
1265
+ return { min, max };
1526
1266
  }
1527
- optionsRef.current.onTileHover?.(tile, event);
1528
- },
1529
- [tileHoverEvent, tileLeaveEvent, emit]
1530
- );
1531
- const handleUnitAnimation = useCallback(
1532
- (unitId, state) => {
1533
- if (unitAnimationEvent) {
1534
- emit(unitAnimationEvent, {
1535
- unitId,
1536
- state,
1537
- timestamp: Date.now()
1538
- });
1539
- }
1540
- optionsRef.current.onUnitAnimation?.(unitId, state);
1541
- },
1542
- [unitAnimationEvent, emit]
1543
- );
1544
- const handleCameraChange = useCallback(
1545
- (position) => {
1546
- if (cameraChangeEvent) {
1547
- emit(cameraChangeEvent, {
1548
- position,
1549
- timestamp: Date.now()
1550
- });
1267
+ }));
1268
+ const maxPolarAngle = mode === "top-down" ? 0.1 : Math.PI / 2 - 0.1;
1269
+ return /* @__PURE__ */ jsx(
1270
+ OrbitControls,
1271
+ {
1272
+ ref: controlsRef,
1273
+ camera,
1274
+ enabled: enableOrbit,
1275
+ target: [initialTarget.current.x, initialTarget.current.y, initialTarget.current.z],
1276
+ minDistance,
1277
+ maxDistance,
1278
+ maxPolarAngle,
1279
+ enableDamping: true,
1280
+ dampingFactor: 0.05
1551
1281
  }
1552
- },
1553
- [cameraChangeEvent, emit]
1554
- );
1555
- return {
1556
- handleTileClick,
1557
- handleUnitClick,
1558
- handleFeatureClick,
1559
- handleCanvasClick,
1560
- handleTileHover,
1561
- handleUnitAnimation,
1562
- handleCameraChange
1563
- };
1564
- }
1565
-
1566
- // lib/drawable/hitTest.ts
1567
- function collectDrawnItems(nodes) {
1568
- const out = [];
1569
- for (const n of nodes) {
1570
- switch (n.type) {
1571
- case "draw-sprite":
1572
- case "draw-shape":
1573
- case "draw-text":
1574
- out.push({ pos: n.position, id: n.id });
1575
- break;
1576
- case "draw-sprite-layer":
1577
- case "draw-shape-layer":
1578
- case "draw-text-layer":
1579
- for (const it of n.items) out.push({ pos: it.position, id: it.id });
1580
- break;
1581
- }
1282
+ );
1582
1283
  }
1583
- return out;
1584
- }
1585
- function buildHitIndex(items) {
1586
- const m = /* @__PURE__ */ new Map();
1587
- for (const it of items) {
1588
- if (it.id !== void 0) m.set(`${it.pos.x},${it.pos.y}`, it.id);
1284
+ );
1285
+ Camera3D.displayName = "Camera3D";
1286
+ function detectAssetRoot2(modelUrl) {
1287
+ const idx = modelUrl.indexOf("/3d/");
1288
+ if (idx !== -1) {
1289
+ return modelUrl.substring(0, idx + 4);
1589
1290
  }
1590
- return m;
1291
+ return modelUrl.substring(0, modelUrl.lastIndexOf("/") + 1);
1591
1292
  }
1592
- function CameraController3D({
1593
- onCameraChange
1594
- }) {
1595
- const { camera } = useThree();
1596
- useEffect(() => {
1597
- if (onCameraChange) {
1598
- onCameraChange({
1599
- x: camera.position.x,
1600
- y: camera.position.y,
1601
- z: camera.position.z
1602
- });
1603
- }
1604
- }, [camera.position, onCameraChange]);
1605
- return null;
1606
- }
1607
- function FollowCamera3D({
1608
- target,
1609
- offset
1610
- }) {
1611
- const { camera } = useThree();
1612
- const look = useRef(new THREE6.Vector3(target[0], target[1], target[2]));
1613
- const goal = useRef(new THREE6.Vector3());
1614
- useFrame((_, delta) => {
1615
- const t = Math.min(1, delta * 5);
1616
- goal.current.set(target[0] + offset[0], target[1] + offset[1], target[2] + offset[2]);
1617
- camera.position.lerp(goal.current, t);
1618
- look.current.lerp(goal.current.set(target[0], target[1], target[2]), t);
1619
- camera.lookAt(look.current);
1620
- });
1621
- return null;
1293
+ function createGLTFLoaderForUrl(url) {
1294
+ const loader = new GLTFLoader();
1295
+ loader.setResourcePath(detectAssetRoot2(url));
1296
+ return loader;
1622
1297
  }
1623
- function SpriteBillboard({ node, world }) {
1624
- const texture = useLoader(THREE6.TextureLoader, node.asset.url);
1625
- const img = texture.image;
1626
- const imgW = img?.width || 1;
1627
- const imgH = img?.height || 1;
1628
- if (node.frame) {
1629
- texture.repeat.set(node.frame.w / imgW, node.frame.h / imgH);
1630
- texture.offset.set(node.frame.x / imgW, 1 - (node.frame.y + node.frame.h) / imgH);
1631
- texture.magFilter = texture.minFilter = THREE6.NearestFilter;
1632
- texture.needsUpdate = true;
1298
+ var AssetLoader = class {
1299
+ constructor() {
1300
+ __publicField(this, "objLoader");
1301
+ __publicField(this, "textureLoader");
1302
+ __publicField(this, "modelCache");
1303
+ __publicField(this, "textureCache");
1304
+ __publicField(this, "loadingPromises");
1305
+ this.objLoader = new OBJLoader();
1306
+ this.textureLoader = new THREE9.TextureLoader();
1307
+ this.modelCache = /* @__PURE__ */ new Map();
1308
+ this.textureCache = /* @__PURE__ */ new Map();
1309
+ this.loadingPromises = /* @__PURE__ */ new Map();
1633
1310
  }
1634
- const aspect = node.frame ? node.frame.w / node.frame.h : imgW / imgH;
1635
- const height = node.height ?? 1;
1636
- const width = node.width ?? height * aspect;
1637
- return /* @__PURE__ */ jsx(Billboard, { position: [world[0], world[1] + height / 2, world[2]], children: /* @__PURE__ */ jsxs("mesh", { children: [
1638
- /* @__PURE__ */ jsx("planeGeometry", { args: [width, height] }),
1639
- /* @__PURE__ */ jsx(
1640
- "meshBasicMaterial",
1641
- {
1642
- map: texture,
1643
- transparent: true,
1644
- alphaTest: 0.1,
1645
- side: THREE6.DoubleSide,
1646
- opacity: node.opacity ?? 1
1647
- }
1648
- )
1649
- ] }) });
1650
- }
1651
- function Sprite3D({ node, projector }) {
1652
- if (node.asset.dimension === "3d" && node.asset.url) {
1653
- return /* @__PURE__ */ jsx("group", { position: projector.toWorld(node.position), children: /* @__PURE__ */ jsx(
1654
- ModelLoader,
1655
- {
1656
- url: node.asset.url,
1657
- scale: node.width ?? projector.cellSize,
1658
- rotation: [0, node.rotation ?? 0, 0],
1659
- fallbackGeometry: "box",
1660
- castShadow: true,
1661
- receiveShadow: true
1662
- }
1663
- ) });
1311
+ /**
1312
+ * Load a GLB/GLTF model
1313
+ * @param url - URL to the .glb or .gltf file
1314
+ * @returns Promise with loaded model scene and animations
1315
+ */
1316
+ async loadModel(url) {
1317
+ if (this.modelCache.has(url)) {
1318
+ return this.modelCache.get(url);
1319
+ }
1320
+ if (this.loadingPromises.has(url)) {
1321
+ return this.loadingPromises.get(url);
1322
+ }
1323
+ const loader = createGLTFLoaderForUrl(url);
1324
+ const loadPromise = loader.loadAsync(url).then((gltf) => {
1325
+ const result = {
1326
+ scene: gltf.scene,
1327
+ animations: gltf.animations || []
1328
+ };
1329
+ this.modelCache.set(url, result);
1330
+ this.loadingPromises.delete(url);
1331
+ return result;
1332
+ }).catch((error) => {
1333
+ this.loadingPromises.delete(url);
1334
+ throw new Error(`Failed to load model ${url}: ${error.message}`);
1335
+ });
1336
+ this.loadingPromises.set(url, loadPromise);
1337
+ return loadPromise;
1664
1338
  }
1665
- if (!node.asset.url) return null;
1666
- return /* @__PURE__ */ jsx(SpriteBillboard, { node, world: projector.toWorld(node.position) });
1667
- }
1668
- function Shape3D({ node, projector }) {
1669
- const world = projector.toWorld(node.position);
1670
- const color = node.fill ?? node.stroke ?? "#ffffff";
1671
- let geometry;
1672
- switch (node.shape) {
1673
- case "cell":
1674
- case "rect": {
1675
- const w = node.shape === "cell" ? projector.cellSize * 0.95 : node.width ?? projector.cellSize;
1676
- const h = node.shape === "cell" ? projector.cellSize * 0.95 : node.height ?? projector.cellSize;
1677
- geometry = /* @__PURE__ */ jsx("planeGeometry", { args: [w, h] });
1678
- break;
1339
+ /**
1340
+ * Load an OBJ model (fallback for non-GLB assets)
1341
+ * @param url - URL to the .obj file
1342
+ * @returns Promise with loaded object group
1343
+ */
1344
+ async loadOBJ(url) {
1345
+ if (this.modelCache.has(url)) {
1346
+ return this.modelCache.get(url).scene;
1679
1347
  }
1680
- case "ellipse": {
1681
- const radiusX = node.radiusX ?? 0.4;
1682
- geometry = node.stroke && !node.fill ? /* @__PURE__ */ jsx("ringGeometry", { args: [Math.max(0, radiusX - 0.08), radiusX, 32] }) : /* @__PURE__ */ jsx("circleGeometry", { args: [radiusX, 32] });
1683
- break;
1348
+ if (this.loadingPromises.has(url)) {
1349
+ const result = await this.loadingPromises.get(url);
1350
+ return result.scene;
1684
1351
  }
1685
- case "poly": {
1686
- if (!node.points || node.points.length === 0) return null;
1687
- const s = new THREE6.Shape();
1688
- node.points.forEach((p, i) => {
1689
- if (i === 0) s.moveTo(p.x, p.y);
1690
- else s.lineTo(p.x, p.y);
1691
- });
1692
- s.closePath();
1693
- geometry = /* @__PURE__ */ jsx("shapeGeometry", { args: [s] });
1694
- break;
1352
+ const loadPromise = this.objLoader.loadAsync(url).then((group) => {
1353
+ const result = {
1354
+ scene: group,
1355
+ animations: []
1356
+ };
1357
+ this.modelCache.set(url, result);
1358
+ this.loadingPromises.delete(url);
1359
+ return result;
1360
+ }).catch((error) => {
1361
+ this.loadingPromises.delete(url);
1362
+ throw new Error(`Failed to load OBJ ${url}: ${error.message}`);
1363
+ });
1364
+ this.loadingPromises.set(url, loadPromise);
1365
+ return (await loadPromise).scene;
1366
+ }
1367
+ /**
1368
+ * Load a texture
1369
+ * @param url - URL to the texture image
1370
+ * @returns Promise with loaded texture
1371
+ */
1372
+ async loadTexture(url) {
1373
+ if (this.textureCache.has(url)) {
1374
+ return this.textureCache.get(url);
1695
1375
  }
1696
- default:
1697
- return null;
1376
+ if (this.loadingPromises.has(`texture:${url}`)) {
1377
+ return this.loadingPromises.get(`texture:${url}`);
1378
+ }
1379
+ const loadPromise = this.textureLoader.loadAsync(url).then((texture) => {
1380
+ texture.colorSpace = THREE9.SRGBColorSpace;
1381
+ this.textureCache.set(url, texture);
1382
+ this.loadingPromises.delete(`texture:${url}`);
1383
+ return texture;
1384
+ }).catch((error) => {
1385
+ this.loadingPromises.delete(`texture:${url}`);
1386
+ throw new Error(`Failed to load texture ${url}: ${error.message}`);
1387
+ });
1388
+ this.loadingPromises.set(`texture:${url}`, loadPromise);
1389
+ return loadPromise;
1698
1390
  }
1699
- return /* @__PURE__ */ jsxs("mesh", { rotation: [-Math.PI / 2, 0, 0], position: [world[0], world[1] + 0.02, world[2]], children: [
1700
- geometry,
1701
- /* @__PURE__ */ jsx("meshBasicMaterial", { color, transparent: true, opacity: node.opacity ?? 1, side: THREE6.DoubleSide })
1702
- ] });
1703
- }
1704
- function Text3D({ node, projector }) {
1705
- const world = projector.toWorld(node.position);
1706
- return /* @__PURE__ */ jsx(Billboard, { position: [world[0], world[1] + 1.2, world[2]], children: /* @__PURE__ */ jsx(
1707
- Text,
1708
- {
1709
- fontSize: 0.32,
1710
- color: node.color,
1711
- anchorX: "center",
1712
- anchorY: "middle",
1713
- outlineWidth: 0.02,
1714
- outlineColor: "#000000",
1715
- children: node.text
1391
+ /**
1392
+ * Preload multiple assets
1393
+ * @param urls - Array of asset URLs to preload
1394
+ * @returns Promise that resolves when all assets are loaded
1395
+ */
1396
+ async preload(urls) {
1397
+ const promises = urls.map((url) => {
1398
+ if (url.endsWith(".glb") || url.endsWith(".gltf")) {
1399
+ return this.loadModel(url).catch(() => null);
1400
+ } else if (url.endsWith(".obj")) {
1401
+ return this.loadOBJ(url).catch(() => null);
1402
+ } else if (/\.(png|jpg|jpeg|webp)$/i.test(url)) {
1403
+ return this.loadTexture(url).catch(() => null);
1404
+ }
1405
+ return Promise.resolve(null);
1406
+ });
1407
+ await Promise.all(promises);
1408
+ }
1409
+ /**
1410
+ * Check if a model is cached
1411
+ * @param url - Model URL
1412
+ */
1413
+ hasModel(url) {
1414
+ return this.modelCache.has(url);
1415
+ }
1416
+ /**
1417
+ * Check if a texture is cached
1418
+ * @param url - Texture URL
1419
+ */
1420
+ hasTexture(url) {
1421
+ return this.textureCache.has(url);
1422
+ }
1423
+ /**
1424
+ * Get cached model (throws if not cached)
1425
+ * @param url - Model URL
1426
+ */
1427
+ getModel(url) {
1428
+ const model = this.modelCache.get(url);
1429
+ if (!model) {
1430
+ throw new Error(`Model ${url} not in cache`);
1716
1431
  }
1717
- ) });
1718
- }
1719
- function Drawable3D({ node, projector }) {
1720
- switch (node.type) {
1721
- case "draw-sprite":
1722
- return /* @__PURE__ */ jsx(Sprite3D, { node, projector });
1723
- case "draw-shape":
1724
- return /* @__PURE__ */ jsx(Shape3D, { node, projector });
1725
- case "draw-text":
1726
- return /* @__PURE__ */ jsx(Text3D, { node, projector });
1727
- case "draw-sprite-layer":
1728
- return /* @__PURE__ */ jsx(Fragment, { children: node.items.map((item, i) => /* @__PURE__ */ jsx(Sprite3D, { node: item, projector }, i)) });
1729
- case "draw-shape-layer":
1730
- return /* @__PURE__ */ jsx(Fragment, { children: node.items.map((item, i) => /* @__PURE__ */ jsx(Shape3D, { node: item, projector }, i)) });
1731
- case "draw-text-layer":
1732
- return /* @__PURE__ */ jsx(Fragment, { children: node.items.map((item, i) => /* @__PURE__ */ jsx(Text3D, { node: item, projector }, i)) });
1432
+ return model;
1433
+ }
1434
+ /**
1435
+ * Get cached texture (throws if not cached)
1436
+ * @param url - Texture URL
1437
+ */
1438
+ getTexture(url) {
1439
+ const texture = this.textureCache.get(url);
1440
+ if (!texture) {
1441
+ throw new Error(`Texture ${url} not in cache`);
1442
+ }
1443
+ return texture;
1444
+ }
1445
+ /**
1446
+ * Clear all caches
1447
+ */
1448
+ clearCache() {
1449
+ this.textureCache.forEach((texture) => {
1450
+ texture.dispose();
1451
+ });
1452
+ this.modelCache.forEach((model) => {
1453
+ model.scene.traverse((child) => {
1454
+ if (child instanceof THREE9.Mesh) {
1455
+ child.geometry.dispose();
1456
+ if (Array.isArray(child.material)) {
1457
+ child.material.forEach((m) => m.dispose());
1458
+ } else {
1459
+ child.material.dispose();
1460
+ }
1461
+ }
1462
+ });
1463
+ });
1464
+ this.modelCache.clear();
1465
+ this.textureCache.clear();
1466
+ this.loadingPromises.clear();
1467
+ }
1468
+ /**
1469
+ * Get cache statistics
1470
+ */
1471
+ getStats() {
1472
+ return {
1473
+ models: this.modelCache.size,
1474
+ textures: this.textureCache.size,
1475
+ loading: this.loadingPromises.size
1476
+ };
1733
1477
  }
1734
- }
1735
-
1736
- // lib/drawable/projector3d.ts
1737
- function create3DProjector(opts = {}) {
1738
- const cellSize = opts.cellSize ?? 1;
1739
- const offsetX = opts.offsetX ?? 0;
1740
- const offsetZ = opts.offsetZ ?? 0;
1741
- return {
1742
- cellSize,
1743
- toWorld: (pos) => [pos.x * cellSize + offsetX, pos.z ?? 0, pos.y * cellSize + offsetZ]
1744
- };
1745
- }
1746
-
1747
- // components/game/three/game3dTheme.ts
1748
- var GRID_COLORS_3D = {
1749
- cell: "#444444",
1750
- section: "#666666"
1751
1478
  };
1752
- var DEFAULT_BACKGROUND_3D = "#1a1a2e";
1753
- function cn(...inputs) {
1754
- return twMerge(clsx(inputs));
1755
- }
1756
- var DEFAULT_GRID_CONFIG = {
1757
- cellSize: 1,
1758
- offsetX: 0,
1759
- offsetZ: 0
1479
+ var assetLoader = new AssetLoader();
1480
+
1481
+ // lib/drawable/three/hooks/useThree.ts
1482
+ var DEFAULT_OPTIONS = {
1483
+ cameraMode: "isometric",
1484
+ cameraPosition: [10, 10, 10],
1485
+ backgroundColor: "#1a1a2e",
1486
+ shadows: true,
1487
+ showGrid: true,
1488
+ gridSize: 20,
1489
+ assetLoader: new AssetLoader()
1760
1490
  };
1761
- var GameCanvas3D = forwardRef(
1762
- ({
1763
- cameraMode = "isometric",
1764
- followTarget,
1765
- showGrid = true,
1766
- orientation = "standard",
1767
- overlay = "default",
1768
- shadows = true,
1769
- backgroundColor = DEFAULT_BACKGROUND_3D,
1770
- className,
1771
- isLoading: externalLoading,
1772
- error: externalError,
1773
- tileClickEvent,
1774
- unitClickEvent,
1775
- featureClickEvent,
1776
- canvasClickEvent,
1777
- tileHoverEvent,
1778
- tileLeaveEvent,
1779
- unitAnimationEvent,
1780
- cameraChangeEvent,
1781
- loadingMessage = "Loading 3D Scene...",
1782
- keyMap,
1783
- keyUpMap,
1784
- children,
1785
- drawables
1786
- }, ref) => {
1787
- const containerRef = useRef(null);
1788
- const controlsRef = useRef(null);
1789
- const [internalError, setInternalError] = useState(null);
1790
- const eventBus = useEventBus();
1791
- const keysRef = useRef(/* @__PURE__ */ new Set());
1792
- useEffect(() => {
1793
- if (!keyMap && !keyUpMap) return;
1794
- const down = (e) => {
1795
- if (keysRef.current.has(e.code)) return;
1796
- keysRef.current.add(e.code);
1797
- const ev = keyMap?.[e.code];
1798
- if (ev) {
1799
- eventBus.emit(`UI:${ev}`, {});
1800
- e.preventDefault();
1801
- }
1802
- };
1803
- const up = (e) => {
1804
- keysRef.current.delete(e.code);
1805
- const ev = keyUpMap?.[e.code];
1806
- if (ev) eventBus.emit(`UI:${ev}`, {});
1807
- };
1808
- window.addEventListener("keydown", down);
1809
- window.addEventListener("keyup", up);
1810
- return () => {
1811
- window.removeEventListener("keydown", down);
1812
- window.removeEventListener("keyup", up);
1813
- };
1814
- }, [keyMap, keyUpMap, eventBus]);
1815
- const eventHandlers = useGameCanvas3DEvents({
1816
- tileClickEvent,
1817
- unitClickEvent,
1818
- featureClickEvent,
1819
- canvasClickEvent,
1820
- tileHoverEvent,
1821
- tileLeaveEvent,
1822
- unitAnimationEvent,
1823
- cameraChangeEvent
1491
+ function useThree4(options = {}) {
1492
+ const opts = { ...DEFAULT_OPTIONS, ...options };
1493
+ const containerRef = useRef(null);
1494
+ const canvasRef = useRef(null);
1495
+ const rendererRef = useRef(null);
1496
+ const sceneRef = useRef(null);
1497
+ const cameraRef = useRef(null);
1498
+ const controlsRef = useRef(null);
1499
+ const gridHelperRef = useRef(null);
1500
+ const rafRef = useRef(0);
1501
+ const [isReady, setIsReady] = useState(false);
1502
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
1503
+ const initialCameraPosition = useMemo(
1504
+ () => new THREE9.Vector3(...opts.cameraPosition),
1505
+ []
1506
+ );
1507
+ useEffect(() => {
1508
+ if (!containerRef.current) return;
1509
+ const container = containerRef.current;
1510
+ const { clientWidth, clientHeight } = container;
1511
+ const scene = new THREE9.Scene();
1512
+ scene.background = new THREE9.Color(opts.backgroundColor);
1513
+ sceneRef.current = scene;
1514
+ let camera;
1515
+ const aspect = clientWidth / clientHeight;
1516
+ if (opts.cameraMode === "isometric") {
1517
+ const size = 10;
1518
+ camera = new THREE9.OrthographicCamera(
1519
+ -size * aspect,
1520
+ size * aspect,
1521
+ size,
1522
+ -size,
1523
+ 0.1,
1524
+ 1e3
1525
+ );
1526
+ } else {
1527
+ camera = new THREE9.PerspectiveCamera(45, aspect, 0.1, 1e3);
1528
+ }
1529
+ camera.position.copy(initialCameraPosition);
1530
+ cameraRef.current = camera;
1531
+ const renderer = new THREE9.WebGLRenderer({
1532
+ antialias: true,
1533
+ alpha: true,
1534
+ canvas: canvasRef.current || void 0
1824
1535
  });
1825
- const drawnItems = useMemo(() => collectDrawnItems(drawables ?? []), [drawables]);
1826
- const scenePositions = useMemo(() => drawnItems.map((i) => i.pos), [drawnItems]);
1827
- const hitIndex = useMemo(() => buildHitIndex(drawnItems), [drawnItems]);
1828
- const gridBounds = useMemo(() => {
1829
- if (scenePositions.length === 0) {
1830
- return { minX: 0, maxX: 10, minZ: 0, maxZ: 10 };
1831
- }
1832
- let minX = Infinity;
1833
- let maxX = -Infinity;
1834
- let minZ = Infinity;
1835
- let maxZ = -Infinity;
1836
- for (const p of scenePositions) {
1837
- if (p.x < minX) minX = p.x;
1838
- if (p.x > maxX) maxX = p.x;
1839
- if (p.y < minZ) minZ = p.y;
1840
- if (p.y > maxZ) maxZ = p.y;
1536
+ renderer.setSize(clientWidth, clientHeight);
1537
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
1538
+ renderer.shadowMap.enabled = opts.shadows;
1539
+ renderer.shadowMap.type = THREE9.PCFSoftShadowMap;
1540
+ rendererRef.current = renderer;
1541
+ const controls = new OrbitControls$1(camera, renderer.domElement);
1542
+ controls.enableDamping = true;
1543
+ controls.dampingFactor = 0.05;
1544
+ controls.minDistance = 2;
1545
+ controls.maxDistance = 100;
1546
+ controls.maxPolarAngle = Math.PI / 2 - 0.1;
1547
+ controlsRef.current = controls;
1548
+ const ambientLight = new THREE9.AmbientLight(16777215, 0.6);
1549
+ scene.add(ambientLight);
1550
+ const directionalLight = new THREE9.DirectionalLight(16777215, 0.8);
1551
+ directionalLight.position.set(10, 20, 10);
1552
+ directionalLight.castShadow = opts.shadows;
1553
+ directionalLight.shadow.mapSize.width = 2048;
1554
+ directionalLight.shadow.mapSize.height = 2048;
1555
+ scene.add(directionalLight);
1556
+ if (opts.showGrid) {
1557
+ const gridHelper = new THREE9.GridHelper(
1558
+ opts.gridSize,
1559
+ opts.gridSize,
1560
+ 4473924,
1561
+ 2236962
1562
+ );
1563
+ scene.add(gridHelper);
1564
+ gridHelperRef.current = gridHelper;
1565
+ }
1566
+ const animate = () => {
1567
+ rafRef.current = requestAnimationFrame(animate);
1568
+ controls.update();
1569
+ renderer.render(scene, camera);
1570
+ };
1571
+ animate();
1572
+ const handleResize = () => {
1573
+ const { clientWidth: width, clientHeight: height } = container;
1574
+ setDimensions({ width, height });
1575
+ if (camera instanceof THREE9.PerspectiveCamera) {
1576
+ camera.aspect = width / height;
1577
+ camera.updateProjectionMatrix();
1578
+ } else if (camera instanceof THREE9.OrthographicCamera) {
1579
+ const aspect2 = width / height;
1580
+ const size = 10;
1581
+ camera.left = -size * aspect2;
1582
+ camera.right = size * aspect2;
1583
+ camera.top = size;
1584
+ camera.bottom = -size;
1585
+ camera.updateProjectionMatrix();
1841
1586
  }
1842
- return { minX, maxX, minZ, maxZ };
1843
- }, [scenePositions]);
1844
- const cameraTarget = useMemo(
1845
- () => [
1846
- (gridBounds.minX + gridBounds.maxX) / 2,
1847
- 0,
1848
- (gridBounds.minZ + gridBounds.maxZ) / 2
1849
- ],
1850
- [gridBounds]
1851
- );
1852
- const gridConfig = useMemo(
1853
- () => ({
1854
- ...DEFAULT_GRID_CONFIG,
1855
- offsetX: -(gridBounds.maxX - gridBounds.minX) / 2,
1856
- offsetZ: -(gridBounds.maxZ - gridBounds.minZ) / 2
1857
- }),
1858
- [gridBounds]
1859
- );
1860
- const drawableProjector = useMemo(
1861
- () => create3DProjector({
1862
- cellSize: gridConfig.cellSize,
1863
- offsetX: -gridBounds.minX * gridConfig.cellSize,
1864
- offsetZ: -gridBounds.minZ * gridConfig.cellSize
1865
- }),
1866
- [gridBounds, gridConfig]
1867
- );
1868
- useImperativeHandle(ref, () => ({
1869
- getCameraPosition: () => {
1870
- if (controlsRef.current) {
1871
- const pos = controlsRef.current.object.position;
1872
- return new THREE6.Vector3(pos.x, pos.y, pos.z);
1873
- }
1874
- return null;
1875
- },
1876
- setCameraPosition: (x, y, z) => {
1877
- if (controlsRef.current) {
1878
- controlsRef.current.object.position.set(x, y, z);
1879
- controlsRef.current.update();
1880
- }
1881
- },
1882
- lookAt: (x, y, z) => {
1883
- if (controlsRef.current) {
1884
- controlsRef.current.target.set(x, y, z);
1885
- controlsRef.current.update();
1886
- }
1887
- },
1888
- resetCamera: () => {
1889
- if (controlsRef.current) {
1890
- controlsRef.current.reset();
1891
- }
1892
- },
1893
- screenshot: () => {
1894
- const canvas = containerRef.current?.querySelector("canvas");
1895
- if (canvas) {
1896
- return canvas.toDataURL("image/png");
1897
- }
1898
- return null;
1899
- }
1900
- }));
1901
- const cameraConfig = useMemo(() => {
1902
- const size = Math.max(
1903
- gridBounds.maxX - gridBounds.minX,
1904
- gridBounds.maxZ - gridBounds.minZ,
1905
- 4
1906
- // minimum framing distance so a tiny board isn't zoomed in
1587
+ renderer.setSize(width, height);
1588
+ };
1589
+ window.addEventListener("resize", handleResize);
1590
+ handleResize();
1591
+ setIsReady(true);
1592
+ return () => {
1593
+ window.removeEventListener("resize", handleResize);
1594
+ cancelAnimationFrame(rafRef.current);
1595
+ controls.dispose();
1596
+ renderer.dispose();
1597
+ scene.clear();
1598
+ };
1599
+ }, []);
1600
+ useEffect(() => {
1601
+ if (!cameraRef.current || !containerRef.current) return;
1602
+ const container = containerRef.current;
1603
+ const { clientWidth, clientHeight } = container;
1604
+ const aspect = clientWidth / clientHeight;
1605
+ const currentPos = cameraRef.current.position.clone();
1606
+ let newCamera;
1607
+ if (opts.cameraMode === "isometric") {
1608
+ const size = 10;
1609
+ newCamera = new THREE9.OrthographicCamera(
1610
+ -size * aspect,
1611
+ size * aspect,
1612
+ size,
1613
+ -size,
1614
+ 0.1,
1615
+ 1e3
1907
1616
  );
1908
- const cx = (gridBounds.minX + gridBounds.maxX) / 2;
1909
- const cz = (gridBounds.minZ + gridBounds.maxZ) / 2;
1910
- const d = size * 1;
1911
- switch (cameraMode) {
1912
- case "isometric":
1913
- return { position: [cx + d, d * 0.8, cz + d], fov: 45 };
1914
- case "top-down":
1915
- return { position: [cx, d * 2, cz + d * 0.35], fov: 45 };
1916
- case "follow":
1917
- return { position: [cx, d * 0.5, cz + d], fov: 45 };
1918
- case "perspective":
1919
- default:
1920
- return { position: [cx + d, d, cz + d], fov: 45 };
1921
- }
1922
- }, [cameraMode, gridBounds]);
1923
- const followWorld = useMemo(() => {
1924
- if (followTarget) return drawableProjector.toWorld(followTarget);
1925
- return cameraTarget;
1926
- }, [followTarget, drawableProjector, cameraTarget]);
1927
- const followOffset = useMemo(
1928
- () => cameraMode === "chase" ? [0, 4, -7] : [5, 7, 5],
1929
- [cameraMode]
1930
- );
1931
- const handleGroundClick = useCallback((e) => {
1932
- if (!tileClickEvent && !unitClickEvent) return;
1933
- e.stopPropagation();
1934
- const cell = {
1935
- x: Math.round(e.point.x / gridConfig.cellSize + gridBounds.minX),
1936
- y: Math.round(e.point.z / gridConfig.cellSize + gridBounds.minZ)
1617
+ } else {
1618
+ newCamera = new THREE9.PerspectiveCamera(45, aspect, 0.1, 1e3);
1619
+ }
1620
+ newCamera.position.copy(currentPos);
1621
+ cameraRef.current = newCamera;
1622
+ if (controlsRef.current) {
1623
+ controlsRef.current.object = newCamera;
1624
+ }
1625
+ if (rendererRef.current) {
1626
+ const animate = () => {
1627
+ rafRef.current = requestAnimationFrame(animate);
1628
+ controlsRef.current?.update();
1629
+ rendererRef.current?.render(sceneRef.current, newCamera);
1937
1630
  };
1938
- const hitId = hitIndex.get(`${cell.x},${cell.y}`);
1939
- if (hitId !== void 0 && unitClickEvent) {
1940
- eventBus.emit(`UI:${unitClickEvent}`, { unitId: hitId, x: cell.x, z: cell.y });
1941
- } else if (tileClickEvent) {
1942
- eventBus.emit(`UI:${tileClickEvent}`, { x: cell.x, z: cell.y });
1631
+ cancelAnimationFrame(rafRef.current);
1632
+ animate();
1633
+ }
1634
+ }, [opts.cameraMode]);
1635
+ const setCameraPosition = useCallback((x, y, z) => {
1636
+ if (cameraRef.current) {
1637
+ cameraRef.current.position.set(x, y, z);
1638
+ controlsRef.current?.update();
1639
+ }
1640
+ }, []);
1641
+ const lookAt = useCallback((x, y, z) => {
1642
+ if (cameraRef.current) {
1643
+ cameraRef.current.lookAt(x, y, z);
1644
+ controlsRef.current?.target.set(x, y, z);
1645
+ controlsRef.current?.update();
1646
+ }
1647
+ }, []);
1648
+ const resetCamera = useCallback(() => {
1649
+ if (cameraRef.current) {
1650
+ cameraRef.current.position.copy(initialCameraPosition);
1651
+ cameraRef.current.lookAt(0, 0, 0);
1652
+ if (controlsRef.current) {
1653
+ controlsRef.current.target.set(0, 0, 0);
1654
+ controlsRef.current.update();
1943
1655
  }
1944
- }, [tileClickEvent, unitClickEvent, gridConfig, gridBounds, hitIndex, eventBus]);
1945
- if (externalLoading) {
1946
- return /* @__PURE__ */ jsx(
1947
- Canvas3DLoadingState,
1948
- {
1949
- progress: 1,
1950
- loaded: 1,
1951
- total: 1,
1952
- message: loadingMessage,
1953
- className
1954
- }
1955
- );
1956
1656
  }
1957
- const displayError = externalError || internalError;
1958
- if (displayError) {
1959
- return /* @__PURE__ */ jsx(Canvas3DErrorBoundary, { children: /* @__PURE__ */ jsx("div", { className: "game-canvas-3d game-canvas-3d--error", children: /* @__PURE__ */ jsxs("div", { className: "game-canvas-3d__error", children: [
1960
- "Error: ",
1961
- displayError
1962
- ] }) }) });
1657
+ }, [initialCameraPosition]);
1658
+ const fitView = useCallback(
1659
+ (bounds) => {
1660
+ if (!cameraRef.current) return;
1661
+ const centerX = (bounds.minX + bounds.maxX) / 2;
1662
+ const centerZ = (bounds.minZ + bounds.maxZ) / 2;
1663
+ const width = bounds.maxX - bounds.minX;
1664
+ const depth = bounds.maxZ - bounds.minZ;
1665
+ const maxDim = Math.max(width, depth);
1666
+ const distance = maxDim * 1.5;
1667
+ const height = distance * 0.8;
1668
+ cameraRef.current.position.set(centerX + distance, height, centerZ + distance);
1669
+ lookAt(centerX, 0, centerZ);
1670
+ },
1671
+ [lookAt]
1672
+ );
1673
+ return {
1674
+ canvasRef,
1675
+ renderer: rendererRef.current,
1676
+ scene: sceneRef.current,
1677
+ camera: cameraRef.current,
1678
+ controls: controlsRef.current,
1679
+ isReady,
1680
+ dimensions,
1681
+ setCameraPosition,
1682
+ lookAt,
1683
+ resetCamera,
1684
+ fitView
1685
+ };
1686
+ }
1687
+ function useAssetLoader(options = {}) {
1688
+ const { preloadUrls = [], loader: customLoader } = options;
1689
+ const loaderRef = useRef(customLoader || new AssetLoader());
1690
+ const [state, setState] = useState({
1691
+ isLoading: false,
1692
+ progress: 0,
1693
+ loaded: 0,
1694
+ total: 0,
1695
+ errors: []
1696
+ });
1697
+ useEffect(() => {
1698
+ if (preloadUrls.length > 0) {
1699
+ preload(preloadUrls);
1963
1700
  }
1964
- return /* @__PURE__ */ jsx(
1965
- Canvas3DErrorBoundary,
1966
- {
1967
- onError: (err) => setInternalError(err.message),
1968
- onReset: () => setInternalError(null),
1969
- children: /* @__PURE__ */ jsx(
1970
- "div",
1971
- {
1972
- ref: containerRef,
1973
- className: cn("game-canvas-3d relative w-full overflow-hidden", className),
1974
- style: { flex: "1 1 0", minHeight: "400px" },
1975
- "data-orientation": orientation,
1976
- "data-camera-mode": cameraMode,
1977
- "data-overlay": overlay,
1978
- children: /* @__PURE__ */ jsxs(
1979
- Canvas,
1980
- {
1981
- shadows,
1982
- camera: {
1983
- position: cameraConfig.position,
1984
- fov: cameraConfig.fov,
1985
- near: 0.1,
1986
- far: 1e3
1987
- },
1988
- style: { background: backgroundColor, position: "absolute", inset: 0 },
1989
- onClick: (e) => {
1990
- if (e.target === e.currentTarget) {
1991
- eventHandlers.handleCanvasClick(e);
1992
- }
1993
- },
1994
- children: [
1995
- /* @__PURE__ */ jsx(CameraController3D, { onCameraChange: eventHandlers.handleCameraChange }),
1996
- (cameraMode === "follow" || cameraMode === "chase") && /* @__PURE__ */ jsx(FollowCamera3D, { target: followWorld, offset: followOffset }),
1997
- /* @__PURE__ */ jsx(
1998
- Lighting3D,
1999
- {
2000
- shadows,
2001
- shadowBias: -4e-4,
2002
- shadowNormalBias: 0.04,
2003
- shadowCameraSize: 5,
2004
- shadowCameraNear: 0.5,
2005
- shadowCameraFar: 500
2006
- }
2007
- ),
2008
- showGrid && /* @__PURE__ */ jsx(
2009
- Grid,
2010
- {
2011
- args: [
2012
- Math.max(gridBounds.maxX - gridBounds.minX + 2, 10),
2013
- Math.max(gridBounds.maxZ - gridBounds.minZ + 2, 10)
2014
- ],
2015
- position: [
2016
- (gridBounds.maxX - gridBounds.minX) / 2 - 0.5,
2017
- 0,
2018
- (gridBounds.maxZ - gridBounds.minZ) / 2 - 0.5
2019
- ],
2020
- cellSize: 1,
2021
- cellThickness: 1,
2022
- cellColor: GRID_COLORS_3D.cell,
2023
- sectionSize: 5,
2024
- sectionThickness: 1.5,
2025
- sectionColor: GRID_COLORS_3D.section,
2026
- fadeDistance: 50,
2027
- fadeStrength: 1
2028
- }
2029
- ),
2030
- drawables && drawables.length > 0 && /* @__PURE__ */ jsx("group", { children: drawables.map((node, i) => /* @__PURE__ */ jsx(Drawable3D, { node, projector: drawableProjector }, i)) }),
2031
- (tileClickEvent || unitClickEvent) && /* @__PURE__ */ jsxs(
2032
- "mesh",
2033
- {
2034
- rotation: [-Math.PI / 2, 0, 0],
2035
- position: [
2036
- (gridBounds.maxX - gridBounds.minX) / 2 * gridConfig.cellSize,
2037
- 0,
2038
- (gridBounds.maxZ - gridBounds.minZ) / 2 * gridConfig.cellSize
2039
- ],
2040
- onClick: handleGroundClick,
2041
- children: [
2042
- /* @__PURE__ */ jsx(
2043
- "planeGeometry",
2044
- {
2045
- args: [
2046
- (gridBounds.maxX - gridBounds.minX + 4) * gridConfig.cellSize,
2047
- (gridBounds.maxZ - gridBounds.minZ + 4) * gridConfig.cellSize
2048
- ]
2049
- }
2050
- ),
2051
- /* @__PURE__ */ jsx("meshBasicMaterial", { transparent: true, opacity: 0, depthWrite: false })
2052
- ]
2053
- }
2054
- ),
2055
- children,
2056
- /* @__PURE__ */ jsx(
2057
- OrbitControls,
2058
- {
2059
- ref: controlsRef,
2060
- enabled: cameraMode !== "follow" && cameraMode !== "chase",
2061
- target: cameraTarget,
2062
- enableDamping: true,
2063
- dampingFactor: 0.05,
2064
- enableZoom: true,
2065
- enablePan: true,
2066
- touches: { ONE: THREE6.TOUCH.ROTATE, TWO: THREE6.TOUCH.DOLLY_PAN },
2067
- minDistance: 2,
2068
- maxDistance: 100,
2069
- maxPolarAngle: Math.PI / 2 - 0.1
2070
- }
2071
- )
2072
- ]
2073
- }
2074
- )
1701
+ }, []);
1702
+ const updateProgress = useCallback((loaded, total) => {
1703
+ setState((prev) => ({
1704
+ ...prev,
1705
+ loaded,
1706
+ total,
1707
+ progress: total > 0 ? Math.round(loaded / total * 100) : 0
1708
+ }));
1709
+ }, []);
1710
+ const loadModel = useCallback(
1711
+ async (url) => {
1712
+ setState((prev) => ({ ...prev, isLoading: true }));
1713
+ try {
1714
+ const model = await loaderRef.current.loadModel(url);
1715
+ setState((prev) => ({
1716
+ ...prev,
1717
+ isLoading: false,
1718
+ loaded: prev.loaded + 1
1719
+ }));
1720
+ return model;
1721
+ } catch (error) {
1722
+ const errorMsg = error instanceof Error ? error.message : String(error);
1723
+ setState((prev) => ({
1724
+ ...prev,
1725
+ isLoading: false,
1726
+ errors: [...prev.errors, errorMsg]
1727
+ }));
1728
+ throw error;
1729
+ }
1730
+ },
1731
+ []
1732
+ );
1733
+ const loadOBJ = useCallback(
1734
+ async (url) => {
1735
+ setState((prev) => ({ ...prev, isLoading: true }));
1736
+ try {
1737
+ const model = await loaderRef.current.loadOBJ(url);
1738
+ setState((prev) => ({
1739
+ ...prev,
1740
+ isLoading: false,
1741
+ loaded: prev.loaded + 1
1742
+ }));
1743
+ return model;
1744
+ } catch (error) {
1745
+ const errorMsg = error instanceof Error ? error.message : String(error);
1746
+ setState((prev) => ({
1747
+ ...prev,
1748
+ isLoading: false,
1749
+ errors: [...prev.errors, errorMsg]
1750
+ }));
1751
+ throw error;
1752
+ }
1753
+ },
1754
+ []
1755
+ );
1756
+ const loadTexture = useCallback(
1757
+ async (url) => {
1758
+ setState((prev) => ({ ...prev, isLoading: true }));
1759
+ try {
1760
+ const texture = await loaderRef.current.loadTexture(url);
1761
+ setState((prev) => ({
1762
+ ...prev,
1763
+ isLoading: false,
1764
+ loaded: prev.loaded + 1
1765
+ }));
1766
+ return texture;
1767
+ } catch (error) {
1768
+ const errorMsg = error instanceof Error ? error.message : String(error);
1769
+ setState((prev) => ({
1770
+ ...prev,
1771
+ isLoading: false,
1772
+ errors: [...prev.errors, errorMsg]
1773
+ }));
1774
+ throw error;
1775
+ }
1776
+ },
1777
+ []
1778
+ );
1779
+ const preload = useCallback(
1780
+ async (urls) => {
1781
+ setState((prev) => ({
1782
+ ...prev,
1783
+ isLoading: true,
1784
+ total: urls.length,
1785
+ loaded: 0,
1786
+ errors: []
1787
+ }));
1788
+ let completed = 0;
1789
+ const errors = [];
1790
+ await Promise.all(
1791
+ urls.map(async (url) => {
1792
+ try {
1793
+ if (url.endsWith(".glb") || url.endsWith(".gltf")) {
1794
+ await loaderRef.current.loadModel(url);
1795
+ } else if (url.endsWith(".obj")) {
1796
+ await loaderRef.current.loadOBJ(url);
1797
+ } else if (/\.(png|jpg|jpeg|webp)$/i.test(url)) {
1798
+ await loaderRef.current.loadTexture(url);
1799
+ }
1800
+ completed++;
1801
+ updateProgress(completed, urls.length);
1802
+ } catch (error) {
1803
+ const errorMsg = error instanceof Error ? error.message : String(error);
1804
+ errors.push(`${url}: ${errorMsg}`);
1805
+ completed++;
1806
+ updateProgress(completed, urls.length);
2075
1807
  }
2076
- )
1808
+ })
1809
+ );
1810
+ setState((prev) => ({
1811
+ ...prev,
1812
+ isLoading: false,
1813
+ errors
1814
+ }));
1815
+ },
1816
+ [updateProgress]
1817
+ );
1818
+ const hasModel = useCallback((url) => {
1819
+ return loaderRef.current.hasModel(url);
1820
+ }, []);
1821
+ const hasTexture = useCallback((url) => {
1822
+ return loaderRef.current.hasTexture(url);
1823
+ }, []);
1824
+ const getModel = useCallback((url) => {
1825
+ try {
1826
+ return loaderRef.current.getModel(url);
1827
+ } catch {
1828
+ return void 0;
1829
+ }
1830
+ }, []);
1831
+ const getTexture = useCallback((url) => {
1832
+ try {
1833
+ return loaderRef.current.getTexture(url);
1834
+ } catch {
1835
+ return void 0;
1836
+ }
1837
+ }, []);
1838
+ const clearCache = useCallback(() => {
1839
+ loaderRef.current.clearCache();
1840
+ setState({
1841
+ isLoading: false,
1842
+ progress: 0,
1843
+ loaded: 0,
1844
+ total: 0,
1845
+ errors: []
1846
+ });
1847
+ }, []);
1848
+ return {
1849
+ ...state,
1850
+ loadModel,
1851
+ loadOBJ,
1852
+ loadTexture,
1853
+ preload,
1854
+ hasModel,
1855
+ hasTexture,
1856
+ getModel,
1857
+ getTexture,
1858
+ clearCache
1859
+ };
1860
+ }
1861
+ function useSceneGraph() {
1862
+ const nodesRef = useRef(/* @__PURE__ */ new Map());
1863
+ const addNode = useCallback((node) => {
1864
+ const existing = nodesRef.current.get(node.id);
1865
+ if (existing) {
1866
+ existing.mesh.removeFromParent();
1867
+ }
1868
+ nodesRef.current.set(node.id, node);
1869
+ }, []);
1870
+ const removeNode = useCallback((id) => {
1871
+ const node = nodesRef.current.get(id);
1872
+ if (node) {
1873
+ node.mesh.removeFromParent();
1874
+ nodesRef.current.delete(id);
1875
+ }
1876
+ }, []);
1877
+ const getNode = useCallback((id) => {
1878
+ return nodesRef.current.get(id);
1879
+ }, []);
1880
+ const updateNodePosition = useCallback(
1881
+ (id, x, y, z) => {
1882
+ const node = nodesRef.current.get(id);
1883
+ if (node) {
1884
+ node.mesh.position.set(x, y, z);
1885
+ node.position = { x, y, z };
1886
+ }
1887
+ },
1888
+ []
1889
+ );
1890
+ const updateNodeGridPosition = useCallback(
1891
+ (id, gridX, gridZ) => {
1892
+ const node = nodesRef.current.get(id);
1893
+ if (node) {
1894
+ node.gridPosition = { x: gridX, z: gridZ };
1895
+ }
1896
+ },
1897
+ []
1898
+ );
1899
+ const getNodeAtGrid = useCallback(
1900
+ (x, z, type) => {
1901
+ return Array.from(nodesRef.current.values()).find((node) => {
1902
+ const matchesGrid = node.gridPosition.x === x && node.gridPosition.z === z;
1903
+ return type ? matchesGrid && node.type === type : matchesGrid;
1904
+ });
1905
+ },
1906
+ []
1907
+ );
1908
+ const getNodesByType = useCallback((type) => {
1909
+ return Array.from(nodesRef.current.values()).filter((node) => node.type === type);
1910
+ }, []);
1911
+ const getNodesInBounds = useCallback(
1912
+ (minX, maxX, minZ, maxZ) => {
1913
+ return Array.from(nodesRef.current.values()).filter((node) => {
1914
+ const { x, z } = node.gridPosition;
1915
+ return x >= minX && x <= maxX && z >= minZ && z <= maxZ;
1916
+ });
1917
+ },
1918
+ []
1919
+ );
1920
+ const clearNodes = useCallback(() => {
1921
+ nodesRef.current.forEach((node) => {
1922
+ node.mesh.removeFromParent();
1923
+ });
1924
+ nodesRef.current.clear();
1925
+ }, []);
1926
+ const countNodes = useCallback((type) => {
1927
+ if (!type) {
1928
+ return nodesRef.current.size;
1929
+ }
1930
+ return Array.from(nodesRef.current.values()).filter((node) => node.type === type).length;
1931
+ }, []);
1932
+ return {
1933
+ nodesRef,
1934
+ addNode,
1935
+ removeNode,
1936
+ getNode,
1937
+ updateNodePosition,
1938
+ updateNodeGridPosition,
1939
+ getNodeAtGrid,
1940
+ getNodesByType,
1941
+ getNodesInBounds,
1942
+ clearNodes,
1943
+ countNodes
1944
+ };
1945
+ }
1946
+ function useRaycaster(options) {
1947
+ const { camera, canvas, cellSize = 1, offsetX = 0, offsetZ = 0 } = options;
1948
+ const raycaster = useRef(new THREE9.Raycaster());
1949
+ const mouse = useRef(new THREE9.Vector2());
1950
+ const clientToNDC = useCallback(
1951
+ (clientX, clientY) => {
1952
+ if (!canvas) {
1953
+ return { x: 0, y: 0 };
1954
+ }
1955
+ const rect = canvas.getBoundingClientRect();
1956
+ return {
1957
+ x: (clientX - rect.left) / rect.width * 2 - 1,
1958
+ y: -((clientY - rect.top) / rect.height) * 2 + 1
1959
+ };
1960
+ },
1961
+ [canvas]
1962
+ );
1963
+ const isWithinCanvas = useCallback(
1964
+ (clientX, clientY) => {
1965
+ if (!canvas) return false;
1966
+ const rect = canvas.getBoundingClientRect();
1967
+ return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
1968
+ },
1969
+ [canvas]
1970
+ );
1971
+ const getIntersection = useCallback(
1972
+ (clientX, clientY, objects) => {
1973
+ if (!camera || !canvas) return null;
1974
+ const ndc = clientToNDC(clientX, clientY);
1975
+ mouse.current.set(ndc.x, ndc.y);
1976
+ raycaster.current.setFromCamera(mouse.current, camera);
1977
+ const intersects = raycaster.current.intersectObjects(objects, true);
1978
+ if (intersects.length > 0) {
1979
+ const hit = intersects[0];
1980
+ return {
1981
+ object: hit.object,
1982
+ point: hit.point,
1983
+ distance: hit.distance,
1984
+ uv: hit.uv,
1985
+ face: hit.face,
1986
+ faceIndex: hit.faceIndex,
1987
+ instanceId: hit.instanceId
1988
+ };
2077
1989
  }
2078
- );
2079
- }
2080
- );
2081
- GameCanvas3D.displayName = "GameCanvas3D";
1990
+ return null;
1991
+ },
1992
+ [camera, canvas, clientToNDC]
1993
+ );
1994
+ const getAllIntersections = useCallback(
1995
+ (clientX, clientY, objects) => {
1996
+ if (!camera || !canvas) return [];
1997
+ const ndc = clientToNDC(clientX, clientY);
1998
+ mouse.current.set(ndc.x, ndc.y);
1999
+ raycaster.current.setFromCamera(mouse.current, camera);
2000
+ const intersects = raycaster.current.intersectObjects(objects, true);
2001
+ return intersects.map((hit) => ({
2002
+ object: hit.object,
2003
+ point: hit.point,
2004
+ distance: hit.distance,
2005
+ uv: hit.uv,
2006
+ face: hit.face,
2007
+ faceIndex: hit.faceIndex,
2008
+ instanceId: hit.instanceId
2009
+ }));
2010
+ },
2011
+ [camera, canvas, clientToNDC]
2012
+ );
2013
+ const getGridCoordinates = useCallback(
2014
+ (clientX, clientY) => {
2015
+ if (!camera || !canvas) return null;
2016
+ const ndc = clientToNDC(clientX, clientY);
2017
+ mouse.current.set(ndc.x, ndc.y);
2018
+ raycaster.current.setFromCamera(mouse.current, camera);
2019
+ const plane = new THREE9.Plane(new THREE9.Vector3(0, 1, 0), 0);
2020
+ const target = new THREE9.Vector3();
2021
+ const intersection = raycaster.current.ray.intersectPlane(plane, target);
2022
+ if (intersection) {
2023
+ const gridX = Math.round((target.x - offsetX) / cellSize);
2024
+ const gridZ = Math.round((target.z - offsetZ) / cellSize);
2025
+ return { x: gridX, z: gridZ };
2026
+ }
2027
+ return null;
2028
+ },
2029
+ [camera, canvas, cellSize, offsetX, offsetZ, clientToNDC]
2030
+ );
2031
+ const getTileAtPosition = useCallback(
2032
+ (clientX, clientY, scene) => {
2033
+ if (!camera || !canvas) return null;
2034
+ const tileMeshes = [];
2035
+ scene.traverse((obj) => {
2036
+ if (obj.userData.type === "tile" || obj.userData.isTile) {
2037
+ tileMeshes.push(obj);
2038
+ }
2039
+ });
2040
+ const hit = getIntersection(clientX, clientY, tileMeshes);
2041
+ if (hit) {
2042
+ const gridCoords2 = getGridCoordinates(clientX, clientY);
2043
+ if (gridCoords2) {
2044
+ return {
2045
+ gridX: gridCoords2.x,
2046
+ gridZ: gridCoords2.z,
2047
+ worldPosition: hit.point,
2048
+ objectType: hit.object.userData.type || "tile",
2049
+ objectId: hit.object.userData.id || hit.object.userData.tileId
2050
+ };
2051
+ }
2052
+ }
2053
+ const gridCoords = getGridCoordinates(clientX, clientY);
2054
+ if (gridCoords) {
2055
+ return {
2056
+ gridX: gridCoords.x,
2057
+ gridZ: gridCoords.z,
2058
+ worldPosition: new THREE9.Vector3(
2059
+ gridCoords.x * cellSize + offsetX,
2060
+ 0,
2061
+ gridCoords.z * cellSize + offsetZ
2062
+ )
2063
+ };
2064
+ }
2065
+ return null;
2066
+ },
2067
+ [camera, canvas, getIntersection, getGridCoordinates, cellSize, offsetX, offsetZ]
2068
+ );
2069
+ return {
2070
+ raycaster,
2071
+ mouse,
2072
+ getIntersection,
2073
+ getAllIntersections,
2074
+ getGridCoordinates,
2075
+ getTileAtPosition,
2076
+ clientToNDC,
2077
+ isWithinCanvas
2078
+ };
2079
+ }
2082
2080
  var DEFAULT_CONFIG = {
2083
2081
  cellSize: 1,
2084
2082
  offsetX: 0,
@@ -2087,7 +2085,7 @@ var DEFAULT_CONFIG = {
2087
2085
  };
2088
2086
  function gridToWorld(gridX, gridZ, config = DEFAULT_CONFIG) {
2089
2087
  const opts = { ...DEFAULT_CONFIG, ...config };
2090
- return new THREE6.Vector3(
2088
+ return new THREE9.Vector3(
2091
2089
  gridX * opts.cellSize + opts.offsetX,
2092
2090
  opts.elevation,
2093
2091
  gridZ * opts.cellSize + opts.offsetZ
@@ -2101,17 +2099,17 @@ function worldToGrid(worldX, worldZ, config = DEFAULT_CONFIG) {
2101
2099
  };
2102
2100
  }
2103
2101
  function raycastToPlane(camera, mouseX, mouseY, planeY = 0) {
2104
- const raycaster = new THREE6.Raycaster();
2105
- const mouse = new THREE6.Vector2(mouseX, mouseY);
2102
+ const raycaster = new THREE9.Raycaster();
2103
+ const mouse = new THREE9.Vector2(mouseX, mouseY);
2106
2104
  raycaster.setFromCamera(mouse, camera);
2107
- const plane = new THREE6.Plane(new THREE6.Vector3(0, 1, 0), -planeY);
2108
- const target = new THREE6.Vector3();
2105
+ const plane = new THREE9.Plane(new THREE9.Vector3(0, 1, 0), -planeY);
2106
+ const target = new THREE9.Vector3();
2109
2107
  const intersection = raycaster.ray.intersectPlane(plane, target);
2110
2108
  return intersection ? target : null;
2111
2109
  }
2112
2110
  function raycastToObjects(camera, mouseX, mouseY, objects) {
2113
- const raycaster = new THREE6.Raycaster();
2114
- const mouse = new THREE6.Vector2(mouseX, mouseY);
2111
+ const raycaster = new THREE9.Raycaster();
2112
+ const mouse = new THREE9.Vector2(mouseX, mouseY);
2115
2113
  raycaster.setFromCamera(mouse, camera);
2116
2114
  const intersects = raycaster.intersectObjects(objects, true);
2117
2115
  return intersects.length > 0 ? intersects[0] : null;
@@ -2139,49 +2137,230 @@ function getNeighbors(x, z, includeDiagonal = false) {
2139
2137
  { x: x - 1, z: z + 1 }
2140
2138
  );
2141
2139
  }
2142
- return neighbors;
2143
- }
2144
- function isInBounds(x, z, bounds) {
2145
- return x >= bounds.minX && x <= bounds.maxX && z >= bounds.minZ && z <= bounds.maxZ;
2146
- }
2147
- function getCellsInRadius(centerX, centerZ, radius) {
2148
- const cells = [];
2149
- const radiusSquared = radius * radius;
2150
- const minX = Math.floor(centerX - radius);
2151
- const maxX = Math.ceil(centerX + radius);
2152
- const minZ = Math.floor(centerZ - radius);
2153
- const maxZ = Math.ceil(centerZ + radius);
2154
- for (let x = minX; x <= maxX; x++) {
2155
- for (let z = minZ; z <= maxZ; z++) {
2156
- const dx = x - centerX;
2157
- const dz = z - centerZ;
2158
- if (dx * dx + dz * dz <= radiusSquared) {
2159
- cells.push({ x, z });
2140
+ return neighbors;
2141
+ }
2142
+ function isInBounds(x, z, bounds) {
2143
+ return x >= bounds.minX && x <= bounds.maxX && z >= bounds.minZ && z <= bounds.maxZ;
2144
+ }
2145
+ function getCellsInRadius(centerX, centerZ, radius) {
2146
+ const cells = [];
2147
+ const radiusSquared = radius * radius;
2148
+ const minX = Math.floor(centerX - radius);
2149
+ const maxX = Math.ceil(centerX + radius);
2150
+ const minZ = Math.floor(centerZ - radius);
2151
+ const maxZ = Math.ceil(centerZ + radius);
2152
+ for (let x = minX; x <= maxX; x++) {
2153
+ for (let z = minZ; z <= maxZ; z++) {
2154
+ const dx = x - centerX;
2155
+ const dz = z - centerZ;
2156
+ if (dx * dx + dz * dz <= radiusSquared) {
2157
+ cells.push({ x, z });
2158
+ }
2159
+ }
2160
+ }
2161
+ return cells;
2162
+ }
2163
+ function createGridHighlight(color = 16776960, opacity = 0.3) {
2164
+ const geometry = new THREE9.PlaneGeometry(0.95, 0.95);
2165
+ const material = new THREE9.MeshBasicMaterial({
2166
+ color,
2167
+ transparent: true,
2168
+ opacity,
2169
+ side: THREE9.DoubleSide
2170
+ });
2171
+ const mesh = new THREE9.Mesh(geometry, material);
2172
+ mesh.rotation.x = -Math.PI / 2;
2173
+ mesh.position.y = 0.01;
2174
+ return mesh;
2175
+ }
2176
+ function normalizeMouseCoordinates(clientX, clientY, element) {
2177
+ const rect = element.getBoundingClientRect();
2178
+ return {
2179
+ x: (clientX - rect.left) / rect.width * 2 - 1,
2180
+ y: -((clientY - rect.top) / rect.height) * 2 + 1
2181
+ };
2182
+ }
2183
+ function isInFrustum(position, camera, padding = 0) {
2184
+ const frustum = new THREE9.Frustum();
2185
+ const projScreenMatrix = new THREE9.Matrix4();
2186
+ projScreenMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
2187
+ frustum.setFromProjectionMatrix(projScreenMatrix);
2188
+ const sphere = new THREE9.Sphere(position, padding);
2189
+ return frustum.intersectsSphere(sphere);
2190
+ }
2191
+ function filterByFrustum(positions, camera, padding = 0) {
2192
+ const frustum = new THREE9.Frustum();
2193
+ const projScreenMatrix = new THREE9.Matrix4();
2194
+ projScreenMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
2195
+ frustum.setFromProjectionMatrix(projScreenMatrix);
2196
+ return positions.filter((position) => {
2197
+ const sphere = new THREE9.Sphere(position, padding);
2198
+ return frustum.intersectsSphere(sphere);
2199
+ });
2200
+ }
2201
+ function getVisibleIndices(positions, camera, padding = 0) {
2202
+ const frustum = new THREE9.Frustum();
2203
+ const projScreenMatrix = new THREE9.Matrix4();
2204
+ const visible = /* @__PURE__ */ new Set();
2205
+ projScreenMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
2206
+ frustum.setFromProjectionMatrix(projScreenMatrix);
2207
+ positions.forEach((position, index) => {
2208
+ const sphere = new THREE9.Sphere(position, padding);
2209
+ if (frustum.intersectsSphere(sphere)) {
2210
+ visible.add(index);
2211
+ }
2212
+ });
2213
+ return visible;
2214
+ }
2215
+ function calculateLODLevel(position, camera, lodLevels) {
2216
+ const distance = position.distanceTo(camera.position);
2217
+ for (let i = 0; i < lodLevels.length; i++) {
2218
+ if (distance < lodLevels[i]) {
2219
+ return i;
2220
+ }
2221
+ }
2222
+ return lodLevels.length;
2223
+ }
2224
+ function updateInstanceLOD(instancedMesh, positions, camera, lodDistances) {
2225
+ const lodIndices = new Uint8Array(positions.length);
2226
+ positions.forEach((position, index) => {
2227
+ lodIndices[index] = calculateLODLevel(position, camera, lodDistances);
2228
+ });
2229
+ return lodIndices;
2230
+ }
2231
+ function cullInstancedMesh(instancedMesh, positions, visibleIndices) {
2232
+ const dummy = new THREE9.Object3D();
2233
+ let visibleCount = 0;
2234
+ positions.forEach((position, index) => {
2235
+ if (visibleIndices.has(index)) {
2236
+ dummy.position.copy(position);
2237
+ dummy.updateMatrix();
2238
+ instancedMesh.setMatrixAt(visibleCount, dummy.matrix);
2239
+ visibleCount++;
2240
+ }
2241
+ });
2242
+ instancedMesh.count = visibleCount;
2243
+ instancedMesh.instanceMatrix.needsUpdate = true;
2244
+ return visibleCount;
2245
+ }
2246
+ var SpatialHashGrid = class {
2247
+ constructor(cellSize = 10) {
2248
+ __publicField(this, "cellSize");
2249
+ __publicField(this, "cells");
2250
+ __publicField(this, "objectPositions");
2251
+ this.cellSize = cellSize;
2252
+ this.cells = /* @__PURE__ */ new Map();
2253
+ this.objectPositions = /* @__PURE__ */ new Map();
2254
+ }
2255
+ /**
2256
+ * Get cell key for a position
2257
+ */
2258
+ getCellKey(x, z) {
2259
+ const cellX = Math.floor(x / this.cellSize);
2260
+ const cellZ = Math.floor(z / this.cellSize);
2261
+ return `${cellX},${cellZ}`;
2262
+ }
2263
+ /**
2264
+ * Insert an object into the grid
2265
+ */
2266
+ insert(id, position) {
2267
+ const key = this.getCellKey(position.x, position.z);
2268
+ if (!this.cells.has(key)) {
2269
+ this.cells.set(key, /* @__PURE__ */ new Set());
2270
+ }
2271
+ this.cells.get(key).add(id);
2272
+ this.objectPositions.set(id, position.clone());
2273
+ }
2274
+ /**
2275
+ * Remove an object from the grid
2276
+ */
2277
+ remove(id) {
2278
+ const position = this.objectPositions.get(id);
2279
+ if (position) {
2280
+ const key = this.getCellKey(position.x, position.z);
2281
+ this.cells.get(key)?.delete(id);
2282
+ this.objectPositions.delete(id);
2283
+ }
2284
+ }
2285
+ /**
2286
+ * Update an object's position
2287
+ */
2288
+ update(id, newPosition) {
2289
+ this.remove(id);
2290
+ this.insert(id, newPosition);
2291
+ }
2292
+ /**
2293
+ * Query objects within a radius of a position
2294
+ */
2295
+ queryRadius(center, radius) {
2296
+ const results = [];
2297
+ const radiusSquared = radius * radius;
2298
+ const minCellX = Math.floor((center.x - radius) / this.cellSize);
2299
+ const maxCellX = Math.floor((center.x + radius) / this.cellSize);
2300
+ const minCellZ = Math.floor((center.z - radius) / this.cellSize);
2301
+ const maxCellZ = Math.floor((center.z + radius) / this.cellSize);
2302
+ for (let x = minCellX; x <= maxCellX; x++) {
2303
+ for (let z = minCellZ; z <= maxCellZ; z++) {
2304
+ const key = `${x},${z}`;
2305
+ const cell = this.cells.get(key);
2306
+ if (cell) {
2307
+ cell.forEach((id) => {
2308
+ const position = this.objectPositions.get(id);
2309
+ if (position) {
2310
+ const dx = position.x - center.x;
2311
+ const dz = position.z - center.z;
2312
+ if (dx * dx + dz * dz <= radiusSquared) {
2313
+ results.push(id);
2314
+ }
2315
+ }
2316
+ });
2317
+ }
2318
+ }
2319
+ }
2320
+ return results;
2321
+ }
2322
+ /**
2323
+ * Query objects within a bounding box
2324
+ */
2325
+ queryBox(minX, maxX, minZ, maxZ) {
2326
+ const results = [];
2327
+ const minCellX = Math.floor(minX / this.cellSize);
2328
+ const maxCellX = Math.floor(maxX / this.cellSize);
2329
+ const minCellZ = Math.floor(minZ / this.cellSize);
2330
+ const maxCellZ = Math.floor(maxZ / this.cellSize);
2331
+ for (let x = minCellX; x <= maxCellX; x++) {
2332
+ for (let z = minCellZ; z <= maxCellZ; z++) {
2333
+ const key = `${x},${z}`;
2334
+ const cell = this.cells.get(key);
2335
+ if (cell) {
2336
+ cell.forEach((id) => {
2337
+ const position = this.objectPositions.get(id);
2338
+ if (position && position.x >= minX && position.x <= maxX && position.z >= minZ && position.z <= maxZ) {
2339
+ results.push(id);
2340
+ }
2341
+ });
2342
+ }
2160
2343
  }
2161
2344
  }
2345
+ return results;
2162
2346
  }
2163
- return cells;
2164
- }
2165
- function createGridHighlight(color = 16776960, opacity = 0.3) {
2166
- const geometry = new THREE6.PlaneGeometry(0.95, 0.95);
2167
- const material = new THREE6.MeshBasicMaterial({
2168
- color,
2169
- transparent: true,
2170
- opacity,
2171
- side: THREE6.DoubleSide
2172
- });
2173
- const mesh = new THREE6.Mesh(geometry, material);
2174
- mesh.rotation.x = -Math.PI / 2;
2175
- mesh.position.y = 0.01;
2176
- return mesh;
2177
- }
2178
- function normalizeMouseCoordinates(clientX, clientY, element) {
2179
- const rect = element.getBoundingClientRect();
2180
- return {
2181
- x: (clientX - rect.left) / rect.width * 2 - 1,
2182
- y: -((clientY - rect.top) / rect.height) * 2 + 1
2183
- };
2184
- }
2347
+ /**
2348
+ * Clear all objects from the grid
2349
+ */
2350
+ clear() {
2351
+ this.cells.clear();
2352
+ this.objectPositions.clear();
2353
+ }
2354
+ /**
2355
+ * Get statistics about the grid
2356
+ */
2357
+ getStats() {
2358
+ return {
2359
+ objects: this.objectPositions.size,
2360
+ cells: this.cells.size
2361
+ };
2362
+ }
2363
+ };
2185
2364
  function useTapReveal(options = {}) {
2186
2365
  const { onReveal, onDismiss, refs, enabled = true } = options;
2187
2366
  const [revealed, setRevealed] = useState(false);
@@ -2329,7 +2508,7 @@ var positionStyles = {
2329
2508
  fixed: "fixed",
2330
2509
  sticky: "sticky"
2331
2510
  };
2332
- var Box = React7.forwardRef(
2511
+ var Box = React6.forwardRef(
2333
2512
  ({
2334
2513
  padding,
2335
2514
  paddingX,
@@ -2394,7 +2573,7 @@ var Box = React7.forwardRef(
2394
2573
  onPointerDown?.(e);
2395
2574
  }, [hoverEvent, tapReveal, triggerProps, onPointerDown]);
2396
2575
  const isClickable = action || onClick;
2397
- return React7.createElement(
2576
+ return React6.createElement(
2398
2577
  Component2,
2399
2578
  {
2400
2579
  ref,
@@ -2515,7 +2694,7 @@ var Typography = ({
2515
2694
  }) => {
2516
2695
  const variant = variantProp ?? (level ? `h${level}` : "body1");
2517
2696
  const Component2 = as || defaultElements[variant];
2518
- return React7.createElement(
2697
+ return React6.createElement(
2519
2698
  Component2,
2520
2699
  {
2521
2700
  id,
@@ -2588,7 +2767,7 @@ var Stack = ({
2588
2767
  };
2589
2768
  const isHorizontal = direction === "horizontal";
2590
2769
  const directionClass = responsive && isHorizontal ? reverse ? "flex-col-reverse md:flex-row-reverse" : "flex-col md:flex-row" : isHorizontal ? reverse ? "flex-row-reverse" : "flex-row" : reverse ? "flex-col-reverse" : "flex-col";
2591
- return React7.createElement(
2770
+ return React6.createElement(
2592
2771
  Component2,
2593
2772
  {
2594
2773
  className: cn(
@@ -4775,12 +4954,12 @@ function useAvl3DConfig() {
4775
4954
  }
4776
4955
  function CameraController({ targetPosition, targetLookAt, animated }) {
4777
4956
  const { camera } = useThree();
4778
- const targetPosVec = useRef(new THREE6.Vector3(...targetPosition));
4779
- const targetLookVec = useRef(new THREE6.Vector3(...targetLookAt));
4957
+ const targetPosVec = useRef(new THREE9.Vector3(...targetPosition));
4958
+ const targetLookVec = useRef(new THREE9.Vector3(...targetLookAt));
4780
4959
  const isAnimating = useRef(false);
4781
4960
  useEffect(() => {
4782
- const newTarget = new THREE6.Vector3(...targetPosition);
4783
- const newLookAt = new THREE6.Vector3(...targetLookAt);
4961
+ const newTarget = new THREE9.Vector3(...targetPosition);
4962
+ const newLookAt = new THREE9.Vector3(...targetLookAt);
4784
4963
  if (!newTarget.equals(targetPosVec.current) || !newLookAt.equals(targetLookVec.current)) {
4785
4964
  targetPosVec.current.copy(newTarget);
4786
4965
  targetLookVec.current.copy(newLookAt);
@@ -4795,9 +4974,9 @@ function CameraController({ targetPosition, targetLookAt, animated }) {
4795
4974
  useFrame((_, delta) => {
4796
4975
  if (!isAnimating.current) return;
4797
4976
  const speed = 3;
4798
- camera.position.x = THREE6.MathUtils.damp(camera.position.x, targetPosVec.current.x, speed, delta);
4799
- camera.position.y = THREE6.MathUtils.damp(camera.position.y, targetPosVec.current.y, speed, delta);
4800
- camera.position.z = THREE6.MathUtils.damp(camera.position.z, targetPosVec.current.z, speed, delta);
4977
+ camera.position.x = THREE9.MathUtils.damp(camera.position.x, targetPosVec.current.x, speed, delta);
4978
+ camera.position.y = THREE9.MathUtils.damp(camera.position.y, targetPosVec.current.y, speed, delta);
4979
+ camera.position.z = THREE9.MathUtils.damp(camera.position.z, targetPosVec.current.z, speed, delta);
4801
4980
  camera.lookAt(targetLookVec.current);
4802
4981
  const dist = camera.position.distanceTo(targetPosVec.current);
4803
4982
  if (dist < 0.05) {
@@ -4812,7 +4991,7 @@ function SceneFade({ animating, children }) {
4812
4991
  useFrame((_, delta) => {
4813
4992
  if (!groupRef.current) return;
4814
4993
  const target = animating ? 0 : 1;
4815
- opacityRef.current = THREE6.MathUtils.damp(opacityRef.current, target, 5, delta);
4994
+ opacityRef.current = THREE9.MathUtils.damp(opacityRef.current, target, 5, delta);
4816
4995
  groupRef.current.visible = opacityRef.current > 0.05;
4817
4996
  const s = 0.9 + opacityRef.current * 0.1;
4818
4997
  groupRef.current.scale.setScalar(s);
@@ -4883,7 +5062,7 @@ var Avl3DViewer = ({
4883
5062
  const handleTraitClick = useCallback((name) => {
4884
5063
  dispatch({ type: "ZOOM_INTO_TRAIT", trait: name, targetPosition: { x: 0, y: 0 } });
4885
5064
  }, []);
4886
- const [highlightedTrait, setHighlightedTrait] = React7.useState(null);
5065
+ const [highlightedTrait, setHighlightedTrait] = React6.useState(null);
4887
5066
  const handleTransitionClick = useCallback((index) => {
4888
5067
  dispatch({ type: "ZOOM_INTO_TRANSITION", transitionIndex: index, targetPosition: { x: 0, y: 0 } });
4889
5068
  }, []);
@@ -4970,7 +5149,7 @@ var Avl3DViewer = ({
4970
5149
  gap: "xs",
4971
5150
  align: "center",
4972
5151
  className: "absolute top-2 left-2 z-10 bg-surface/80 backdrop-blur rounded-md px-3 py-1.5",
4973
- children: breadcrumbs.map((crumb, i) => /* @__PURE__ */ jsxs(React7.Fragment, { children: [
5152
+ children: breadcrumbs.map((crumb, i) => /* @__PURE__ */ jsxs(React6.Fragment, { children: [
4974
5153
  i > 0 && /* @__PURE__ */ jsx(Typography, { variant: "small", color: "muted", className: "mx-1", children: "/" }),
4975
5154
  i < breadcrumbs.length - 1 ? /* @__PURE__ */ jsx(
4976
5155
  Box,
@@ -5064,186 +5243,5 @@ var Avl3DViewer = ({
5064
5243
  );
5065
5244
  };
5066
5245
  Avl3DViewer.displayName = "Avl3DViewer";
5067
- function isInFrustum(position, camera, padding = 0) {
5068
- const frustum = new THREE6.Frustum();
5069
- const projScreenMatrix = new THREE6.Matrix4();
5070
- projScreenMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
5071
- frustum.setFromProjectionMatrix(projScreenMatrix);
5072
- const sphere = new THREE6.Sphere(position, padding);
5073
- return frustum.intersectsSphere(sphere);
5074
- }
5075
- function filterByFrustum(positions, camera, padding = 0) {
5076
- const frustum = new THREE6.Frustum();
5077
- const projScreenMatrix = new THREE6.Matrix4();
5078
- projScreenMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
5079
- frustum.setFromProjectionMatrix(projScreenMatrix);
5080
- return positions.filter((position) => {
5081
- const sphere = new THREE6.Sphere(position, padding);
5082
- return frustum.intersectsSphere(sphere);
5083
- });
5084
- }
5085
- function getVisibleIndices(positions, camera, padding = 0) {
5086
- const frustum = new THREE6.Frustum();
5087
- const projScreenMatrix = new THREE6.Matrix4();
5088
- const visible = /* @__PURE__ */ new Set();
5089
- projScreenMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
5090
- frustum.setFromProjectionMatrix(projScreenMatrix);
5091
- positions.forEach((position, index) => {
5092
- const sphere = new THREE6.Sphere(position, padding);
5093
- if (frustum.intersectsSphere(sphere)) {
5094
- visible.add(index);
5095
- }
5096
- });
5097
- return visible;
5098
- }
5099
- function calculateLODLevel(position, camera, lodLevels) {
5100
- const distance = position.distanceTo(camera.position);
5101
- for (let i = 0; i < lodLevels.length; i++) {
5102
- if (distance < lodLevels[i]) {
5103
- return i;
5104
- }
5105
- }
5106
- return lodLevels.length;
5107
- }
5108
- function updateInstanceLOD(instancedMesh, positions, camera, lodDistances) {
5109
- const lodIndices = new Uint8Array(positions.length);
5110
- positions.forEach((position, index) => {
5111
- lodIndices[index] = calculateLODLevel(position, camera, lodDistances);
5112
- });
5113
- return lodIndices;
5114
- }
5115
- function cullInstancedMesh(instancedMesh, positions, visibleIndices) {
5116
- const dummy = new THREE6.Object3D();
5117
- let visibleCount = 0;
5118
- positions.forEach((position, index) => {
5119
- if (visibleIndices.has(index)) {
5120
- dummy.position.copy(position);
5121
- dummy.updateMatrix();
5122
- instancedMesh.setMatrixAt(visibleCount, dummy.matrix);
5123
- visibleCount++;
5124
- }
5125
- });
5126
- instancedMesh.count = visibleCount;
5127
- instancedMesh.instanceMatrix.needsUpdate = true;
5128
- return visibleCount;
5129
- }
5130
- var SpatialHashGrid = class {
5131
- constructor(cellSize = 10) {
5132
- __publicField(this, "cellSize");
5133
- __publicField(this, "cells");
5134
- __publicField(this, "objectPositions");
5135
- this.cellSize = cellSize;
5136
- this.cells = /* @__PURE__ */ new Map();
5137
- this.objectPositions = /* @__PURE__ */ new Map();
5138
- }
5139
- /**
5140
- * Get cell key for a position
5141
- */
5142
- getCellKey(x, z) {
5143
- const cellX = Math.floor(x / this.cellSize);
5144
- const cellZ = Math.floor(z / this.cellSize);
5145
- return `${cellX},${cellZ}`;
5146
- }
5147
- /**
5148
- * Insert an object into the grid
5149
- */
5150
- insert(id, position) {
5151
- const key = this.getCellKey(position.x, position.z);
5152
- if (!this.cells.has(key)) {
5153
- this.cells.set(key, /* @__PURE__ */ new Set());
5154
- }
5155
- this.cells.get(key).add(id);
5156
- this.objectPositions.set(id, position.clone());
5157
- }
5158
- /**
5159
- * Remove an object from the grid
5160
- */
5161
- remove(id) {
5162
- const position = this.objectPositions.get(id);
5163
- if (position) {
5164
- const key = this.getCellKey(position.x, position.z);
5165
- this.cells.get(key)?.delete(id);
5166
- this.objectPositions.delete(id);
5167
- }
5168
- }
5169
- /**
5170
- * Update an object's position
5171
- */
5172
- update(id, newPosition) {
5173
- this.remove(id);
5174
- this.insert(id, newPosition);
5175
- }
5176
- /**
5177
- * Query objects within a radius of a position
5178
- */
5179
- queryRadius(center, radius) {
5180
- const results = [];
5181
- const radiusSquared = radius * radius;
5182
- const minCellX = Math.floor((center.x - radius) / this.cellSize);
5183
- const maxCellX = Math.floor((center.x + radius) / this.cellSize);
5184
- const minCellZ = Math.floor((center.z - radius) / this.cellSize);
5185
- const maxCellZ = Math.floor((center.z + radius) / this.cellSize);
5186
- for (let x = minCellX; x <= maxCellX; x++) {
5187
- for (let z = minCellZ; z <= maxCellZ; z++) {
5188
- const key = `${x},${z}`;
5189
- const cell = this.cells.get(key);
5190
- if (cell) {
5191
- cell.forEach((id) => {
5192
- const position = this.objectPositions.get(id);
5193
- if (position) {
5194
- const dx = position.x - center.x;
5195
- const dz = position.z - center.z;
5196
- if (dx * dx + dz * dz <= radiusSquared) {
5197
- results.push(id);
5198
- }
5199
- }
5200
- });
5201
- }
5202
- }
5203
- }
5204
- return results;
5205
- }
5206
- /**
5207
- * Query objects within a bounding box
5208
- */
5209
- queryBox(minX, maxX, minZ, maxZ) {
5210
- const results = [];
5211
- const minCellX = Math.floor(minX / this.cellSize);
5212
- const maxCellX = Math.floor(maxX / this.cellSize);
5213
- const minCellZ = Math.floor(minZ / this.cellSize);
5214
- const maxCellZ = Math.floor(maxZ / this.cellSize);
5215
- for (let x = minCellX; x <= maxCellX; x++) {
5216
- for (let z = minCellZ; z <= maxCellZ; z++) {
5217
- const key = `${x},${z}`;
5218
- const cell = this.cells.get(key);
5219
- if (cell) {
5220
- cell.forEach((id) => {
5221
- const position = this.objectPositions.get(id);
5222
- if (position && position.x >= minX && position.x <= maxX && position.z >= minZ && position.z <= maxZ) {
5223
- results.push(id);
5224
- }
5225
- });
5226
- }
5227
- }
5228
- }
5229
- return results;
5230
- }
5231
- /**
5232
- * Clear all objects from the grid
5233
- */
5234
- clear() {
5235
- this.cells.clear();
5236
- this.objectPositions.clear();
5237
- }
5238
- /**
5239
- * Get statistics about the grid
5240
- */
5241
- getStats() {
5242
- return {
5243
- objects: this.objectPositions.size,
5244
- cells: this.cells.size
5245
- };
5246
- }
5247
- };
5248
5246
 
5249
- export { AVL_3D_COLORS, AssetLoader, Avl3DApplicationScene, Avl3DContext, Avl3DEffects, Avl3DOrbitalScene, Avl3DTraitScene, Avl3DTransitionScene, Avl3DViewer, CAMERA_POSITIONS, Camera3D, Canvas3DErrorBoundary, Canvas3DLoadingState, GameCanvas3D, Lighting3D, ModelLoader, Scene3D, SpatialHashGrid, arcCurve3D, assetLoader, calculateLODLevel, createGridHighlight, cullInstancedMesh, fibonacciSpherePositions, filterByFrustum, getCellsInRadius, getNeighbors, getVisibleIndices, goldenSpiralPositions, gridDistance, gridManhattanDistance, gridToWorld, isInBounds, isInFrustum, normalizeMouseCoordinates, orbitRingPositions, raycastToObjects, raycastToPlane, selfLoopCurve3D, treeLayout3D, updateInstanceLOD, useAssetLoader, useAvl3DConfig, useGameCanvas3DEvents, useRaycaster, useSceneGraph, useThree3 as useThree, worldToGrid };
5247
+ export { AVL_3D_COLORS, AssetLoader, Avl3DApplicationScene, Avl3DContext, Avl3DEffects, Avl3DOrbitalScene, Avl3DTraitScene, Avl3DTransitionScene, Avl3DViewer, CAMERA_POSITIONS, Camera3D, Canvas3DErrorBoundary, Canvas3DHost, Canvas3DLoadingState, Canvas3DHost as GameCanvas3D, Lighting3D, ModelLoader, Scene3D, SpatialHashGrid, arcCurve3D, assetLoader, calculateLODLevel, createGridHighlight, cullInstancedMesh, fibonacciSpherePositions, filterByFrustum, getCellsInRadius, getNeighbors, getVisibleIndices, goldenSpiralPositions, gridDistance, gridManhattanDistance, gridToWorld, isInBounds, isInFrustum, normalizeMouseCoordinates, orbitRingPositions, raycastToObjects, raycastToPlane, selfLoopCurve3D, treeLayout3D, updateInstanceLOD, useAssetLoader, useAvl3DConfig, useGameCanvas3DEvents, useRaycaster, useSceneGraph, useThree4 as useThree, worldToGrid };