@almadar/ui 5.96.0 → 5.98.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.
@@ -328,6 +328,36 @@ var Canvas3DErrorBoundary = class extends React8.Component {
328
328
  }
329
329
  };
330
330
  var log2 = logger.createLogger("almadar:ui:game:model-loader");
331
+ var gltfCache = /* @__PURE__ */ new Map();
332
+ function loadGltfCached(url, assetRoot) {
333
+ const key = `${assetRoot}|${url}`;
334
+ let entry = gltfCache.get(key);
335
+ if (!entry) {
336
+ const pending = {
337
+ promise: new Promise((resolveDone) => {
338
+ const loader = new GLTFLoader.GLTFLoader();
339
+ loader.setResourcePath(assetRoot);
340
+ loader.load(
341
+ url,
342
+ (gltf) => {
343
+ log2.debug("Loaded", { url, clips: gltf.animations.length });
344
+ pending.gltf = { scene: gltf.scene, animations: gltf.animations };
345
+ resolveDone();
346
+ },
347
+ void 0,
348
+ (err) => {
349
+ log2.warn("Failed", { url, error: err instanceof Error ? err : String(err) });
350
+ pending.error = err instanceof Error ? err : new Error(String(err));
351
+ resolveDone();
352
+ }
353
+ );
354
+ })
355
+ };
356
+ entry = pending;
357
+ gltfCache.set(key, entry);
358
+ }
359
+ return entry;
360
+ }
331
361
  function detectAssetRoot(modelUrl) {
332
362
  const idx = modelUrl.indexOf("/3d/");
333
363
  if (idx !== -1) {
@@ -335,45 +365,30 @@ function detectAssetRoot(modelUrl) {
335
365
  }
336
366
  return modelUrl.substring(0, modelUrl.lastIndexOf("/") + 1);
337
367
  }
368
+ function stateFromEntry(entry) {
369
+ if (!entry) return { model: null, clips: [], isLoading: false, error: null };
370
+ if (entry.gltf) return { model: entry.gltf.scene, clips: entry.gltf.animations, isLoading: false, error: null };
371
+ return { model: null, clips: [], isLoading: !entry.error, error: entry.error ?? null };
372
+ }
338
373
  function useGLTFModel(url, resourceBasePath) {
339
- const [state, setState] = React8.useState({
340
- model: null,
341
- clips: [],
342
- isLoading: false,
343
- error: null
344
- });
374
+ const [state, setState] = React8.useState(
375
+ () => stateFromEntry(url ? loadGltfCached(url, resourceBasePath || detectAssetRoot(url)) : null)
376
+ );
345
377
  React8.useEffect(() => {
346
378
  if (!url) {
347
379
  setState({ model: null, clips: [], isLoading: false, error: null });
348
380
  return;
349
381
  }
350
- log2.debug("Loading", { url });
351
- setState((prev) => ({ ...prev, isLoading: true, error: null }));
352
- const assetRoot = resourceBasePath || detectAssetRoot(url);
353
- const loader = new GLTFLoader.GLTFLoader();
354
- loader.setResourcePath(assetRoot);
355
- loader.load(
356
- url,
357
- (gltf) => {
358
- log2.debug("Loaded", { url, clips: gltf.animations.length });
359
- setState({
360
- model: gltf.scene,
361
- clips: gltf.animations,
362
- isLoading: false,
363
- error: null
364
- });
365
- },
366
- void 0,
367
- (err) => {
368
- log2.warn("Failed", { url, error: err instanceof Error ? err : String(err) });
369
- setState({
370
- model: null,
371
- clips: [],
372
- isLoading: false,
373
- error: err instanceof Error ? err : new Error(String(err))
374
- });
375
- }
376
- );
382
+ const entry = loadGltfCached(url, resourceBasePath || detectAssetRoot(url));
383
+ setState(stateFromEntry(entry));
384
+ if (entry.gltf || entry.error) return;
385
+ let cancelled = false;
386
+ void entry.promise.then(() => {
387
+ if (!cancelled) setState(stateFromEntry(entry));
388
+ });
389
+ return () => {
390
+ cancelled = true;
391
+ };
377
392
  }, [url, resourceBasePath]);
378
393
  return state;
379
394
  }
@@ -390,21 +405,28 @@ function ModelLoader({
390
405
  castShadow = true,
391
406
  receiveShadow = true,
392
407
  resourceBasePath,
393
- animation
408
+ animation,
409
+ tint
394
410
  }) {
395
411
  const { model: loadedModel, clips, isLoading, error } = useGLTFModel(url, resourceBasePath);
396
412
  const model = React8.useMemo(() => {
397
413
  if (!loadedModel) return null;
398
414
  const cloned = SkeletonUtils.clone(loadedModel);
399
415
  cloned.updateMatrixWorld(true);
416
+ const tintColor = tint ? new THREE6__namespace.Color(tint) : null;
400
417
  cloned.traverse((child) => {
401
418
  if (child instanceof THREE6__namespace.Mesh) {
402
419
  child.castShadow = castShadow;
403
420
  child.receiveShadow = receiveShadow;
421
+ if (tintColor && child.material instanceof THREE6__namespace.MeshStandardMaterial) {
422
+ const mat = child.material.clone();
423
+ mat.color.multiply(tintColor);
424
+ child.material = mat;
425
+ }
404
426
  }
405
427
  });
406
428
  return cloned;
407
- }, [loadedModel, castShadow, receiveShadow]);
429
+ }, [loadedModel, castShadow, receiveShadow, tint]);
408
430
  const mixer = React8.useMemo(() => model ? new THREE6__namespace.AnimationMixer(model) : null, [model]);
