@almadar/ui 5.95.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.
@@ -1,9 +1,10 @@
1
- import React6, { forwardRef, useRef, useEffect, useImperativeHandle, useState, useMemo, useCallback, createContext, Component, useContext, useSyncExternalStore, useReducer } from 'react';
1
+ import * as React8 from 'react';
2
+ import React8__default, { forwardRef, useRef, useEffect, useImperativeHandle, useState, useMemo, useCallback, createContext, useContext, Component, useSyncExternalStore, useReducer } from 'react';
2
3
  import { useThree, useFrame, Canvas, useLoader } from '@react-three/fiber';
3
4
  import * as THREE6 from 'three';
4
5
  import { Vector3, QuadraticBezierCurve3, MathUtils, Quaternion } from 'three';
5
6
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
6
- 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';
7
8
  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';
@@ -306,6 +307,36 @@ var Canvas3DErrorBoundary = class extends Component {
306
307
  }
307
308
  };
308
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
+ }
309
340
  function detectAssetRoot(modelUrl) {
310
341
  const idx = modelUrl.indexOf("/3d/");
311
342
  if (idx !== -1) {
@@ -313,42 +344,30 @@ function detectAssetRoot(modelUrl) {
313
344
  }
314
345
  return modelUrl.substring(0, modelUrl.lastIndexOf("/") + 1);
315
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
+ }
316
352
  function useGLTFModel(url, resourceBasePath) {
317
- const [state, setState] = useState({
318
- model: null,
319
- isLoading: false,
320
- error: null
321
- });
353
+ const [state, setState] = useState(
354
+ () => stateFromEntry(url ? loadGltfCached(url, resourceBasePath || detectAssetRoot(url)) : null)
355
+ );
322
356
  useEffect(() => {
323
357
  if (!url) {
324
- setState({ model: null, isLoading: false, error: null });
358
+ setState({ model: null, clips: [], isLoading: false, error: null });
325
359
  return;
326
360
  }
327
- log2.debug("Loading", { url });
328
- setState((prev) => ({ ...prev, isLoading: true, error: null }));
329
- const assetRoot = resourceBasePath || detectAssetRoot(url);
330
- const loader = new GLTFLoader$1();
331
- loader.setResourcePath(assetRoot);
332
- loader.load(
333
- url,
334
- (gltf) => {
335
- log2.debug("Loaded", { url });
336
- setState({
337
- model: gltf.scene,
338
- isLoading: false,
339
- error: null
340
- });
341
- },
342
- void 0,
343
- (err) => {
344
- log2.warn("Failed", { url, error: err instanceof Error ? err : String(err) });
345
- setState({
346
- model: null,
347
- isLoading: false,
348
- error: err instanceof Error ? err : new Error(String(err))
349
- });
350
- }
351
- );
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
+ };
352
371
  }, [url, resourceBasePath]);
353
372
  return state;
354
373
  }
@@ -364,21 +383,44 @@ function ModelLoader({
364
383
  fallbackGeometry = "box",
365
384
  castShadow = true,
366
385
  receiveShadow = true,
367
- resourceBasePath
386
+ resourceBasePath,
387
+ animation,
388
+ tint
368
389
  }) {
369
- const { model: loadedModel, isLoading, error } = useGLTFModel(url, resourceBasePath);
390
+ const { model: loadedModel, clips, isLoading, error } = useGLTFModel(url, resourceBasePath);
370
391
  const model = useMemo(() => {
371
392
  if (!loadedModel) return null;
372
393
  const cloned = clone(loadedModel);
373
394
  cloned.updateMatrixWorld(true);
395
+ const tintColor = tint ? new THREE6.Color(tint) : null;
374
396
  cloned.traverse((child) => {
375
397
  if (child instanceof THREE6.Mesh) {
376
398
  child.castShadow = castShadow;
377
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
+ }
378
405
  }
379
406
  });
380
407
  return cloned;
381
- }, [loadedModel, castShadow, receiveShadow]);
408
+ }, [loadedModel, castShadow, receiveShadow, tint]);
409
+ const mixer = useMemo(() => model ? new THREE6.AnimationMixer(model) : null, [model]);
410
+ useEffect(() => {
411
+ if (!mixer || !animation || clips.length === 0) return;
412
+ const wanted = animation.toLowerCase();
413
+ const clip = clips.find((c) => c.name === animation) ?? clips.find((c) => c.name.toLowerCase() === wanted);
414
+ if (!clip) return;
415
+ const action = mixer.clipAction(clip);
416
+ action.reset().fadeIn(0.15).play();
417
+ return () => {
418
+ action.fadeOut(0.15);
419
+ };
420
+ }, [mixer, clips, animation]);
421
+ useFrame((_, delta) => {
422
+ mixer?.update(delta);
423
+ });
382
424
  const normFactor = useMemo(() => {
383
425
  if (!model) return 1;
384
426
  const box = new THREE6.Box3().setFromObject(model);
@@ -1695,6 +1737,356 @@ function UnitSpriteBillboard({
1695
1737
  )
1696
1738
  ] });
1697
1739
  }
