@almadar/ui 5.96.0 → 5.97.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.
@@ -4,7 +4,7 @@ import { useThree, useFrame, Canvas, useLoader } from '@react-three/fiber';
4
4
  import * as THREE6 from 'three';
5
5
  import { Vector3, QuadraticBezierCurve3, MathUtils, Quaternion } from 'three';
6
6
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
7
- import { OrbitControls, Billboard, Grid, Stars, Sparkles, Html, RoundedBox } from '@react-three/drei';
7
+ import { OrbitControls, Grid, Billboard, Text, Stars, Sparkles, Html, RoundedBox } from '@react-three/drei';
8
8
  import { createLogger } from '@almadar/logger';
9
9
  import { GLTFLoader as GLTFLoader$1 } from 'three/examples/jsm/loaders/GLTFLoader';
10
10
  import { clone } from 'three/examples/jsm/utils/SkeletonUtils';
@@ -307,6 +307,36 @@ var Canvas3DErrorBoundary = class extends Component {
307
307
  }
308
308
  };
309
309
  var log2 = createLogger("almadar:ui:game:model-loader");
310
+ var gltfCache = /* @__PURE__ */ new Map();
311
+ function loadGltfCached(url, assetRoot) {
312
+ const key = `${assetRoot}|${url}`;
313
+ let entry = gltfCache.get(key);
314
+ if (!entry) {
315
+ const pending = {
316
+ promise: new Promise((resolveDone) => {
317
+ const loader = new GLTFLoader$1();
318
+ loader.setResourcePath(assetRoot);
319
+ loader.load(
320
+ url,
321
+ (gltf) => {
322
+ log2.debug("Loaded", { url, clips: gltf.animations.length });
323
+ pending.gltf = { scene: gltf.scene, animations: gltf.animations };
324
+ resolveDone();
325
+ },
326
+ void 0,
327
+ (err) => {
328
+ log2.warn("Failed", { url, error: err instanceof Error ? err : String(err) });
329
+ pending.error = err instanceof Error ? err : new Error(String(err));
330
+ resolveDone();
331
+ }
332
+ );
333
+ })
334
+ };
335
+ entry = pending;
336
+ gltfCache.set(key, entry);
337
+ }
338
+ return entry;
339
+ }
310
340
  function detectAssetRoot(modelUrl) {
311
341
  const idx = modelUrl.indexOf("/3d/");
312
342
  if (idx !== -1) {
@@ -314,45 +344,30 @@ function detectAssetRoot(modelUrl) {
314
344
  }
315
345
  return modelUrl.substring(0, modelUrl.lastIndexOf("/") + 1);
316
346
  }
347
+ function stateFromEntry(entry) {
348
+ if (!entry) return { model: null, clips: [], isLoading: false, error: null };
349
+ if (entry.gltf) return { model: entry.gltf.scene, clips: entry.gltf.animations, isLoading: false, error: null };
350
+ return { model: null, clips: [], isLoading: !entry.error, error: entry.error ?? null };
351
+ }
317
352
  function useGLTFModel(url, resourceBasePath) {
318
- const [state, setState] = useState({
319
- model: null,
320
- clips: [],
321
- isLoading: false,
322
- error: null
323
- });
353
+ const [state, setState] = useState(
354
+ () => stateFromEntry(url ? loadGltfCached(url, resourceBasePath || detectAssetRoot(url)) : null)
355
+ );
324
356
  useEffect(() => {
325
357
  if (!url) {
326
358
  setState({ model: null, clips: [], isLoading: false, error: null });
327
359
  return;
328
360
  }
329
- log2.debug("Loading", { url });
330
- setState((prev) => ({ ...prev, isLoading: true, error: null }));
331
- const assetRoot = resourceBasePath || detectAssetRoot(url);
332
- const loader = new GLTFLoader$1();
333
- loader.setResourcePath(assetRoot);
334
- loader.load(
335
- url,
336
- (gltf) => {
337
- log2.debug("Loaded", { url, clips: gltf.animations.length });
338
- setState({
339
- model: gltf.scene,
340
- clips: gltf.animations,
341
- isLoading: false,
342
- error: null
343
- });
344
- },
345
- void 0,
346
- (err) => {
347
- log2.warn("Failed", { url, error: err instanceof Error ? err : String(err) });
348
- setState({
349
- model: null,
350
- clips: [],
351
- isLoading: false,
352
- error: err instanceof Error ? err : new Error(String(err))
353
- });
354
- }
355
- );
361
+ const entry = loadGltfCached(url, resourceBasePath || detectAssetRoot(url));
362
+ setState(stateFromEntry(entry));
363
+ if (entry.gltf || entry.error) return;
364
+ let cancelled = false;
365
+ void entry.promise.then(() => {
366
+ if (!cancelled) setState(stateFromEntry(entry));
367
+ });
368
+ return () => {
369
+ cancelled = true;
370
+ };
356
371
  }, [url, resourceBasePath]);
357
372
  return state;
358
373
  }
@@ -369,21 +384,28 @@ function ModelLoader({
369
384
  castShadow = true,
370
385
  receiveShadow = true,
371
386
  resourceBasePath,
372
- animation
387
+ animation,
388
+ tint
373
389
  }) {
374
390
  const { model: loadedModel, clips, isLoading, error } = useGLTFModel(url, resourceBasePath);
375
391
  const model = useMemo(() => {
376
392
  if (!loadedModel) return null;
377
393
  const cloned = clone(loadedModel);
378
394
  cloned.updateMatrixWorld(true);
395
+ const tintColor = tint ? new THREE6.Color(tint) : null;
379
396
  cloned.traverse((child) => {
380
397
  if (child instanceof THREE6.Mesh) {
381
398
  child.castShadow = castShadow;
382
399
  child.receiveShadow = receiveShadow;
400
+ if (tintColor && child.material instanceof THREE6.MeshStandardMaterial) {
401
+ const mat = child.material.clone();
402
+ mat.color.multiply(tintColor);
403
+ child.material = mat;
404
+ }
383
405
  }
384
406
  });
385
407
  return cloned;
386
- }, [loadedModel, castShadow, receiveShadow]);
408
+ }, [loadedModel, castShadow, receiveShadow, tint]);
387
409
  const mixer = useMemo(() => model ? new THREE6.AnimationMixer(model) : null, [model]);