409
431
  React8.useEffect(() => {
410
432
  if (!mixer || !animation || clips.length === 0) return;
@@ -1781,7 +1803,10 @@ function SideScene({
1781
1803
  ppu,
1782
1804
  playerSprite,
1783
1805
  tileSprites,
1784
- interpolate
1806
+ interpolate,
1807
+ features = [],
1808
+ events = [],
1809
+ assetManifest
1785
1810
  }) {
1786
1811
  const pw = player.width ?? 32;
1787
1812
  const ph = player.height ?? 48;
@@ -1813,6 +1838,21 @@ function SideScene({
1813
1838
  `plat-${i}`
1814
1839
  );
1815
1840
  }),
1841
+ features.map((feature, i) => {
1842
+ const model = feature.assetUrl ?? assetManifest?.features?.[feature.type];
1843
+ const fx = feature.x / ppu;
1844
+ const fy = (worldHeight - feature.y) / ppu;
1845
+ if (!model?.url) return null;
1846
+ return /* @__PURE__ */ jsxRuntime.jsx("group", { position: [fx, fy, 0], children: /* @__PURE__ */ jsxRuntime.jsx(ModelLoader, { url: model.url, scale: 0.6, tint: feature.color, fallbackGeometry: "sphere", castShadow: true }) }, feature.id ?? `sfeat-${i}`);
1847
+ }),
1848
+ events.map((ev, i) => /* @__PURE__ */ jsxRuntime.jsx(
1849
+ EventMarker,
1850
+ {
1851
+ event: ev,
1852
+ position: [ev.x / ppu, (worldHeight - (ev.y ?? 0)) / ppu + 0.8, 0.2]
1853
+ },
1854
+ ev.id ?? `sev-${i}`
1855
+ )),
1816
1856
  /* @__PURE__ */ jsxRuntime.jsx(LerpedGroup, { target: playerPos, enabled: interpolate, children: playerSprite?.url ? /* @__PURE__ */ jsxRuntime.jsx("group", { position: [0, playerH / 2, 0], children: /* @__PURE__ */ jsxRuntime.jsx(
1817
1857
  ModelLoader,
1818
1858
  {
@@ -1829,6 +1869,245 @@ function SideScene({
1829
1869
  ] }) })
1830
1870
  ] });
1831
1871
  }
1872
+ var UNIT_BASE_MODEL_SCALE = 0.5;
1873
+ var UNIT_BASE_BILLBOARD_HEIGHT = 1.2;
1874
+ var UNIT_BASE_PRIMITIVE_RADIUS = 0.3;
1875
+ var EVENT_COLORS = {
1876
+ hit: "#ff5544",
1877
+ damage: "#ff5544",
1878
+ attack: "#ff8844",
1879
+ heal: "#44dd66",
1880
+ pickup: "#ffcc33",
1881
+ score: "#ffcc33",
1882
+ death: "#aa3333",
1883
+ win: "#66ff88",
1884
+ lose: "#ff6666"
1885
+ };
1886
+ function EventMarker({
1887
+ event,
1888
+ position
1889
+ }) {
1890
+ return /* @__PURE__ */ jsxRuntime.jsx(drei.Billboard, { position, children: /* @__PURE__ */ jsxRuntime.jsx(
1891
+ drei.Text,
1892
+ {
1893
+ fontSize: 0.32,
1894
+ color: EVENT_COLORS[event.type] ?? "#ffffff",
1895
+ outlineWidth: 0.02,
1896
+ outlineColor: "#000000",
1897
+ anchorX: "center",
1898
+ anchorY: "middle",
1899
+ children: event.message ?? event.type
1900
+ }
1901
+ ) });
1902
+ }
1903
+ function DefaultTile({
1904
+ tile,
1905
+ position,
1906
+ model,
1907
+ isSelected,
1908
+ isHovered,
1909
+ isValidMove,
1910
+ isAttackTarget,
1911
+ onTileClick,
1912
+ onTileHover
1913
+ }) {
1914
+ let color = 8421504;
1915
+ if (tile.type === "water") color = 4491468;
1916
+ else if (tile.type === "grass") color = 4500036;
1917
+ else if (tile.type === "sand") color = 14535816;
1918
+ else if (tile.type === "rock") color = 8947848;
1919
+ else if (tile.type === "snow") color = 15658734;
1920
+ let emissive = 0;
1921
+ if (isSelected) emissive = 4473924;
1922
+ else if (isAttackTarget) emissive = 4456448;
1923
+ else if (isValidMove) emissive = 17408;
1924
+ else if (isHovered) emissive = 2236962;
1925
+ if (model?.url) {
1926
+ return /* @__PURE__ */ jsxRuntime.jsx(
1927
+ "group",
1928
+ {
1929
+ position,
1930
+ onClick: (e) => onTileClick(tile, e),
1931
+ onPointerEnter: (e) => onTileHover(tile, e),
1932
+ onPointerLeave: (e) => onTileHover(null, e),
1933
+ userData: { type: "tile", tileId: tile.id, gridX: tile.x, gridZ: tile.z ?? tile.y },
1934
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1935
+ ModelLoader,
1936
+ {
1937
+ url: model.url,
1938
+ scale: 0.95,
1939
+ rotation: [0, tile.rotation ?? 0, 0],
1940
+ fallbackGeometry: "box",
1941
+ castShadow: true,
1942
+ receiveShadow: true
1943
+ }
1944
+ )
1945
+ }
1946
+ );
1947
+ }
1948
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1949
+ "mesh",
1950
+ {
1951
+ position,
1952
+ onClick: (e) => onTileClick(tile, e),
1953
+ onPointerEnter: (e) => onTileHover(tile, e),
1954
+ onPointerLeave: (e) => onTileHover(null, e),
1955
+ userData: { type: "tile", tileId: tile.id, gridX: tile.x, gridZ: tile.z ?? tile.y },
1956
+ children: [
1957
+ /* @__PURE__ */ jsxRuntime.jsx("boxGeometry", { args: [0.95, 0.2, 0.95] }),
1958
+ /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color, emissive })
1959
+ ]
1960
+ }
1961
+ );
1962
+ }
1963
+ function DefaultUnit({
1964
+ unit,
1965
+ position,
1966
+ model,
1967
+ isSelected,
1968
+ unitScale,
1969
+ cellSize,
1970
+ resolveUnitFrame,
1971
+ onUnitClick
1972
+ }) {
1973
+ const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
1974
+ const hasAtlas = unitAtlasUrl(unit) !== null;
1975
+ const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
1976
+ const modelScale = UNIT_BASE_MODEL_SCALE * unitScale * cellSize;
1977
+ const billboardHeight = UNIT_BASE_BILLBOARD_HEIGHT * unitScale * cellSize;
1978
+ const primitiveRadius = UNIT_BASE_PRIMITIVE_RADIUS * unitScale * cellSize;
1979
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1980
+ "group",
1981
+ {
1982
+ position,
1983
+ onClick: (e) => onUnitClick(unit, e),
1984
+ userData: { type: "unit", unitId: unit.id },
1985
+ children: [
1986
+ isSelected && /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, 0.05, 0], rotation: [-Math.PI / 2, 0, 0], children: [
1987
+ /* @__PURE__ */ jsxRuntime.jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
1988
+ /* @__PURE__ */ jsxRuntime.jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
1989
+ ] }),
1990
+ hasAtlas && initialFrame ? (
1991
+ /* Animated sprite-sheet billboard — single cropped frame, by state */
1992
+ /* @__PURE__ */ jsxRuntime.jsx(drei.Billboard, { children: /* @__PURE__ */ jsxRuntime.jsx(
1993
+ UnitSpriteBillboard,
1994
+ {
1995
+ sheetUrl: initialFrame.sheetUrl,
1996
+ resolveFrame: () => resolveUnitFrame(unit.id),
1997
+ height: billboardHeight
1998
+ }
1999
+ ) })
2000
+ ) : model?.url ? (
2001
+ /* GLB unit model — LOLO's `animation` field drives the named clip */
2002
+ /* @__PURE__ */ jsxRuntime.jsx(
2003
+ ModelLoader,
2004
+ {
2005
+ url: model.url,
2006
+ scale: modelScale,
2007
+ animation: unit.animation ?? "idle",
2008
+ fallbackGeometry: "box",
2009
+ castShadow: true
2010
+ }
2011
+ )
2012
+ ) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2013
+ /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, primitiveRadius, 0], children: [
2014
+ /* @__PURE__ */ jsxRuntime.jsx("cylinderGeometry", { args: [primitiveRadius, primitiveRadius, primitiveRadius * 0.33, 8] }),
2015
+ /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color })
2016
+ ] }),
2017
+ /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, primitiveRadius * 2, 0], children: [
2018
+ /* @__PURE__ */ jsxRuntime.jsx("capsuleGeometry", { args: [primitiveRadius * 0.67, primitiveRadius * 1.33, 4, 8] }),
2019
+ /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color })
2020
+ ] }),
2021
+ /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, primitiveRadius * 3, 0], children: [
2022
+ /* @__PURE__ */ jsxRuntime.jsx("sphereGeometry", { args: [primitiveRadius * 0.4, 8, 8] }),
2023
+ /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color })
2024
+ ] })
2025
+ ] }),
2026
+ unit.health !== void 0 && unit.maxHealth !== void 0 && /* @__PURE__ */ jsxRuntime.jsxs("group", { position: [0, billboardHeight, 0], children: [
2027
+ /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [-0.25, 0, 0], children: [
2028
+ /* @__PURE__ */ jsxRuntime.jsx("planeGeometry", { args: [0.5, 0.05] }),
2029
+ /* @__PURE__ */ jsxRuntime.jsx("meshBasicMaterial", { color: 3355443 })
2030
+ ] }),
2031
+ /* @__PURE__ */ jsxRuntime.jsxs(
2032
+ "mesh",
2033
+ {
2034
+ position: [
2035
+ -0.25 + 0.5 * (unit.health / unit.maxHealth) / 2,
2036
+ 0,
2037
+ 0.01
2038
+ ],
2039
+ children: [
2040
+ /* @__PURE__ */ jsxRuntime.jsx("planeGeometry", { args: [0.5 * (unit.health / unit.maxHealth), 0.05] }),
2041
+ /* @__PURE__ */ jsxRuntime.jsx(
2042
+ "meshBasicMaterial",
2043
+ {
2044
+ color: unit.health / unit.maxHealth > 0.5 ? 4500036 : unit.health / unit.maxHealth > 0.25 ? 11184708 : 16729156
2045
+ }
2046
+ )
2047
+ ]
2048
+ }
2049
+ )
2050
+ ] })
2051
+ ]
2052
+ }
2053
+ );
2054
+ }
2055
+ function DefaultFeature({
2056
+ feature,
2057
+ position,
2058
+ model,
2059
+ onFeatureClick
2060
+ }) {
2061
+ if (model?.url) {
2062
+ return /* @__PURE__ */ jsxRuntime.jsx(
2063
+ ModelLoader,
2064
+ {
2065
+ url: model.url,
2066
+ position,
2067
+ scale: 0.5,
2068
+ rotation: [0, feature.rotation ?? 0, 0],
2069
+ tint: feature.color,
2070
+ onClick: () => onFeatureClick(feature, null),
2071
+ fallbackGeometry: "box"
2072
+ }
2073
+ );
2074
+ }
2075
+ if (feature.type === "tree") {
2076
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2077
+ "group",
2078
+ {
2079
+ position,
2080
+ onClick: (e) => onFeatureClick(feature, e),
2081
+ userData: { type: "feature", featureId: feature.id },
2082
+ children: [
2083
+ /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, 0.4, 0], children: [
2084
+ /* @__PURE__ */ jsxRuntime.jsx("cylinderGeometry", { args: [0.1, 0.15, 0.8, 6] }),
2085
+ /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color: 9127187 })
2086
+ ] }),
2087
+ /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, 0.9, 0], children: [
2088
+ /* @__PURE__ */ jsxRuntime.jsx("coneGeometry", { args: [0.5, 0.8, 8] }),
2089
+ /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color: 2263842 })
2090
+ ] })
2091
+ ]
2092
+ }
2093
+ );
2094
+ }
2095
+ if (feature.type === "rock") {
2096
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2097
+ "mesh",
2098
+ {
2099
+ position: [position[0], position[1] + 0.3, position[2]],
2100
+ onClick: (e) => onFeatureClick(feature, e),
2101
+ userData: { type: "feature", featureId: feature.id },
2102
+ children: [
2103
+ /* @__PURE__ */ jsxRuntime.jsx("dodecahedronGeometry", { args: [0.3, 0] }),
2104
+ /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color: 8421504 })
2105
+ ]
2106
+ }
2107
+ );
2108
+ }
2109
+ return null;
2110
+ }
1832
2111
  var GameCanvas3D = React8.forwardRef(
1833
2112
  ({
1834
2113
  tiles = [],
@@ -2115,216 +2394,6 @@ var GameCanvas3D = React8.forwardRef(
2115
2394
  }
2116
2395
  return cameraTarget;
2117
2396
  }, [player, pixelsPerUnit, worldHeight, units, selectedUnitId, gridBounds, gridConfig, cameraTarget]);
2118
- const DefaultTileRenderer = React8.useCallback(
2119
- ({ tile, position }) => {
2120
- const isSelected = tile.id ? selectedTileIds.includes(tile.id) : false;
2121
- const isHovered = hoveredTile?.id === tile.id;
2122
- const isValidMove = validMoves.some(
2123
- (m) => m.x === tile.x && m.z === (tile.z ?? tile.y ?? 0)
2124
- );
2125
- const isAttackTarget = attackTargets.some(
2126
- (m) => m.x === tile.x && m.z === (tile.z ?? tile.y ?? 0)
2127
- );
2128
- let color = 8421504;
2129
- if (tile.type === "water") color = 4491468;
2130
- else if (tile.type === "grass") color = 4500036;
2131
- else if (tile.type === "sand") color = 14535816;
2132
- else if (tile.type === "rock") color = 8947848;
2133
- else if (tile.type === "snow") color = 15658734;
2134
- let emissive = 0;
2135
- if (isSelected) emissive = 4473924;
2136
- else if (isAttackTarget) emissive = 4456448;
2137
- else if (isValidMove) emissive = 17408;
2138
- else if (isHovered) emissive = 2236962;
2139
- const tileModel = tile.modelUrl ?? assetManifest?.terrains?.[tile.terrain ?? tile.type ?? ""];
2140
- if (tileModel?.url) {
2141
- return /* @__PURE__ */ jsxRuntime.jsx(
2142
- "group",
2143
- {
2144
- position,
2145
- onClick: (e) => handleTileClick(tile, e),
2146
- onPointerEnter: (e) => handleTileHover(tile, e),
2147
- onPointerLeave: (e) => handleTileHover(null, e),
2148
- userData: { type: "tile", tileId: tile.id, gridX: tile.x, gridZ: tile.z ?? tile.y },
2149
- children: /* @__PURE__ */ jsxRuntime.jsx(
2150
- ModelLoader,
2151
- {
2152
- url: tileModel.url,
2153
- scale: 0.95,
2154
- fallbackGeometry: "box",
2155
- castShadow: true,
2156
- receiveShadow: true
2157
- }
2158
- )
2159
- }
2160
- );
2161
- }
2162
- return /* @__PURE__ */ jsxRuntime.jsxs(
2163
- "mesh",
2164
- {
2165
- position,
2166
- onClick: (e) => handleTileClick(tile, e),
2167
- onPointerEnter: (e) => handleTileHover(tile, e),
2168
- onPointerLeave: (e) => handleTileHover(null, e),
2169
- userData: { type: "tile", tileId: tile.id, gridX: tile.x, gridZ: tile.z ?? tile.y },
2170
- children: [
2171
- /* @__PURE__ */ jsxRuntime.jsx("boxGeometry", { args: [0.95, 0.2, 0.95] }),
2172
- /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color, emissive })
2173
- ]
2174
- }
2175
- );
2176
- },
2177
- [selectedTileIds, hoveredTile, validMoves, attackTargets, handleTileClick, handleTileHover, assetManifest]
2178
- );
2179
- const UNIT_BASE_MODEL_SCALE = 0.5;
2180
- const UNIT_BASE_BILLBOARD_HEIGHT = 1.2;
2181
- const UNIT_BASE_PRIMITIVE_RADIUS = 0.3;
2182
- const DefaultUnitRenderer = React8.useCallback(
2183
- ({ unit, position }) => {
2184
- const isSelected = selectedUnitId === unit.id;
2185
- const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
2186
- const hasAtlas = unitAtlasUrl(unit) !== null;
2187
- const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
2188
- const unitModel = unit.modelUrl ?? assetManifest?.units?.[unit.unitType ?? ""];
2189
- const modelScale = UNIT_BASE_MODEL_SCALE * unitScale * gridConfig.cellSize;
2190
- const billboardHeight = UNIT_BASE_BILLBOARD_HEIGHT * unitScale * gridConfig.cellSize;
2191
- const primitiveRadius = UNIT_BASE_PRIMITIVE_RADIUS * unitScale * gridConfig.cellSize;
2192
- return /* @__PURE__ */ jsxRuntime.jsxs(
2193
- "group",
2194
- {
2195
- position,
2196
- onClick: (e) => handleUnitClick(unit, e),
2197
- userData: { type: "unit", unitId: unit.id },
2198
- children: [
2199
- isSelected && /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, 0.05, 0], rotation: [-Math.PI / 2, 0, 0], children: [
2200
- /* @__PURE__ */ jsxRuntime.jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
2201
- /* @__PURE__ */ jsxRuntime.jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
2202
- ] }),
2203
- hasAtlas && initialFrame ? (
2204
- /* Animated sprite-sheet billboard — single cropped frame, by state */
2205
- /* @__PURE__ */ jsxRuntime.jsx(drei.Billboard, { children: /* @__PURE__ */ jsxRuntime.jsx(
2206
- UnitSpriteBillboard,
2207
- {
2208
- sheetUrl: initialFrame.sheetUrl,
2209
- resolveFrame: () => resolveUnitFrame(unit.id),
2210
- height: billboardHeight
2211
- }
2212
- ) })
2213
- ) : unitModel?.url ? (
2214
- /* GLB unit model — LOLO's `animation` field drives the named clip */
2215
- /* @__PURE__ */ jsxRuntime.jsx(
2216
- ModelLoader,
2217
- {
2218
- url: unitModel.url,
2219
- scale: modelScale,
2220
- animation: unit.animation ?? "idle",
2221
- fallbackGeometry: "box",
2222
- castShadow: true
2223
- }
2224
- )
2225
- ) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2226
- /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, primitiveRadius, 0], children: [
2227
- /* @__PURE__ */ jsxRuntime.jsx("cylinderGeometry", { args: [primitiveRadius, primitiveRadius, primitiveRadius * 0.33, 8] }),
2228
- /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color })
2229
- ] }),
2230
- /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, primitiveRadius * 2, 0], children: [
2231
- /* @__PURE__ */ jsxRuntime.jsx("capsuleGeometry", { args: [primitiveRadius * 0.67, primitiveRadius * 1.33, 4, 8] }),
2232
- /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color })
2233
- ] }),
2234
- /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, primitiveRadius * 3, 0], children: [
2235
- /* @__PURE__ */ jsxRuntime.jsx("sphereGeometry", { args: [primitiveRadius * 0.4, 8, 8] }),
2236
- /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color })
2237
- ] })
2238
- ] }),
2239
- unit.health !== void 0 && unit.maxHealth !== void 0 && /* @__PURE__ */ jsxRuntime.jsxs("group", { position: [0, billboardHeight, 0], children: [
2240
- /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [-0.25, 0, 0], children: [
2241
- /* @__PURE__ */ jsxRuntime.jsx("planeGeometry", { args: [0.5, 0.05] }),
2242
- /* @__PURE__ */ jsxRuntime.jsx("meshBasicMaterial", { color: 3355443 })
2243
- ] }),
2244
- /* @__PURE__ */ jsxRuntime.jsxs(
2245
- "mesh",
2246
- {
2247
- position: [
2248
- -0.25 + 0.5 * (unit.health / unit.maxHealth) / 2,
2249
- 0,
2250
- 0.01
2251
- ],
2252
- children: [
2253
- /* @__PURE__ */ jsxRuntime.jsx("planeGeometry", { args: [0.5 * (unit.health / unit.maxHealth), 0.05] }),
2254
- /* @__PURE__ */ jsxRuntime.jsx(
2255
- "meshBasicMaterial",
2256
- {
2257
- color: unit.health / unit.maxHealth > 0.5 ? 4500036 : unit.health / unit.maxHealth > 0.25 ? 11184708 : 16729156
2258
- }
2259
- )
2260
- ]
2261
- }
2262
- )
2263
- ] })
2264
- ]
2265
- }
2266
- );
2267
- },
2268
- [selectedUnitId, handleUnitClick, resolveUnitFrame, unitScale, gridConfig, assetManifest]
2269
- );
2270
- const DefaultFeatureRenderer = React8.useCallback(
2271
- ({
2272
- feature,
2273
- position
2274
- }) => {
2275
- const featureModel = feature.assetUrl ?? assetManifest?.features?.[feature.type];
2276
- if (featureModel?.url) {
2277
- return /* @__PURE__ */ jsxRuntime.jsx(
2278
- ModelLoader,
2279
- {
2280
- url: featureModel.url,
2281
- position,
2282
- scale: 0.5,
2283
- rotation: [0, feature.rotation ?? 0, 0],
2284
- onClick: () => handleFeatureClick(feature, null),
2285
- fallbackGeometry: "box"
2286
- },
2287
- feature.id
2288
- );
2289
- }
2290
- if (feature.type === "tree") {
2291
- return /* @__PURE__ */ jsxRuntime.jsxs(
2292
- "group",
2293
- {
2294
- position,
2295
- onClick: (e) => handleFeatureClick(feature, e),
2296
- userData: { type: "feature", featureId: feature.id },
2297
- children: [
2298
- /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, 0.4, 0], children: [
2299
- /* @__PURE__ */ jsxRuntime.jsx("cylinderGeometry", { args: [0.1, 0.15, 0.8, 6] }),
2300
- /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color: 9127187 })
2301
- ] }),
2302
- /* @__PURE__ */ jsxRuntime.jsxs("mesh", { position: [0, 0.9, 0], children: [
2303
- /* @__PURE__ */ jsxRuntime.jsx("coneGeometry", { args: [0.5, 0.8, 8] }),
2304
- /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color: 2263842 })
2305
- ] })
2306
- ]
2307
- }
2308
- );
2309
- }
2310
- if (feature.type === "rock") {
2311
- return /* @__PURE__ */ jsxRuntime.jsxs(
2312
- "mesh",
2313
- {
2314
- position: [position[0], position[1] + 0.3, position[2]],
2315
- onClick: (e) => handleFeatureClick(feature, e),
2316
- userData: { type: "feature", featureId: feature.id },
2317
- children: [
2318
- /* @__PURE__ */ jsxRuntime.jsx("dodecahedronGeometry", { args: [0.3, 0] }),
2319
- /* @__PURE__ */ jsxRuntime.jsx("meshStandardMaterial", { color: 8421504 })
2320
- ]
2321
- }
2322
- );
2323
- }
2324
- return null;
2325
- },
2326
- [handleFeatureClick, assetManifest]
2327
- );
2328
2397
  if (externalLoading || assetsLoading && preloadAssets.length > 0) {
2329
2398
  return /* @__PURE__ */ jsxRuntime.jsx(
2330
2399
  Canvas3DLoadingState,
@@ -2430,7 +2499,10 @@ var GameCanvas3D = React8.forwardRef(
2430
2499
  ppu: pixelsPerUnit,
2431
2500
  playerSprite,
2432
2501
  tileSprites,
2433
- interpolate: interpolateUnits
2502
+ interpolate: interpolateUnits,
2503
+ features,
2504
+ events,
2505
+ assetManifest
2434
2506
  }
2435
2507
  )
2436
2508
  ) : /* @__PURE__ */ jsxRuntime.jsxs("group", { children: [
@@ -2440,8 +2512,25 @@ var GameCanvas3D = React8.forwardRef(
2440
2512
  tile.z ?? tile.y ?? 0,
2441
2513
  tile.elevation ?? 0
2442
2514
  );
2443
- const Renderer = CustomTileRenderer || DefaultTileRenderer;
2444
- return /* @__PURE__ */ jsxRuntime.jsx(Renderer, { tile, position }, tile.id ?? `tile-${index}`);
2515
+ const key = tile.id ?? `tile-${index}`;
2516
+ if (CustomTileRenderer) {
2517
+ return /* @__PURE__ */ jsxRuntime.jsx(CustomTileRenderer, { tile, position }, key);
2518
+ }
2519
+ return /* @__PURE__ */ jsxRuntime.jsx(
2520
+ DefaultTile,
2521
+ {
2522
+ tile,
2523
+ position,
2524
+ model: tile.modelUrl ?? assetManifest?.terrains?.[tile.terrain ?? tile.type ?? ""],
2525
+ isSelected: tile.id ? selectedTileIds.includes(tile.id) : false,
2526
+ isHovered: hoveredTile?.id === tile.id,
2527
+ isValidMove: validMoves.some((m) => m.x === tile.x && m.z === (tile.z ?? tile.y ?? 0)),
2528
+ isAttackTarget: attackTargets.some((m) => m.x === tile.x && m.z === (tile.z ?? tile.y ?? 0)),
2529
+ onTileClick: handleTileClick,
2530
+ onTileHover: handleTileHover
2531
+ },
2532
+ key
2533
+ );
2445
2534
  }),
2446
2535
  features.map((feature, index) => {
2447
2536
  const position = gridToWorld2(
@@ -2449,8 +2538,20 @@ var GameCanvas3D = React8.forwardRef(
2449
2538
  feature.z ?? feature.y ?? 0,
2450
2539
  (feature.elevation ?? 0) + 0.5
2451
2540
  );
2452
- const Renderer = CustomFeatureRenderer || DefaultFeatureRenderer;
2453
- return /* @__PURE__ */ jsxRuntime.jsx(Renderer, { feature, position }, feature.id ?? `feature-${index}`);
2541
+ const key = feature.id ?? `feature-${index}`;
2542
+ if (CustomFeatureRenderer) {
2543
+ return /* @__PURE__ */ jsxRuntime.jsx(CustomFeatureRenderer, { feature, position }, key);
2544
+ }
2545
+ return /* @__PURE__ */ jsxRuntime.jsx(
2546
+ DefaultFeature,
2547
+ {
2548
+ feature,
2549
+ position,
2550
+ model: feature.assetUrl ?? assetManifest?.features?.[feature.type],
2551
+ onFeatureClick: handleFeatureClick
2552
+ },
2553
+ key
2554
+ );
2454
2555
  }),
2455
2556
  units.map((unit) => {
2456
2557
  const position = gridToWorld2(
@@ -2458,8 +2559,30 @@ var GameCanvas3D = React8.forwardRef(
2458
2559
  unit.z ?? unit.y ?? unit.position?.y ?? 0,
2459
2560
  (unit.elevation ?? 0) + 0.5
2460
2561
  );
2461
- const Renderer = CustomUnitRenderer || DefaultUnitRenderer;
2462
- return /* @__PURE__ */ jsxRuntime.jsx(LerpedGroup, { target: position, enabled: interpolateUnits, children: /* @__PURE__ */ jsxRuntime.jsx(Renderer, { unit, position: [0, 0, 0] }) }, unit.id);
2562
+ return /* @__PURE__ */ jsxRuntime.jsx(LerpedGroup, { target: position, enabled: interpolateUnits, children: CustomUnitRenderer ? /* @__PURE__ */ jsxRuntime.jsx(CustomUnitRenderer, { unit, position: [0, 0, 0] }) : /* @__PURE__ */ jsxRuntime.jsx(
2563
+ DefaultUnit,
2564
+ {
2565
+ unit,
2566
+ position: [0, 0, 0],
2567
+ model: unit.modelUrl ?? assetManifest?.units?.[unit.unitType ?? ""],
2568
+ isSelected: selectedUnitId === unit.id,
2569
+ unitScale,
2570
+ cellSize: gridConfig.cellSize,
2571
+ resolveUnitFrame,
2572
+ onUnitClick: handleUnitClick
2573
+ }
2574
+ ) }, unit.id);
2575
+ }),
2576
+ events.map((ev, i) => {
2577
+ const position = gridToWorld2(ev.x, ev.z ?? ev.y ?? 0, 0);
2578
+ return /* @__PURE__ */ jsxRuntime.jsx(
2579
+ EventMarker,
2580
+ {
2581
+ event: ev,
2582
+ position: [position[0], position[1] + 1.4, position[2]]
2583
+ },
2584
+ ev.id ?? `ev-${i}`
2585
+ );
2463
2586
  })
2464
2587
  ] }),
2465
2588
  children,