1740
+ function FollowCamera({
1741
+ target,
1742
+ offset
1743
+ }) {
1744
+ const { camera } = useThree();
1745
+ const look = useRef(new THREE6.Vector3(target[0], target[1], target[2]));
1746
+ const goal = useRef(new THREE6.Vector3());
1747
+ useFrame((_, delta) => {
1748
+ const t = Math.min(1, delta * 5);
1749
+ goal.current.set(target[0] + offset[0], target[1] + offset[1], target[2] + offset[2]);
1750
+ camera.position.lerp(goal.current, t);
1751
+ look.current.lerp(goal.current.set(target[0], target[1], target[2]), t);
1752
+ camera.lookAt(look.current);
1753
+ });
1754
+ return null;
1755
+ }
1756
+ function LerpedGroup({
1757
+ target,
1758
+ enabled,
1759
+ children
1760
+ }) {
1761
+ const ref = useRef(null);
1762
+ const goal = useRef(new THREE6.Vector3());
1763
+ useFrame((_, delta) => {
1764
+ const g = ref.current;
1765
+ if (!g) return;
1766
+ goal.current.set(target[0], target[1], target[2]);
1767
+ if (enabled) g.position.lerp(goal.current, Math.min(1, delta * 10));
1768
+ else g.position.copy(goal.current);
1769
+ });
1770
+ return /* @__PURE__ */ jsx("group", { ref, position: target, children });
1771
+ }
1772
+ var SIDE_PLATFORM_COLORS = {
1773
+ ground: "#5b8c3e",
1774
+ platform: "#8b5a2b",
1775
+ hazard: "#cc4444",
1776
+ goal: "#4488cc"
1777
+ };
1778
+ function SideScene({
1779
+ player,
1780
+ platforms,
1781
+ worldHeight,
1782
+ ppu,
1783
+ playerSprite,
1784
+ tileSprites,
1785
+ interpolate,
1786
+ features = [],
1787
+ events = [],
1788
+ assetManifest
1789
+ }) {
1790
+ const pw = player.width ?? 32;
1791
+ const ph = player.height ?? 48;
1792
+ const playerPos = [
1793
+ (player.x + pw / 2) / ppu,
1794
+ (worldHeight - player.y - ph) / ppu,
1795
+ 0
1796
+ ];
1797
+ const playerH = Math.max(ph / ppu, 0.5);
1798
+ return /* @__PURE__ */ jsxs("group", { children: [
1799
+ platforms.map((p, i) => {
1800
+ const platformType = p.type ?? "platform";
1801
+ const model = tileSprites?.[platformType]?.url;
1802
+ const topY = (worldHeight - p.y) / ppu;
1803
+ if (model) {
1804
+ const cells = Math.max(1, Math.round(p.width / ppu));
1805
+ const x0 = p.x / ppu;
1806
+ return /* @__PURE__ */ jsx("group", { children: Array.from({ length: cells }, (_, c) => /* @__PURE__ */ jsx("group", { position: [x0 + c + 0.5, topY - 0.475, 0], children: /* @__PURE__ */ jsx(ModelLoader, { url: model, scale: 0.95, fallbackGeometry: "box", castShadow: true, receiveShadow: true }) }, c)) }, `plat-${i}`);
1807
+ }
1808
+ return /* @__PURE__ */ jsxs(
1809
+ "mesh",
1810
+ {
1811
+ position: [(p.x + p.width / 2) / ppu, topY - p.height / 2 / ppu, 0],
1812
+ children: [
1813
+ /* @__PURE__ */ jsx("boxGeometry", { args: [p.width / ppu, p.height / ppu, 1] }),
1814
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: SIDE_PLATFORM_COLORS[platformType] ?? "#888888" })
1815
+ ]
1816
+ },
1817
+ `plat-${i}`
1818
+ );
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
+ )),
1835
+ /* @__PURE__ */ jsx(LerpedGroup, { target: playerPos, enabled: interpolate, children: playerSprite?.url ? /* @__PURE__ */ jsx("group", { position: [0, playerH / 2, 0], children: /* @__PURE__ */ jsx(
1836
+ ModelLoader,
1837
+ {
1838
+ url: playerSprite.url,
1839
+ scale: playerH,
1840
+ rotation: [0, player.facingRight ? 90 : -90, 0],
1841
+ animation: player.animation ?? "idle",
1842
+ fallbackGeometry: "box",
1843
+ castShadow: true
1844
+ }
1845
+ ) }) : /* @__PURE__ */ jsxs("mesh", { position: [0, playerH / 2, 0], children: [
1846
+ /* @__PURE__ */ jsx("capsuleGeometry", { args: [playerH * 0.25, playerH * 0.5, 4, 8] }),
1847
+ /* @__PURE__ */ jsx("meshStandardMaterial", { color: "#4488ff" })
1848
+ ] }) })
1849
+ ] });
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
+ }
1698
2090
  var GameCanvas3D = forwardRef(
1699
2091
  ({
1700
2092
  tiles = [],
@@ -1738,12 +2130,47 @@ var GameCanvas3D = forwardRef(
1738
2130
  selectedTileIds = [],
1739
2131
  selectedUnitId = null,
1740
2132
  unitScale = 1,
2133
+ keyMap,
2134
+ keyUpMap,
2135
+ player,
2136
+ platforms = [],
2137
+ worldHeight = 400,
2138
+ pixelsPerUnit = 32,
2139
+ playerSprite,
2140
+ tileSprites,
2141
+ assetManifest,
2142
+ interpolateUnits = false,
1741
2143
  children
1742
2144
  }, ref) => {
1743
2145
  const containerRef = useRef(null);
1744
2146
  const controlsRef = useRef(null);
1745
2147
  const [hoveredTile, setHoveredTile] = useState(null);
1746
2148
  const [internalError, setInternalError] = useState(null);
2149
+ const eventBus = useEventBus();
2150
+ const keysRef = useRef(/* @__PURE__ */ new Set());
2151
+ useEffect(() => {
2152
+ if (!keyMap && !keyUpMap) return;
2153
+ const down = (e) => {
2154
+ if (keysRef.current.has(e.code)) return;
2155
+ keysRef.current.add(e.code);
2156
+ const ev = keyMap?.[e.code];
2157
+ if (ev) {
2158
+ eventBus.emit(`UI:${ev}`, {});
2159
+ e.preventDefault();
2160
+ }
2161
+ };
2162
+ const up = (e) => {
2163
+ keysRef.current.delete(e.code);
2164
+ const ev = keyUpMap?.[e.code];
2165
+ if (ev) eventBus.emit(`UI:${ev}`, {});
2166
+ };
2167
+ window.addEventListener("keydown", down);
2168
+ window.addEventListener("keyup", up);
2169
+ return () => {
2170
+ window.removeEventListener("keydown", down);
2171
+ window.removeEventListener("keyup", up);
2172
+ };
2173
+ }, [keyMap, keyUpMap, eventBus]);
1747
2174
  useEffect(() => {
1748
2175
  const el = containerRef.current;
1749
2176
  if (!el) return;
@@ -1913,6 +2340,11 @@ var GameCanvas3D = forwardRef(
1913
2340
  position: [cx, d * 2, cz],
1914
2341
  fov: 45
1915
2342
  };
2343
+ case "follow":
2344
+ return {
2345
+ position: [cx, d * 0.5, cz + d],
2346
+ fov: 45
2347
+ };
1916
2348
  case "perspective":
1917
2349
  default:
1918
2350
  return {
@@ -1921,212 +2353,26 @@ var GameCanvas3D = forwardRef(
1921
2353
  };
1922
2354
  }
1923
2355
  }, [cameraMode, gridBounds]);
1924
- const DefaultTileRenderer = useCallback(
1925
- ({ tile, position }) => {
1926
- const isSelected = tile.id ? selectedTileIds.includes(tile.id) : false;
1927
- const isHovered = hoveredTile?.id === tile.id;
1928
- const isValidMove = validMoves.some(
1929
- (m) => m.x === tile.x && m.z === (tile.z ?? tile.y ?? 0)
1930
- );
1931
- const isAttackTarget = attackTargets.some(
1932
- (m) => m.x === tile.x && m.z === (tile.z ?? tile.y ?? 0)
1933
- );
1934
- let color = 8421504;
1935
- if (tile.type === "water") color = 4491468;
1936
- else if (tile.type === "grass") color = 4500036;
1937
- else if (tile.type === "sand") color = 14535816;
1938
- else if (tile.type === "rock") color = 8947848;
1939
- else if (tile.type === "snow") color = 15658734;
1940
- let emissive = 0;
1941
- if (isSelected) emissive = 4473924;
1942
- else if (isAttackTarget) emissive = 4456448;
1943
- else if (isValidMove) emissive = 17408;
1944
- else if (isHovered) emissive = 2236962;
1945
- if (tile.modelUrl) {
1946
- return /* @__PURE__ */ jsx(
1947
- "group",
1948
- {
1949
- position,
1950
- onClick: (e) => handleTileClick(tile, e),
1951
- onPointerEnter: (e) => handleTileHover(tile, e),
1952
- onPointerLeave: (e) => handleTileHover(null, e),
1953
- userData: { type: "tile", tileId: tile.id, gridX: tile.x, gridZ: tile.z ?? tile.y },
1954
- children: /* @__PURE__ */ jsx(
1955
- ModelLoader,
1956
- {
1957
- url: tile.modelUrl.url,
1958
- scale: 0.95,
1959
- fallbackGeometry: "box",
1960
- castShadow: true,
1961
- receiveShadow: true
1962
- }
1963
- )
1964
- }
1965
- );
1966
- }
1967
- return /* @__PURE__ */ jsxs(
1968
- "mesh",
1969
- {
1970
- position,
1971
- onClick: (e) => handleTileClick(tile, e),
1972
- onPointerEnter: (e) => handleTileHover(tile, e),
1973
- onPointerLeave: (e) => handleTileHover(null, e),
1974
- userData: { type: "tile", tileId: tile.id, gridX: tile.x, gridZ: tile.z ?? tile.y },
1975
- children: [
1976
- /* @__PURE__ */ jsx("boxGeometry", { args: [0.95, 0.2, 0.95] }),
1977
- /* @__PURE__ */ jsx("meshStandardMaterial", { color, emissive })
1978
- ]
1979
- }
1980
- );
1981
- },
1982
- [selectedTileIds, hoveredTile, validMoves, attackTargets, handleTileClick, handleTileHover]
1983
- );
1984
- const UNIT_BASE_MODEL_SCALE = 0.5;
1985
- const UNIT_BASE_BILLBOARD_HEIGHT = 1.2;
1986
- const UNIT_BASE_PRIMITIVE_RADIUS = 0.3;
1987
- const DefaultUnitRenderer = useCallback(
1988
- ({ unit, position }) => {
1989
- const isSelected = selectedUnitId === unit.id;
1990
- const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
1991
- const hasAtlas = unitAtlasUrl(unit) !== null;
1992
- const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
1993
- const modelScale = UNIT_BASE_MODEL_SCALE * unitScale * gridConfig.cellSize;
1994
- const billboardHeight = UNIT_BASE_BILLBOARD_HEIGHT * unitScale * gridConfig.cellSize;
1995
- const primitiveRadius = UNIT_BASE_PRIMITIVE_RADIUS * unitScale * gridConfig.cellSize;
1996
- return /* @__PURE__ */ jsxs(
1997
- "group",
1998
- {
1999
- position,
2000
- onClick: (e) => handleUnitClick(unit, e),
2001
- userData: { type: "unit", unitId: unit.id },
2002
- children: [
2003
- isSelected && /* @__PURE__ */ jsxs("mesh", { position: [0, 0.05, 0], rotation: [-Math.PI / 2, 0, 0], children: [
2004
- /* @__PURE__ */ jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
2005
- /* @__PURE__ */ jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
2006
- ] }),
2007
- hasAtlas && initialFrame ? (
2008
- /* Animated sprite-sheet billboard — single cropped frame, by state */
2009
- /* @__PURE__ */ jsx(Billboard, { children: /* @__PURE__ */ jsx(
2010
- UnitSpriteBillboard,
2011
- {
2012
- sheetUrl: initialFrame.sheetUrl,
2013
- resolveFrame: () => resolveUnitFrame(unit.id),
2014
- height: billboardHeight
2015
- }
2016
- ) })
2017
- ) : unit.modelUrl ? (
2018
- /* GLB unit model (box fallback while loading / on error) */
2019
- /* @__PURE__ */ jsx(
2020
- ModelLoader,
2021
- {
2022
- url: unit.modelUrl.url,
2023
- scale: modelScale,
2024
- fallbackGeometry: "box",
2025
- castShadow: true
2026
- }
2027
- )
2028
- ) : /* @__PURE__ */ jsxs(Fragment, { children: [
2029
- /* @__PURE__ */ jsxs("mesh", { position: [0, primitiveRadius, 0], children: [
2030
- /* @__PURE__ */ jsx("cylinderGeometry", { args: [primitiveRadius, primitiveRadius, primitiveRadius * 0.33, 8] }),
2031
- /* @__PURE__ */ jsx("meshStandardMaterial", { color })
2032
- ] }),
2033
- /* @__PURE__ */ jsxs("mesh", { position: [0, primitiveRadius * 2, 0], children: [
2034
- /* @__PURE__ */ jsx("capsuleGeometry", { args: [primitiveRadius * 0.67, primitiveRadius * 1.33, 4, 8] }),
2035
- /* @__PURE__ */ jsx("meshStandardMaterial", { color })
2036
- ] }),
2037
- /* @__PURE__ */ jsxs("mesh", { position: [0, primitiveRadius * 3, 0], children: [
2038
- /* @__PURE__ */ jsx("sphereGeometry", { args: [primitiveRadius * 0.4, 8, 8] }),
2039
- /* @__PURE__ */ jsx("meshStandardMaterial", { color })
2040
- ] })
2041
- ] }),
2042
- unit.health !== void 0 && unit.maxHealth !== void 0 && /* @__PURE__ */ jsxs("group", { position: [0, billboardHeight, 0], children: [
2043
- /* @__PURE__ */ jsxs("mesh", { position: [-0.25, 0, 0], children: [
2044
- /* @__PURE__ */ jsx("planeGeometry", { args: [0.5, 0.05] }),
2045
- /* @__PURE__ */ jsx("meshBasicMaterial", { color: 3355443 })
2046
- ] }),
2047
- /* @__PURE__ */ jsxs(
2048
- "mesh",
2049
- {
2050
- position: [
2051
- -0.25 + 0.5 * (unit.health / unit.maxHealth) / 2,
2052
- 0,
2053
- 0.01
2054
- ],
2055
- children: [
2056
- /* @__PURE__ */ jsx("planeGeometry", { args: [0.5 * (unit.health / unit.maxHealth), 0.05] }),
2057
- /* @__PURE__ */ jsx(
2058
- "meshBasicMaterial",
2059
- {
2060
- color: unit.health / unit.maxHealth > 0.5 ? 4500036 : unit.health / unit.maxHealth > 0.25 ? 11184708 : 16729156
2061
- }
2062
- )
2063
- ]
2064
- }
2065
- )
2066
- ] })
2067
- ]
2068
- }
2069
- );
2070
- },
2071
- [selectedUnitId, handleUnitClick, resolveUnitFrame, unitScale, gridConfig]
2072
- );
2073
- const DefaultFeatureRenderer = useCallback(
2074
- ({
2075
- feature,
2076
- position
2077
- }) => {
2078
- if (feature.assetUrl) {
2079
- return /* @__PURE__ */ jsx(
2080
- ModelLoader,
2081
- {
2082
- url: feature.assetUrl.url,
2083
- position,
2084
- scale: 0.5,
2085
- rotation: [0, feature.rotation ?? 0, 0],
2086
- onClick: () => handleFeatureClick(feature, null),
2087
- fallbackGeometry: "box"
2088
- },
2089
- feature.id
2090
- );
2091
- }
2092
- if (feature.type === "tree") {
2093
- return /* @__PURE__ */ jsxs(
2094
- "group",
2095
- {
2096
- position,
2097
- onClick: (e) => handleFeatureClick(feature, e),
2098
- userData: { type: "feature", featureId: feature.id },
2099
- children: [
2100
- /* @__PURE__ */ jsxs("mesh", { position: [0, 0.4, 0], children: [
2101
- /* @__PURE__ */ jsx("cylinderGeometry", { args: [0.1, 0.15, 0.8, 6] }),
2102
- /* @__PURE__ */ jsx("meshStandardMaterial", { color: 9127187 })
2103
- ] }),
2104
- /* @__PURE__ */ jsxs("mesh", { position: [0, 0.9, 0], children: [
2105
- /* @__PURE__ */ jsx("coneGeometry", { args: [0.5, 0.8, 8] }),
2106
- /* @__PURE__ */ jsx("meshStandardMaterial", { color: 2263842 })
2107
- ] })
2108
- ]
2109
- }
2110
- );
2111
- }
2112
- if (feature.type === "rock") {
2113
- return /* @__PURE__ */ jsxs(
2114
- "mesh",
2115
- {
2116
- position: [position[0], position[1] + 0.3, position[2]],
2117
- onClick: (e) => handleFeatureClick(feature, e),
2118
- userData: { type: "feature", featureId: feature.id },
2119
- children: [
2120
- /* @__PURE__ */ jsx("dodecahedronGeometry", { args: [0.3, 0] }),
2121
- /* @__PURE__ */ jsx("meshStandardMaterial", { color: 8421504 })
2122
- ]
2123
- }
2124
- );
2125
- }
2126
- return null;
2127
- },
2128
- [handleFeatureClick]
2129
- );
2356
+ const followTarget = useMemo(() => {
2357
+ if (player) {
2358
+ return [
2359
+ (player.x + (player.width ?? 32) / 2) / pixelsPerUnit,
2360
+ (worldHeight - player.y - (player.height ?? 48) / 2) / pixelsPerUnit,
2361
+ 0
2362
+ ];
2363
+ }
2364
+ const selected = units.find((u) => u.id === selectedUnitId);
2365
+ if (selected) {
2366
+ const sx = selected.x ?? selected.position?.x ?? 0;
2367
+ const sz = selected.z ?? selected.y ?? selected.position?.y ?? 0;
2368
+ return [
2369
+ (sx - gridBounds.minX) * gridConfig.cellSize,
2370
+ 0,
2371
+ (sz - gridBounds.minZ) * gridConfig.cellSize
2372
+ ];
2373
+ }
2374
+ return cameraTarget;
2375
+ }, [player, pixelsPerUnit, worldHeight, units, selectedUnitId, gridBounds, gridConfig, cameraTarget]);
2130
2376
  if (externalLoading || assetsLoading && preloadAssets.length > 0) {
2131
2377
  return /* @__PURE__ */ jsx(
2132
2378
  Canvas3DLoadingState,
@@ -2179,6 +2425,13 @@ var GameCanvas3D = forwardRef(
2179
2425
  },
2180
2426
  children: [
2181
2427
  /* @__PURE__ */ jsx(CameraController, { onCameraChange: eventHandlers.handleCameraChange }),
2428
+ cameraMode === "follow" && /* @__PURE__ */ jsx(
2429
+ FollowCamera,
2430
+ {
2431
+ target: followTarget,
2432
+ offset: player ? [0, 2, 9] : [5, 7, 5]
2433
+ }
2434
+ ),
2182
2435
  /* @__PURE__ */ jsx("ambientLight", { intensity: 0.6 }),
2183
2436
  /* @__PURE__ */ jsx(
2184
2437
  "directionalLight",
@@ -2186,7 +2439,9 @@ var GameCanvas3D = forwardRef(
2186
2439
  position: [10, 20, 10],
2187
2440
  intensity: 0.8,
2188
2441
  castShadow: shadows,
2189
- "shadow-mapSize": [2048, 2048]
2442
+ "shadow-mapSize": [2048, 2048],
2443
+ "shadow-bias": -4e-4,
2444
+ "shadow-normalBias": 0.04
2190
2445
  }
2191
2446
  ),
2192
2447
  /* @__PURE__ */ jsx("hemisphereLight", { intensity: 0.3, color: "#87ceeb", groundColor: "#362d1d" }),
@@ -2212,15 +2467,49 @@ var GameCanvas3D = forwardRef(
2212
2467
  fadeStrength: 1
2213
2468
  }
2214
2469
  ),
2215
- /* @__PURE__ */ jsxs("group", { children: [
2470
+ player ? (
2471
+ /* Side-scroller mode — LOLO-owned pixel space mapped to world units */
2472
+ /* @__PURE__ */ jsx(
2473
+ SideScene,
2474
+ {
2475
+ player,
2476
+ platforms,
2477
+ worldHeight,
2478
+ ppu: pixelsPerUnit,
2479
+ playerSprite,
2480
+ tileSprites,
2481
+ interpolate: interpolateUnits,
2482
+ features,
2483
+ events,
2484
+ assetManifest
2485
+ }
2486
+ )
2487
+ ) : /* @__PURE__ */ jsxs("group", { children: [
2216
2488
  tiles.map((tile, index) => {
2217
2489
  const position = gridToWorld2(
2218
2490
  tile.x,
2219
2491
  tile.z ?? tile.y ?? 0,
2220
2492
  tile.elevation ?? 0
2221
2493
  );
2222
- const Renderer = CustomTileRenderer || DefaultTileRenderer;
2223
- 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
+ );
2224
2513
  }),
2225
2514
  features.map((feature, index) => {
2226
2515
  const position = gridToWorld2(
@@ -2228,17 +2517,51 @@ var GameCanvas3D = forwardRef(
2228
2517
  feature.z ?? feature.y ?? 0,
2229
2518
  (feature.elevation ?? 0) + 0.5
2230
2519
  );
2231
- const Renderer = CustomFeatureRenderer || DefaultFeatureRenderer;
2232
- 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
+ );
2233
2534
  }),
2234
2535
  units.map((unit) => {
2235
2536
  const position = gridToWorld2(
2236
- unit.x ?? 0,
2237
- unit.z ?? unit.y ?? 0,
2537
+ unit.x ?? unit.position?.x ?? 0,
2538
+ unit.z ?? unit.y ?? unit.position?.y ?? 0,
2238
2539
  (unit.elevation ?? 0) + 0.5
2239
2540
  );
2240
- const Renderer = CustomUnitRenderer || DefaultUnitRenderer;
2241
- return /* @__PURE__ */ jsx(Renderer, { unit, position }, 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
+ );
2242
2565
  })
2243
2566
  ] }),
2244
2567
  children,
@@ -2246,6 +2569,7 @@ var GameCanvas3D = forwardRef(
2246
2569
  OrbitControls,
2247
2570
  {
2248
2571
  ref: controlsRef,
2572
+ enabled: cameraMode !== "follow",
2249
2573
  target: cameraTarget,
2250
2574
  enableDamping: true,
2251
2575
  dampingFactor: 0.05,
@@ -2278,9 +2602,6 @@ var GameCanvas3D = forwardRef(
2278
2602
  }
2279
2603
  );
2280
2604
  GameCanvas3D.displayName = "GameCanvas3D";
2281
- function Canvas3D(props) {
2282
- return /* @__PURE__ */ jsx(GameCanvas3D, { ...props });
2283
- }
2284
2605
  var DEFAULT_FAMILY = "lucide";
2285
2606
  var VALID_FAMILIES = [
2286
2607
  "lucide",
@@ -2353,7 +2674,7 @@ function loadLib(key, importer) {
2353
2674
  return p;
2354
2675
  }
2355
2676
  function lazyFamilyIcon(libKey, importer, pick, fallbackName, family) {
2356
- const Lazy = React6.lazy(async () => {
2677
+ const Lazy = React8__default.lazy(async () => {
2357
2678
  const lib = await loadLib(libKey, importer);
2358
2679
  const Comp = pick(lib);
2359
2680
  if (!Comp) {
@@ -2363,7 +2684,7 @@ function lazyFamilyIcon(libKey, importer, pick, fallbackName, family) {
2363
2684
  return { default: Comp };
2364
2685
  });
2365
2686
  const Wrapped = (props) => /* @__PURE__ */ jsx(
2366
- React6.Suspense,
2687
+ React8__default.Suspense,
2367
2688
  {
2368
2689
  fallback: /* @__PURE__ */ jsx(
2369
2690
  "span",
@@ -3032,7 +3353,7 @@ var Icon = ({
3032
3353
  const directIcon = typeof icon === "string" ? void 0 : icon;
3033
3354
  const effectiveName = typeof icon === "string" ? icon : name;
3034
3355
  const family = useIconFamily();
3035
- const RenderedComponent = React6.useMemo(() => {
3356
+ const RenderedComponent = React8__default.useMemo(() => {
3036
3357
  if (directIcon) return null;
3037
3358
  return effectiveName ? resolveIconForFamily(effectiveName, family) : null;
3038
3359
  }, [directIcon, effectiveName, family]);
@@ -3080,6 +3401,143 @@ var Icon = ({
3080
3401
  );
3081
3402
  };
3082
3403
  Icon.displayName = "Icon";
3404
+
3405
+ // lib/atlasSlice.ts
3406
+ var atlasCache = /* @__PURE__ */ new Map();
3407
+ function isTilesheet(a) {
3408
+ return typeof a.tileWidth === "number";
3409
+ }
3410
+ function getAtlas(url, onReady) {
3411
+ if (atlasCache.has(url)) return atlasCache.get(url) ?? void 0;
3412
+ atlasCache.set(url, void 0);
3413
+ fetch(url).then((r) => r.json()).then((json) => {
3414
+ atlasCache.set(url, json);
3415
+ onReady();
3416
+ }).catch(() => {
3417
+ atlasCache.set(url, null);
3418
+ });
3419
+ return void 0;
3420
+ }
3421
+ function subRectFor(atlas, sprite) {
3422
+ if (isTilesheet(atlas)) {
3423
+ let col;
3424
+ let row;
3425
+ if (sprite.includes(",")) {
3426
+ const [c, r] = sprite.split(",").map((n) => Number(n.trim()));
3427
+ col = c;
3428
+ row = r;
3429
+ } else {
3430
+ const i = Number(sprite);
3431
+ if (!Number.isFinite(i)) return null;
3432
+ col = i % atlas.columns;
3433
+ row = Math.floor(i / atlas.columns);
3434
+ }
3435
+ if (!Number.isFinite(col) || !Number.isFinite(row) || col < 0 || row < 0 || col >= atlas.columns || row >= atlas.rows) return null;
3436
+ const margin = atlas.margin ?? 0;
3437
+ const spacing = atlas.spacing ?? 0;
3438
+ return {
3439
+ sx: margin + col * (atlas.tileWidth + spacing),
3440
+ sy: margin + row * (atlas.tileHeight + spacing),
3441
+ sw: atlas.tileWidth,
3442
+ sh: atlas.tileHeight
3443
+ };
3444
+ }
3445
+ const st = atlas.subTextures[sprite];
3446
+ if (!st) return null;
3447
+ return { sx: st.x, sy: st.y, sw: st.width, sh: st.height };
3448
+ }
3449
+ function isAtlasAsset(asset) {
3450
+ return !!asset && typeof asset.atlas === "string" && typeof asset.sprite === "string";
3451
+ }
3452
+ var imageCache = /* @__PURE__ */ new Map();
3453
+ var imageWaiters = /* @__PURE__ */ new Map();
3454
+ function getSheetImage(url, onReady) {
3455
+ const cached = imageCache.get(url);
3456
+ if (cached) return cached;
3457
+ (imageWaiters.get(url) ?? imageWaiters.set(url, /* @__PURE__ */ new Set()).get(url)).add(onReady);
3458
+ if (!imageCache.has(url)) {
3459
+ imageCache.set(url, null);
3460
+ const img = new Image();
3461
+ img.crossOrigin = "anonymous";
3462
+ img.onload = () => {
3463
+ imageCache.set(url, img);
3464
+ imageWaiters.get(url)?.forEach((fn) => fn());
3465
+ imageWaiters.delete(url);
3466
+ };
3467
+ img.src = url;
3468
+ }
3469
+ return null;
3470
+ }
3471
+ function AtlasImage({
3472
+ asset,
3473
+ size,
3474
+ width,
3475
+ height,
3476
+ fill = false,
3477
+ fit = "contain",
3478
+ alt,
3479
+ className,
3480
+ style,
3481
+ "aria-hidden": ariaHidden
3482
+ }) {
3483
+ const [, bump] = React8.useReducer((x) => x + 1, 0);
3484
+ const canvasRef = React8.useRef(null);
3485
+ const sliced = isAtlasAsset(asset);
3486
+ const atlas = sliced ? getAtlas(asset.atlas, bump) : void 0;
3487
+ const img = sliced && asset?.url ? getSheetImage(asset.url, bump) : null;
3488
+ const rect = sliced && atlas ? subRectFor(atlas, asset.sprite) : null;
3489
+ React8.useEffect(() => {
3490
+ const canvas = canvasRef.current;
3491
+ if (!canvas || !img || !rect) return;
3492
+ canvas.width = rect.sw;
3493
+ canvas.height = rect.sh;
3494
+ const ctx = canvas.getContext("2d");
3495
+ if (!ctx) return;
3496
+ ctx.imageSmoothingEnabled = false;
3497
+ ctx.clearRect(0, 0, rect.sw, rect.sh);
3498
+ ctx.drawImage(img, rect.sx, rect.sy, rect.sw, rect.sh, 0, 0, rect.sw, rect.sh);
3499
+ }, [img, rect?.sx, rect?.sy, rect?.sw, rect?.sh]);
3500
+ const w = fill ? "100%" : width ?? size;
3501
+ const h = fill ? "100%" : height ?? size;
3502
+ const boxStyle = {
3503
+ ...fill ? { position: "absolute", inset: 0 } : {},
3504
+ width: w,
3505
+ height: h,
3506
+ objectFit: fit,
3507
+ imageRendering: "pixelated",
3508
+ ...style
3509
+ };
3510
+ if (!asset?.url) return null;
3511
+ if (sliced) {
3512
+ if (!rect || !img) {
3513
+ return /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: cn("inline-block flex-shrink-0", className), style: { ...boxStyle, objectFit: void 0 } });
3514
+ }
3515
+ return /* @__PURE__ */ jsx(
3516
+ "canvas",
3517
+ {
3518
+ ref: canvasRef,
3519
+ role: ariaHidden ? void 0 : "img",
3520
+ "aria-hidden": ariaHidden,
3521
+ "aria-label": ariaHidden ? void 0 : alt ?? asset.name ?? asset.category ?? "",
3522
+ className: cn("flex-shrink-0", className),
3523
+ style: boxStyle
3524
+ }
3525
+ );
3526
+ }
3527
+ return /* @__PURE__ */ jsx(
3528
+ "img",
3529
+ {
3530
+ src: asset.url,
3531
+ alt: alt ?? asset.name ?? asset.category ?? "",
3532
+ "aria-hidden": ariaHidden,
3533
+ ...typeof w === "number" ? { width: w } : {},
3534
+ ...typeof h === "number" ? { height: h } : {},
3535
+ className: cn("flex-shrink-0", className),
3536
+ style: boxStyle
3537
+ }
3538
+ );
3539
+ }
3540
+ AtlasImage.displayName = "AtlasImage";
3083
3541
  var variantStyles = {
3084
3542
  primary: [
3085
3543
  "bg-primary text-primary-foreground",
@@ -3152,7 +3610,7 @@ function resolveIconProp(value, sizeClass) {
3152
3610
  const IconComp = value;
3153
3611
  return /* @__PURE__ */ jsx(IconComp, { className: sizeClass });
3154
3612
  }
3155
- if (React6.isValidElement(value)) {
3613
+ if (React8__default.isValidElement(value)) {
3156
3614
  return value;
3157
3615
  }
3158
3616
  if (typeof value === "object" && value !== null && isIconLike(value)) {
@@ -3161,7 +3619,7 @@ function resolveIconProp(value, sizeClass) {
3161
3619
  }
3162
3620
  return value;
3163
3621
  }
3164
- var Button = React6.forwardRef(
3622
+ var Button = React8__default.forwardRef(
3165
3623
  ({
3166
3624
  className,
3167
3625
  variant = "primary",
@@ -3185,7 +3643,7 @@ var Button = React6.forwardRef(
3185
3643
  const leftIconValue = leftIcon || iconProp;
3186
3644
  const rightIconValue = rightIcon || iconRightProp;
3187
3645
  const px = size === "sm" ? 16 : size === "lg" ? 20 : 16;
3188
- const resolvedLeftIcon = iconAsset?.url ? /* @__PURE__ */ jsx("img", { src: iconAsset.url, alt: iconAsset.name ?? iconAsset.category ?? "", width: px, height: px, style: { imageRendering: "pixelated", objectFit: "contain", width: px, height: px }, className: "flex-shrink-0" }) : resolveIconProp(leftIconValue, iconSizeStyles[size]);
3646
+ const resolvedLeftIcon = iconAsset?.url ? /* @__PURE__ */ jsx(AtlasImage, { asset: iconAsset, size: px, alt: iconAsset.name ?? iconAsset.category ?? "", className: "flex-shrink-0" }) : resolveIconProp(leftIconValue, iconSizeStyles[size]);
3189
3647
  const resolvedRightIcon = resolveIconProp(rightIconValue, iconSizeStyles[size]);
3190
3648
  const handleClick = (e) => {
3191
3649
  if (action) {
@@ -3309,7 +3767,7 @@ var Typography = ({
3309
3767
  }) => {
3310
3768
  const variant = variantProp ?? (level ? `h${level}` : "body1");
3311
3769
  const Component2 = as || defaultElements[variant];
3312
- return React6.createElement(
3770
+ return React8__default.createElement(
3313
3771
  Component2,
3314
3772
  {
3315
3773
  id,
@@ -3382,7 +3840,7 @@ var Stack = ({
3382
3840
  };
3383
3841
  const isHorizontal = direction === "horizontal";
3384
3842
  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";
3385
- return React6.createElement(
3843
+ return React8__default.createElement(
3386
3844
  Component2,
3387
3845
  {
3388
3846
  className: cn(
@@ -3757,7 +4215,7 @@ var positionStyles = {
3757
4215
  fixed: "fixed",
3758
4216
  sticky: "sticky"
3759
4217
  };
3760
- var Box = React6.forwardRef(
4218
+ var Box = React8__default.forwardRef(
3761
4219
  ({
3762
4220
  padding,
3763
4221
  paddingX,
@@ -3822,7 +4280,7 @@ var Box = React6.forwardRef(
3822
4280
  onPointerDown?.(e);
3823
4281
  }, [hoverEvent, tapReveal, triggerProps, onPointerDown]);
3824
4282
  const isClickable = action || onClick;
3825
- return React6.createElement(
4283
+ return React8__default.createElement(
3826
4284
  Component2,
3827
4285
  {
3828
4286
  ref,
@@ -6610,7 +7068,7 @@ var Avl3DViewer = ({
6610
7068
  const handleTraitClick = useCallback((name) => {
6611
7069
  dispatch({ type: "ZOOM_INTO_TRAIT", trait: name, targetPosition: { x: 0, y: 0 } });
6612
7070
  }, []);
6613
- const [highlightedTrait, setHighlightedTrait] = React6.useState(null);
7071
+ const [highlightedTrait, setHighlightedTrait] = React8__default.useState(null);
6614
7072
  const handleTransitionClick = useCallback((index) => {
6615
7073
  dispatch({ type: "ZOOM_INTO_TRANSITION", transitionIndex: index, targetPosition: { x: 0, y: 0 } });
6616
7074
  }, []);
@@ -6697,7 +7155,7 @@ var Avl3DViewer = ({
6697
7155
  gap: "xs",
6698
7156
  align: "center",
6699
7157
  className: "absolute top-2 left-2 z-10 bg-surface/80 backdrop-blur rounded-md px-3 py-1.5",
6700
- children: breadcrumbs.map((crumb, i) => /* @__PURE__ */ jsxs(React6.Fragment, { children: [
7158
+ children: breadcrumbs.map((crumb, i) => /* @__PURE__ */ jsxs(React8__default.Fragment, { children: [
6701
7159
  i > 0 && /* @__PURE__ */ jsx(Typography, { variant: "small", color: "muted", className: "mx-1", children: "/" }),
6702
7160
  i < breadcrumbs.length - 1 ? /* @__PURE__ */ jsx(
6703
7161
  Box,
@@ -6973,4 +7431,4 @@ var SpatialHashGrid = class {
6973
7431
  }
6974
7432
  };
6975
7433
 
6976
- export { AVL_3D_COLORS, AssetLoader, Avl3DApplicationScene, Avl3DContext, Avl3DEffects, Avl3DOrbitalScene, Avl3DTraitScene, Avl3DTransitionScene, Avl3DViewer, CAMERA_POSITIONS, Camera3D, Canvas3D, Canvas3DErrorBoundary, Canvas3DLoadingState, GameBoard3D, GameCanvas3D, GameCanvas3DBattleTemplate, GameCanvas3DCastleTemplate, GameCanvas3DWorldMapTemplate, 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 };
7434
+ export { AVL_3D_COLORS, AssetLoader, Avl3DApplicationScene, Avl3DContext, Avl3DEffects, Avl3DOrbitalScene, Avl3DTraitScene, Avl3DTransitionScene, Avl3DViewer, CAMERA_POSITIONS, Camera3D, Canvas3DErrorBoundary, Canvas3DLoadingState, GameBoard3D, GameCanvas3D, GameCanvas3DBattleTemplate, GameCanvas3DCastleTemplate, GameCanvas3DWorldMapTemplate, 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 };