388
410
  useEffect(() => {
389
411
  if (!mixer || !animation || clips.length === 0) return;
@@ -1760,7 +1782,10 @@ function SideScene({
1760
1782
  ppu,
1761
1783
  playerSprite,
1762
1784
  tileSprites,
1763
- interpolate
1785
+ interpolate,
1786
+ features = [],
1787
+ events = [],
1788
+ assetManifest
1764
1789
  }) {
1765
1790
  const pw = player.width ?? 32;
1766
1791
  const ph = player.height ?? 48;
@@ -1792,6 +1817,21 @@ function SideScene({
1792
1817
  `plat-${i}`
1793
1818
  );
1794
1819
  }),
1820
+ features.map((feature, i) => {
1821
+ const model = feature.assetUrl ?? assetManifest?.features?.[feature.type];
1822
+ const fx = feature.x / ppu;
1823
+ const fy = (worldHeight - feature.y) / ppu;
1824
+ if (!model?.url) return null;
1825
+ return /* @__PURE__ */ jsx("group", { position: [fx, fy, 0], children: /* @__PURE__ */ jsx(ModelLoader, { url: model.url, scale: 0.6, tint: feature.color, fallbackGeometry: "sphere", castShadow: true }) }, feature.id ?? `sfeat-${i}`);
1826
+ }),
1827
+ events.map((ev, i) => /* @__PURE__ */ jsx(
1828
+ EventMarker,
1829
+ {
1830
+ event: ev,
1831
+ position: [ev.x / ppu, (worldHeight - (ev.y ?? 0)) / ppu + 0.8, 0.2]
1832
+ },
1833
+ ev.id ?? `sev-${i}`
1834
+ )),
1795
1835
  /* @__PURE__ */ jsx(LerpedGroup, { target: playerPos, enabled: interpolate, children: playerSprite?.url ? /* @__PURE__ */ jsx("group", { position: [0, playerH / 2, 0], children: /* @__PURE__ */ jsx(
1796
1836
  ModelLoader,
1797
1837
  {
@@ -1808,6 +1848,245 @@ function SideScene({
1808
1848
  ] }) })
1809
1849
  ] });
1810
1850
  }
1851
+ var UNIT_BASE_MODEL_SCALE = 0.5;
1852
+ var UNIT_BASE_BILLBOARD_HEIGHT = 1.2;
1853
+ var UNIT_BASE_PRIMITIVE_RADIUS = 0.3;
1854
+ var EVENT_COLORS = {
1855
+ hit: "#ff5544",
1856
+ damage: "#ff5544",
1857
+ attack: "#ff8844",
1858
+ heal: "#44dd66",
1859
+ pickup: "#ffcc33",
1860
+ score: "#ffcc33",
1861
+ death: "#aa3333",
1862
+ win: "#66ff88",
1863
+ lose: "#ff6666"
1864
+ };
1865
+ function EventMarker({
1866
+ event,
1867
+ position
1868
+ }) {
1869
+ return /* @__PURE__ */ jsx(Billboard, { position, children: /* @__PURE__ */ jsx(
1870
+ Text,
1871
+ {
1872
+ fontSize: 0.32,
1873
+ color: EVENT_COLORS[event.type] ?? "#ffffff",
1874
+ outlineWidth: 0.02,
1875
+ outlineColor: "#000000",
1876
+ anchorX: "center",
1877
+ anchorY: "middle",
1878
+ children: event.message ?? event.type
1879
+ }
1880
+ ) });
1881
+ }
1882
+ function DefaultTile({
1883
+ tile,
1884
+ position,
1885
+ model,
1886
+ isSelected,
1887
+ isHovered,
1888
+ isValidMove,
1889
+ isAttackTarget,
1890
+ onTileClick,
1891
+ onTileHover
1892
+ }) {
1893
+ let color = 8421504;
1894
+ if (tile.type === "water") color = 4491468;
1895
+ else if (tile.type === "grass") color = 4500036;
1896
+ else if (tile.type === "sand") color = 14535816;
1897
+ else if (tile.type === "rock") color = 8947848;
1898
+ else if (tile.type === "snow") color = 15658734;
1899
+ let emissive = 0;
1900
+ if (isSelected) emissive = 4473924;
1901
+ else if (isAttackTarget) emissive = 4456448;
1902
+ else if (isValidMove) emissive = 17408;
1903
+ else if (isHovered) emissive = 2236962;
1904
+ if (model?.url) {
1905
+ return /* @__PURE__ */ jsx(
1906
+ "group",
1907
+ {
1908
+ position,
1909
+ onClick: (e) => onTileClick(tile, e),
1910
+ onPointerEnter: (e) => onTileHover(tile, e),
1911
+ onPointerLeave: (e) => onTileHover(null, e),
1912
+ userData: { type: "tile", tileId: tile.id, gridX: tile.x, gridZ: tile.z ?? tile.y },
1913
+ children: /* @__PURE__ */ jsx(
1914
+ ModelLoader,
1915
+ {
1916
+ url: model.url,
1917
+ scale: 0.95,
1918
+ rotation: [0, tile.rotation ?? 0, 0],
1919
+ fallbackGeometry: "box",
1920
+ castShadow: true,
1921
+ receiveShadow: true
1922
+ }
1923
+ )
1924
+ }
1925
+ );
1926
+ }
1927
+ return /* @__PURE__ */ jsxs(
1928
+ "mesh",
1929
+ {
1930
+ position,
1931
+ onClick: (e) => onTileClick(tile, e),
1932
+ onPointerEnter: (e) => onTileHover(tile, e),
1933
+ onPointerLeave: (e) => onTileHover(null, e),
1934
+ userData: { type: "tile", tileId: tile.id, gridX: tile.x, gridZ: tile.z ?? tile.y },
1935
+ children: [
1936
+ /* @__PURE__ */ jsx("boxGeometry", { args: [0.95, 0.2, 0.95] }),
1937
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color, emissive })
1938
+ ]
1939
+ }
1940
+ );
1941
+ }
1942
+ function DefaultUnit({
1943
+ unit,
1944
+ position,
1945
+ model,
1946
+ isSelected,
1947
+ unitScale,
1948
+ cellSize,
1949
+ resolveUnitFrame,
1950
+ onUnitClick
1951
+ }) {
1952
+ const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
1953
+ const hasAtlas = unitAtlasUrl(unit) !== null;
1954
+ const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
1955
+ const modelScale = UNIT_BASE_MODEL_SCALE * unitScale * cellSize;
1956
+ const billboardHeight = UNIT_BASE_BILLBOARD_HEIGHT * unitScale * cellSize;
1957
+ const primitiveRadius = UNIT_BASE_PRIMITIVE_RADIUS * unitScale * cellSize;
1958
+ return /* @__PURE__ */ jsxs(
1959
+ "group",
1960
+ {
1961
+ position,
1962
+ onClick: (e) => onUnitClick(unit, e),
1963
+ userData: { type: "unit", unitId: unit.id },
1964
+ children: [
1965
+ isSelected && /* @__PURE__ */ jsxs("mesh", { position: [0, 0.05, 0], rotation: [-Math.PI / 2, 0, 0], children: [
1966
+ /* @__PURE__ */ jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
1967
+ /* @__PURE__ */ jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
1968
+ ] }),
1969
+ hasAtlas && initialFrame ? (
1970
+ /* Animated sprite-sheet billboard — single cropped frame, by state */
1971
+ /* @__PURE__ */ jsx(Billboard, { children: /* @__PURE__ */ jsx(
1972
+ UnitSpriteBillboard,
1973
+ {
1974
+ sheetUrl: initialFrame.sheetUrl,
1975
+ resolveFrame: () => resolveUnitFrame(unit.id),
1976
+ height: billboardHeight
1977
+ }
1978
+ ) })
1979
+ ) : model?.url ? (
1980
+ /* GLB unit model — LOLO's `animation` field drives the named clip */
1981
+ /* @__PURE__ */ jsx(
1982
+ ModelLoader,
1983
+ {
1984
+ url: model.url,
1985
+ scale: modelScale,
1986
+ animation: unit.animation ?? "idle",
1987
+ fallbackGeometry: "box",
1988
+ castShadow: true
1989
+ }
1990
+ )
1991
+ ) : /* @__PURE__ */ jsxs(Fragment, { children: [
1992
+ /* @__PURE__ */ jsxs("mesh", { position: [0, primitiveRadius, 0], children: [
1993
+ /* @__PURE__ */ jsx("cylinderGeometry", { args: [primitiveRadius, primitiveRadius, primitiveRadius * 0.33, 8] }),
1994
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color })
1995
+ ] }),
1996
+ /* @__PURE__ */ jsxs("mesh", { position: [0, primitiveRadius * 2, 0], children: [
1997
+ /* @__PURE__ */ jsx("capsuleGeometry", { args: [primitiveRadius * 0.67, primitiveRadius * 1.33, 4, 8] }),
1998
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color })
1999
+ ] }),
2000
+ /* @__PURE__ */ jsxs("mesh", { position: [0, primitiveRadius * 3, 0], children: [
2001
+ /* @__PURE__ */ jsx("sphereGeometry", { args: [primitiveRadius * 0.4, 8, 8] }),
2002
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color })
2003
+ ] })
2004
+ ] }),
2005
+ unit.health !== void 0 && unit.maxHealth !== void 0 && /* @__PURE__ */ jsxs("group", { position: [0, billboardHeight, 0], children: [
2006
+ /* @__PURE__ */ jsxs("mesh", { position: [-0.25, 0, 0], children: [
2007
+ /* @__PURE__ */ jsx("planeGeometry", { args: [0.5, 0.05] }),
2008
+ /* @__PURE__ */ jsx("meshBasicMaterial", { color: 3355443 })
2009
+ ] }),
2010
+ /* @__PURE__ */ jsxs(
2011
+ "mesh",
2012
+ {
2013
+ position: [
2014
+ -0.25 + 0.5 * (unit.health / unit.maxHealth) / 2,
2015
+ 0,
2016
+ 0.01
2017
+ ],
2018
+ children: [
2019
+ /* @__PURE__ */ jsx("planeGeometry", { args: [0.5 * (unit.health / unit.maxHealth), 0.05] }),
2020
+ /* @__PURE__ */ jsx(
2021
+ "meshBasicMaterial",
2022
+ {
2023
+ color: unit.health / unit.maxHealth > 0.5 ? 4500036 : unit.health / unit.maxHealth > 0.25 ? 11184708 : 16729156
2024
+ }
2025
+ )
2026
+ ]
2027
+ }
2028
+ )
2029
+ ] })
2030
+ ]
2031
+ }
2032
+ );
2033
+ }
2034
+ function DefaultFeature({
2035
+ feature,
2036
+ position,
2037
+ model,
2038
+ onFeatureClick
2039
+ }) {
2040
+ if (model?.url) {
2041
+ return /* @__PURE__ */ jsx(
2042
+ ModelLoader,
2043
+ {
2044
+ url: model.url,
2045
+ position,
2046
+ scale: 0.5,
2047
+ rotation: [0, feature.rotation ?? 0, 0],
2048
+ tint: feature.color,
2049
+ onClick: () => onFeatureClick(feature, null),
2050
+ fallbackGeometry: "box"
2051
+ }
2052
+ );
2053
+ }
2054
+ if (feature.type === "tree") {
2055
+ return /* @__PURE__ */ jsxs(
2056
+ "group",
2057
+ {
2058
+ position,
2059
+ onClick: (e) => onFeatureClick(feature, e),
2060
+ userData: { type: "feature", featureId: feature.id },
2061
+ children: [
2062
+ /* @__PURE__ */ jsxs("mesh", { position: [0, 0.4, 0], children: [
2063
+ /* @__PURE__ */ jsx("cylinderGeometry", { args: [0.1, 0.15, 0.8, 6] }),
2064
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: 9127187 })
2065
+ ] }),
2066
+ /* @__PURE__ */ jsxs("mesh", { position: [0, 0.9, 0], children: [
2067
+ /* @__PURE__ */ jsx("coneGeometry", { args: [0.5, 0.8, 8] }),
2068
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: 2263842 })
2069
+ ] })
2070
+ ]
2071
+ }
2072
+ );
2073
+ }
2074
+ if (feature.type === "rock") {
2075
+ return /* @__PURE__ */ jsxs(
2076
+ "mesh",
2077
+ {
2078
+ position: [position[0], position[1] + 0.3, position[2]],
2079
+ onClick: (e) => onFeatureClick(feature, e),
2080
+ userData: { type: "feature", featureId: feature.id },
2081
+ children: [
2082
+ /* @__PURE__ */ jsx("dodecahedronGeometry", { args: [0.3, 0] }),
2083
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: 8421504 })
2084
+ ]
2085
+ }
2086
+ );
2087
+ }
2088
+ return null;
2089
+ }
1811
2090
  var GameCanvas3D = forwardRef(
1812
2091
  ({
1813
2092
  tiles = [],
@@ -2094,216 +2373,6 @@ var GameCanvas3D = forwardRef(
2094
2373
  }
2095
2374
  return cameraTarget;
2096
2375
  }, [player, pixelsPerUnit, worldHeight, units, selectedUnitId, gridBounds, gridConfig, cameraTarget]);
2097
- const DefaultTileRenderer = useCallback(
2098
- ({ tile, position }) => {
2099
- const isSelected = tile.id ? selectedTileIds.includes(tile.id) : false;
2100
- const isHovered = hoveredTile?.id === tile.id;
2101
- const isValidMove = validMoves.some(
2102
- (m) => m.x === tile.x && m.z === (tile.z ?? tile.y ?? 0)
2103
- );
2104
- const isAttackTarget = attackTargets.some(
2105
- (m) => m.x === tile.x && m.z === (tile.z ?? tile.y ?? 0)
2106
- );
2107
- let color = 8421504;
2108
- if (tile.type === "water") color = 4491468;
2109
- else if (tile.type === "grass") color = 4500036;
2110
- else if (tile.type === "sand") color = 14535816;
2111
- else if (tile.type === "rock") color = 8947848;
2112
- else if (tile.type === "snow") color = 15658734;
2113
- let emissive = 0;
2114
- if (isSelected) emissive = 4473924;
2115
- else if (isAttackTarget) emissive = 4456448;
2116
- else if (isValidMove) emissive = 17408;
2117
- else if (isHovered) emissive = 2236962;
2118
- const tileModel = tile.modelUrl ?? assetManifest?.terrains?.[tile.terrain ?? tile.type ?? ""];
2119
- if (tileModel?.url) {
2120
- return /* @__PURE__ */ jsx(
2121
- "group",
2122
- {
2123
- position,
2124
- onClick: (e) => handleTileClick(tile, e),
2125
- onPointerEnter: (e) => handleTileHover(tile, e),
2126
- onPointerLeave: (e) => handleTileHover(null, e),
2127
- userData: { type: "tile", tileId: tile.id, gridX: tile.x, gridZ: tile.z ?? tile.y },
2128
- children: /* @__PURE__ */ jsx(
2129
- ModelLoader,
2130
- {
2131
- url: tileModel.url,
2132
- scale: 0.95,
2133
- fallbackGeometry: "box",
2134
- castShadow: true,
2135
- receiveShadow: true
2136
- }
2137
- )
2138
- }
2139
- );
2140
- }
2141
- return /* @__PURE__ */ jsxs(
2142
- "mesh",
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: [
2150
- /* @__PURE__ */ jsx("boxGeometry", { args: [0.95, 0.2, 0.95] }),
2151
- /* @__PURE__ */ jsx("meshStandardMaterial", { color, emissive })
2152
- ]
2153
- }
2154
- );
2155
- },
2156
- [selectedTileIds, hoveredTile, validMoves, attackTargets, handleTileClick, handleTileHover, assetManifest]
2157
- );
2158
- const UNIT_BASE_MODEL_SCALE = 0.5;
2159
- const UNIT_BASE_BILLBOARD_HEIGHT = 1.2;
2160
- const UNIT_BASE_PRIMITIVE_RADIUS = 0.3;
2161
- const DefaultUnitRenderer = useCallback(
2162
- ({ unit, position }) => {
2163
- const isSelected = selectedUnitId === unit.id;
2164
- const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
2165
- const hasAtlas = unitAtlasUrl(unit) !== null;
2166
- const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
2167
- const unitModel = unit.modelUrl ?? assetManifest?.units?.[unit.unitType ?? ""];
2168
- const modelScale = UNIT_BASE_MODEL_SCALE * unitScale * gridConfig.cellSize;
2169
- const billboardHeight = UNIT_BASE_BILLBOARD_HEIGHT * unitScale * gridConfig.cellSize;
2170
- const primitiveRadius = UNIT_BASE_PRIMITIVE_RADIUS * unitScale * gridConfig.cellSize;
2171
- return /* @__PURE__ */ jsxs(
2172
- "group",
2173
- {
2174
- position,
2175
- onClick: (e) => handleUnitClick(unit, e),
2176
- userData: { type: "unit", unitId: unit.id },
2177
- children: [
2178
- isSelected && /* @__PURE__ */ jsxs("mesh", { position: [0, 0.05, 0], rotation: [-Math.PI / 2, 0, 0], children: [
2179
- /* @__PURE__ */ jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
2180
- /* @__PURE__ */ jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
2181
- ] }),
2182
- hasAtlas && initialFrame ? (
2183
- /* Animated sprite-sheet billboard — single cropped frame, by state */
2184
- /* @__PURE__ */ jsx(Billboard, { children: /* @__PURE__ */ jsx(
2185
- UnitSpriteBillboard,
2186
- {
2187
- sheetUrl: initialFrame.sheetUrl,
2188
- resolveFrame: () => resolveUnitFrame(unit.id),
2189
- height: billboardHeight
2190
- }
2191
- ) })
2192
- ) : unitModel?.url ? (
2193
- /* GLB unit model — LOLO's `animation` field drives the named clip */
2194
- /* @__PURE__ */ jsx(
2195
- ModelLoader,
2196
- {
2197
- url: unitModel.url,
2198
- scale: modelScale,
2199
- animation: unit.animation ?? "idle",
2200
- fallbackGeometry: "box",
2201
- castShadow: true
2202
- }
2203
- )
2204
- ) : /* @__PURE__ */ jsxs(Fragment, { children: [
2205
- /* @__PURE__ */ jsxs("mesh", { position: [0, primitiveRadius, 0], children: [
2206
- /* @__PURE__ */ jsx("cylinderGeometry", { args: [primitiveRadius, primitiveRadius, primitiveRadius * 0.33, 8] }),
2207
- /* @__PURE__ */ jsx("meshStandardMaterial", { color })
2208
- ] }),
2209
- /* @__PURE__ */ jsxs("mesh", { position: [0, primitiveRadius * 2, 0], children: [
2210
- /* @__PURE__ */ jsx("capsuleGeometry", { args: [primitiveRadius * 0.67, primitiveRadius * 1.33, 4, 8] }),
2211
- /* @__PURE__ */ jsx("meshStandardMaterial", { color })
2212
- ] }),
2213
- /* @__PURE__ */ jsxs("mesh", { position: [0, primitiveRadius * 3, 0], children: [
2214
- /* @__PURE__ */ jsx("sphereGeometry", { args: [primitiveRadius * 0.4, 8, 8] }),
2215
- /* @__PURE__ */ jsx("meshStandardMaterial", { color })
2216
- ] })
2217
- ] }),
2218
- unit.health !== void 0 && unit.maxHealth !== void 0 && /* @__PURE__ */ jsxs("group", { position: [0, billboardHeight, 0], children: [
2219
- /* @__PURE__ */ jsxs("mesh", { position: [-0.25, 0, 0], children: [
2220
- /* @__PURE__ */ jsx("planeGeometry", { args: [0.5, 0.05] }),
2221
- /* @__PURE__ */ jsx("meshBasicMaterial", { color: 3355443 })
2222
- ] }),
2223
- /* @__PURE__ */ jsxs(
2224
- "mesh",
2225
- {
2226
- position: [
2227
- -0.25 + 0.5 * (unit.health / unit.maxHealth) / 2,
2228
- 0,
2229
- 0.01
2230
- ],
2231
- children: [
2232
- /* @__PURE__ */ jsx("planeGeometry", { args: [0.5 * (unit.health / unit.maxHealth), 0.05] }),
2233
- /* @__PURE__ */ jsx(
2234
- "meshBasicMaterial",
2235
- {
2236
- color: unit.health / unit.maxHealth > 0.5 ? 4500036 : unit.health / unit.maxHealth > 0.25 ? 11184708 : 16729156
2237
- }
2238
- )
2239
- ]
2240
- }
2241
- )
2242
- ] })
2243
- ]
2244
- }
2245
- );
2246
- },
2247
- [selectedUnitId, handleUnitClick, resolveUnitFrame, unitScale, gridConfig, assetManifest]
2248
- );
2249
- const DefaultFeatureRenderer = useCallback(
2250
- ({
2251
- feature,
2252
- position
2253
- }) => {
2254
- const featureModel = feature.assetUrl ?? assetManifest?.features?.[feature.type];
2255
- if (featureModel?.url) {
2256
- return /* @__PURE__ */ jsx(
2257
- ModelLoader,
2258
- {
2259
- url: featureModel.url,
2260
- position,
2261
- scale: 0.5,
2262
- rotation: [0, feature.rotation ?? 0, 0],
2263
- onClick: () => handleFeatureClick(feature, null),
2264
- fallbackGeometry: "box"
2265
- },
2266
- feature.id
2267
- );
2268
- }
2269
- if (feature.type === "tree") {
2270
- return /* @__PURE__ */ jsxs(
2271
- "group",
2272
- {
2273
- position,
2274
- onClick: (e) => handleFeatureClick(feature, e),
2275
- userData: { type: "feature", featureId: feature.id },
2276
- children: [
2277
- /* @__PURE__ */ jsxs("mesh", { position: [0, 0.4, 0], children: [
2278
- /* @__PURE__ */ jsx("cylinderGeometry", { args: [0.1, 0.15, 0.8, 6] }),
2279
- /* @__PURE__ */ jsx("meshStandardMaterial", { color: 9127187 })
2280
- ] }),
2281
- /* @__PURE__ */ jsxs("mesh", { position: [0, 0.9, 0], children: [
2282
- /* @__PURE__ */ jsx("coneGeometry", { args: [0.5, 0.8, 8] }),
2283
- /* @__PURE__ */ jsx("meshStandardMaterial", { color: 2263842 })
2284
- ] })
2285
- ]
2286
- }
2287
- );
2288
- }
2289
- if (feature.type === "rock") {
2290
- return /* @__PURE__ */ jsxs(
2291
- "mesh",
2292
- {
2293
- position: [position[0], position[1] + 0.3, position[2]],
2294
- onClick: (e) => handleFeatureClick(feature, e),
2295
- userData: { type: "feature", featureId: feature.id },
2296
- children: [
2297
- /* @__PURE__ */ jsx("dodecahedronGeometry", { args: [0.3, 0] }),
2298
- /* @__PURE__ */ jsx("meshStandardMaterial", { color: 8421504 })
2299
- ]
2300
- }
2301
- );
2302
- }
2303
- return null;
2304
- },
2305
- [handleFeatureClick, assetManifest]
2306
- );
2307
2376
  if (externalLoading || assetsLoading && preloadAssets.length > 0) {
2308
2377
  return /* @__PURE__ */ jsx(
2309
2378
  Canvas3DLoadingState,
@@ -2409,7 +2478,10 @@ var GameCanvas3D = forwardRef(
2409
2478
  ppu: pixelsPerUnit,
2410
2479
  playerSprite,
2411
2480
  tileSprites,
2412
- interpolate: interpolateUnits
2481
+ interpolate: interpolateUnits,
2482
+ features,
2483
+ events,
2484
+ assetManifest
2413
2485
  }
2414
2486
  )
2415
2487
  ) : /* @__PURE__ */ jsxs("group", { children: [
@@ -2419,8 +2491,25 @@ var GameCanvas3D = forwardRef(
2419
2491
  tile.z ?? tile.y ?? 0,
2420
2492
  tile.elevation ?? 0
2421
2493
  );
2422
- const Renderer = CustomTileRenderer || DefaultTileRenderer;
2423
- return /* @__PURE__ */ jsx(Renderer, { tile, position }, tile.id ?? `tile-${index}`);
2494
+ const key = tile.id ?? `tile-${index}`;
2495
+ if (CustomTileRenderer) {
2496
+ return /* @__PURE__ */ jsx(CustomTileRenderer, { tile, position }, key);
2497
+ }
2498
+ return /* @__PURE__ */ jsx(
2499
+ DefaultTile,
2500
+ {
2501
+ tile,
2502
+ position,
2503
+ model: tile.modelUrl ?? assetManifest?.terrains?.[tile.terrain ?? tile.type ?? ""],
2504
+ isSelected: tile.id ? selectedTileIds.includes(tile.id) : false,
2505
+ isHovered: hoveredTile?.id === tile.id,
2506
+ isValidMove: validMoves.some((m) => m.x === tile.x && m.z === (tile.z ?? tile.y ?? 0)),
2507
+ isAttackTarget: attackTargets.some((m) => m.x === tile.x && m.z === (tile.z ?? tile.y ?? 0)),
2508
+ onTileClick: handleTileClick,
2509
+ onTileHover: handleTileHover
2510
+ },
2511
+ key
2512
+ );
2424
2513
  }),
2425
2514
  features.map((feature, index) => {
2426
2515
  const position = gridToWorld2(
@@ -2428,8 +2517,20 @@ var GameCanvas3D = forwardRef(
2428
2517
  feature.z ?? feature.y ?? 0,
2429
2518
  (feature.elevation ?? 0) + 0.5
2430
2519
  );
2431
- const Renderer = CustomFeatureRenderer || DefaultFeatureRenderer;
2432
- return /* @__PURE__ */ jsx(Renderer, { feature, position }, feature.id ?? `feature-${index}`);
2520
+ const key = feature.id ?? `feature-${index}`;
2521
+ if (CustomFeatureRenderer) {
2522
+ return /* @__PURE__ */ jsx(CustomFeatureRenderer, { feature, position }, key);
2523
+ }
2524
+ return /* @__PURE__ */ jsx(
2525
+ DefaultFeature,
2526
+ {
2527
+ feature,
2528
+ position,
2529
+ model: feature.assetUrl ?? assetManifest?.features?.[feature.type],
2530
+ onFeatureClick: handleFeatureClick
2531
+ },
2532
+ key
2533
+ );
2433
2534
  }),
2434
2535
  units.map((unit) => {
2435
2536
  const position = gridToWorld2(
@@ -2437,8 +2538,30 @@ var GameCanvas3D = forwardRef(
2437
2538
  unit.z ?? unit.y ?? unit.position?.y ?? 0,
2438
2539
  (unit.elevation ?? 0) + 0.5
2439
2540
  );
2440
- const Renderer = CustomUnitRenderer || DefaultUnitRenderer;
2441
- return /* @__PURE__ */ jsx(LerpedGroup, { target: position, enabled: interpolateUnits, children: /* @__PURE__ */ jsx(Renderer, { unit, position: [0, 0, 0] }) }, unit.id);
2541
+ return /* @__PURE__ */ jsx(LerpedGroup, { target: position, enabled: interpolateUnits, children: CustomUnitRenderer ? /* @__PURE__ */ jsx(CustomUnitRenderer, { unit, position: [0, 0, 0] }) : /* @__PURE__ */ jsx(
2542
+ DefaultUnit,
2543
+ {
2544
+ unit,
2545
+ position: [0, 0, 0],
2546
+ model: unit.modelUrl ?? assetManifest?.units?.[unit.unitType ?? ""],
2547
+ isSelected: selectedUnitId === unit.id,
2548
+ unitScale,
2549
+ cellSize: gridConfig.cellSize,
2550
+ resolveUnitFrame,
2551
+ onUnitClick: handleUnitClick
2552
+ }
2553
+ ) }, unit.id);
2554
+ }),
2555
+ events.map((ev, i) => {
2556
+ const position = gridToWorld2(ev.x, ev.z ?? ev.y ?? 0, 0);
2557
+ return /* @__PURE__ */ jsx(
2558
+ EventMarker,
2559
+ {
2560
+ event: ev,
2561
+ position: [position[0], position[1] + 1.4, position[2]]
2562
+ },
2563
+ ev.id ?? `ev-${i}`
2564
+ );
2442
2565
  })
2443
2566
  ] }),
2444
2567
  children,