@almadar/ui 5.49.1 → 5.51.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.
@@ -41,12 +41,12 @@ import { DndContext, pointerWithin, rectIntersection, closestCorners, useSensors
41
41
  import { useSortable, arrayMove, sortableKeyboardCoordinates, SortableContext, rectSortingStrategy, verticalListSortingStrategy } from '@dnd-kit/sortable';
42
42
  import { CSS } from '@dnd-kit/utilities';
43
43
  import { useNodeId, ReactFlowProvider, Handle, Position } from '@xyflow/react';
44
- import * as THREE from 'three';
44
+ import * as THREE3 from 'three';
45
45
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
46
46
  import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
47
47
  import { GLTFLoader as GLTFLoader$1 } from 'three/examples/jsm/loaders/GLTFLoader';
48
- import { Canvas, useThree } from '@react-three/fiber';
49
- import { Grid as Grid$1, OrbitControls } from '@react-three/drei';
48
+ import { Canvas, useLoader, useFrame, useThree } from '@react-three/fiber';
49
+ import { Billboard, Grid as Grid$1, OrbitControls } from '@react-three/drei';
50
50
  import { getPatternDefinition, getComponentForPattern as getComponentForPattern$1 } from '@almadar/patterns';
51
51
 
52
52
  var __defProp = Object.defineProperty;
@@ -7011,6 +7011,52 @@ var init_ControlButton = __esm({
7011
7011
  ControlButton.displayName = "ControlButton";
7012
7012
  }
7013
7013
  });
7014
+
7015
+ // components/game/organisms/utils/spriteAnimation.ts
7016
+ function inferDirection(dx, dy) {
7017
+ if (dx === 0 && dy === 0) return "se";
7018
+ if (dx >= 0 && dy >= 0) return "se";
7019
+ if (dx <= 0 && dy >= 0) return "sw";
7020
+ if (dx >= 0 && dy <= 0) return "ne";
7021
+ return "nw";
7022
+ }
7023
+ function resolveSheetDirection(facing) {
7024
+ switch (facing) {
7025
+ case "se":
7026
+ return { sheetDir: "se", flipX: false };
7027
+ case "sw":
7028
+ return { sheetDir: "sw", flipX: false };
7029
+ case "ne":
7030
+ return { sheetDir: "sw", flipX: true };
7031
+ case "nw":
7032
+ return { sheetDir: "se", flipX: true };
7033
+ }
7034
+ }
7035
+ function frameRect(frame, row, columns, frameWidth, frameHeight) {
7036
+ return {
7037
+ sx: frame % columns * frameWidth,
7038
+ sy: row * frameHeight,
7039
+ sw: frameWidth,
7040
+ sh: frameHeight
7041
+ };
7042
+ }
7043
+ function getCurrentFrameFromDef(def, elapsed) {
7044
+ const frameDuration = 1e3 / def.frameRate;
7045
+ const totalDuration = def.frames * frameDuration;
7046
+ if (def.loop) {
7047
+ const frame2 = Math.floor(elapsed % totalDuration / frameDuration);
7048
+ return { frame: frame2, finished: false };
7049
+ }
7050
+ if (elapsed >= totalDuration) {
7051
+ return { frame: def.frames - 1, finished: true };
7052
+ }
7053
+ const frame = Math.floor(elapsed / frameDuration);
7054
+ return { frame, finished: false };
7055
+ }
7056
+ var init_spriteAnimation = __esm({
7057
+ "components/game/organisms/utils/spriteAnimation.ts"() {
7058
+ }
7059
+ });
7014
7060
  function Sprite({
7015
7061
  spritesheet = "https://almadar-kflow-assets.web.app/shared/isometric-blocks/Spritesheet/allTiles_sheet.png",
7016
7062
  frameWidth = 64,
@@ -7031,12 +7077,8 @@ function Sprite({
7031
7077
  }) {
7032
7078
  const eventBus = useEventBus();
7033
7079
  const sourcePosition = useMemo(() => {
7034
- const frameX = frame % columns;
7035
- const frameY = Math.floor(frame / columns);
7036
- return {
7037
- x: frameX * frameWidth,
7038
- y: frameY * frameHeight
7039
- };
7080
+ const { sx, sy } = frameRect(frame, Math.floor(frame / columns), columns, frameWidth, frameHeight);
7081
+ return { x: sx, y: sy };
7040
7082
  }, [frame, columns, frameWidth, frameHeight]);
7041
7083
  const transform = useMemo(() => {
7042
7084
  const transforms = [
@@ -7084,6 +7126,7 @@ var init_Sprite = __esm({
7084
7126
  "components/game/atoms/Sprite.tsx"() {
7085
7127
  "use client";
7086
7128
  init_useEventBus();
7129
+ init_spriteAnimation();
7087
7130
  }
7088
7131
  });
7089
7132
  function StateIndicator({
@@ -10819,6 +10862,163 @@ var init_useCamera = __esm({
10819
10862
  "use client";
10820
10863
  }
10821
10864
  });
10865
+ function unitAtlasUrl(unit) {
10866
+ if (unit.spriteSheet) return unit.spriteSheet;
10867
+ const sprite = unit.sprite;
10868
+ if (!sprite) return null;
10869
+ const match = /^(.*-sprite-sheet)(?:-(?:se|sw))?(?:-v\d+)?\.png$/.exec(sprite);
10870
+ if (!match) return null;
10871
+ return `${match[1]}.json`;
10872
+ }
10873
+ function resolveSheetUrl(atlasUrl, relativeSheetPath) {
10874
+ try {
10875
+ return new URL(relativeSheetPath, atlasUrl).toString();
10876
+ } catch {
10877
+ const base = atlasUrl.slice(0, atlasUrl.lastIndexOf("/") + 1);
10878
+ return `${base}${relativeSheetPath.replace(/^\.\//, "")}`;
10879
+ }
10880
+ }
10881
+ function useUnitSpriteAtlas(units) {
10882
+ const atlasCacheRef = useRef(/* @__PURE__ */ new Map());
10883
+ const loadingRef = useRef(/* @__PURE__ */ new Set());
10884
+ const [pendingCount, setPendingCount] = useState(0);
10885
+ const [, forceTick] = useState(0);
10886
+ const animStatesRef = useRef(/* @__PURE__ */ new Map());
10887
+ const lastTickRef = useRef(0);
10888
+ const rafRef = useRef(0);
10889
+ const atlasUrls = useMemo(() => {
10890
+ const set = /* @__PURE__ */ new Set();
10891
+ for (const unit of units) {
10892
+ const url = unitAtlasUrl(unit);
10893
+ if (url) set.add(url);
10894
+ }
10895
+ return [...set];
10896
+ }, [units]);
10897
+ useEffect(() => {
10898
+ const cache = atlasCacheRef.current;
10899
+ const loading = loadingRef.current;
10900
+ const toLoad = atlasUrls.filter((url) => !cache.has(url) && !loading.has(url));
10901
+ if (toLoad.length === 0) return;
10902
+ let cancelled = false;
10903
+ setPendingCount((prev) => prev + toLoad.length);
10904
+ for (const url of toLoad) {
10905
+ loading.add(url);
10906
+ fetch(url).then((res) => res.ok ? res.json() : Promise.reject(new Error(String(res.status)))).then((atlas) => {
10907
+ if (cancelled) return;
10908
+ cache.set(url, atlas);
10909
+ }).catch(() => {
10910
+ }).finally(() => {
10911
+ if (cancelled) return;
10912
+ loading.delete(url);
10913
+ setPendingCount((prev) => Math.max(0, prev - 1));
10914
+ forceTick((n) => n + 1);
10915
+ });
10916
+ }
10917
+ return () => {
10918
+ cancelled = true;
10919
+ };
10920
+ }, [atlasUrls]);
10921
+ const sheetUrls = useMemo(() => {
10922
+ const urls = /* @__PURE__ */ new Set();
10923
+ for (const unit of units) {
10924
+ const atlasUrl = unitAtlasUrl(unit);
10925
+ if (!atlasUrl) continue;
10926
+ const atlas = atlasCacheRef.current.get(atlasUrl);
10927
+ if (!atlas) continue;
10928
+ for (const rel of Object.values(atlas.sheets)) {
10929
+ if (rel) urls.add(resolveSheetUrl(atlasUrl, rel));
10930
+ }
10931
+ }
10932
+ return [...urls];
10933
+ }, [units, pendingCount]);
10934
+ useEffect(() => {
10935
+ const hasAtlasUnits = units.some((u) => unitAtlasUrl(u) !== null);
10936
+ if (!hasAtlasUnits) return;
10937
+ let running = true;
10938
+ const tick = (ts) => {
10939
+ if (!running) return;
10940
+ const last = lastTickRef.current || ts;
10941
+ const delta = ts - last;
10942
+ lastTickRef.current = ts;
10943
+ const states = animStatesRef.current;
10944
+ const currentIds = /* @__PURE__ */ new Set();
10945
+ for (const unit of units) {
10946
+ if (unitAtlasUrl(unit) === null) continue;
10947
+ currentIds.add(unit.id);
10948
+ let state = states.get(unit.id);
10949
+ if (!state) {
10950
+ state = { animation: "idle", direction: "se", elapsed: 0, walkHold: 0, prev: null };
10951
+ states.set(unit.id, state);
10952
+ }
10953
+ const posX = unit.position?.x ?? unit.x ?? 0;
10954
+ const posY = unit.position?.y ?? unit.y ?? 0;
10955
+ if (state.prev) {
10956
+ const dx = posX - state.prev.x;
10957
+ const dy = posY - state.prev.y;
10958
+ if (dx !== 0 || dy !== 0) {
10959
+ state.animation = "walk";
10960
+ state.direction = inferDirection(dx, dy);
10961
+ state.walkHold = WALK_HOLD_MS;
10962
+ } else if (state.animation === "walk") {
10963
+ state.walkHold -= delta;
10964
+ if (state.walkHold <= 0) state.animation = "idle";
10965
+ }
10966
+ }
10967
+ state.prev = { x: posX, y: posY };
10968
+ state.elapsed += delta;
10969
+ }
10970
+ for (const id of states.keys()) {
10971
+ if (!currentIds.has(id)) states.delete(id);
10972
+ }
10973
+ rafRef.current = requestAnimationFrame(tick);
10974
+ };
10975
+ rafRef.current = requestAnimationFrame(tick);
10976
+ return () => {
10977
+ running = false;
10978
+ cancelAnimationFrame(rafRef.current);
10979
+ lastTickRef.current = 0;
10980
+ };
10981
+ }, [units]);
10982
+ const resolveUnitFrame = useCallback((unitId) => {
10983
+ const unit = units.find((u) => u.id === unitId);
10984
+ if (!unit) return null;
10985
+ const atlasUrl = unitAtlasUrl(unit);
10986
+ if (!atlasUrl) return null;
10987
+ const atlas = atlasCacheRef.current.get(atlasUrl);
10988
+ if (!atlas) return null;
10989
+ const state = animStatesRef.current.get(unitId);
10990
+ const animation = state?.animation ?? "idle";
10991
+ const direction = state?.direction ?? "se";
10992
+ const elapsed = state?.elapsed ?? 0;
10993
+ const def = atlas.animations[animation] ?? atlas.animations.idle;
10994
+ if (!def) return null;
10995
+ const { sheetDir, flipX } = resolveSheetDirection(direction);
10996
+ const rel = atlas.sheets[sheetDir] ?? atlas.sheets.se ?? atlas.sheets.sw;
10997
+ if (!rel) return null;
10998
+ const sheetUrl = resolveSheetUrl(atlasUrl, rel);
10999
+ const isIdle = animation === "idle";
11000
+ const frame = isIdle ? 0 : getCurrentFrameFromDef(def, elapsed).frame;
11001
+ const rect = frameRect(frame, def.row, atlas.columns, atlas.frameWidth, atlas.frameHeight);
11002
+ return {
11003
+ sheetUrl,
11004
+ sx: rect.sx,
11005
+ sy: rect.sy,
11006
+ sw: rect.sw,
11007
+ sh: rect.sh,
11008
+ flipX,
11009
+ applyBreathing: isIdle
11010
+ };
11011
+ }, [units]);
11012
+ return { sheetUrls, resolveUnitFrame, pendingCount };
11013
+ }
11014
+ var WALK_HOLD_MS;
11015
+ var init_useUnitSpriteAtlas = __esm({
11016
+ "components/game/molecules/useUnitSpriteAtlas.ts"() {
11017
+ "use client";
11018
+ init_spriteAnimation();
11019
+ WALK_HOLD_MS = 600;
11020
+ }
11021
+ });
10822
11022
 
10823
11023
  // components/game/organisms/utils/isometric.ts
10824
11024
  function isoToScreen(tileX, tileY, scale, baseOffsetX) {
@@ -10933,6 +11133,10 @@ function IsometricCanvas({
10933
11133
  () => unitsProp.map((u) => u.position ? u : { ...u, position: { x: u.x ?? 0, y: u.y ?? 0 } }),
10934
11134
  [unitsProp]
10935
11135
  );
11136
+ const { sheetUrls: atlasSheetUrls, resolveUnitFrame: resolveUnitFrameInternal, pendingCount: atlasPending } = useUnitSpriteAtlas(units);
11137
+ const resolveFrameForUnit = useCallback((unitId) => {
11138
+ return resolveUnitFrame?.(unitId) ?? resolveUnitFrameInternal(unitId);
11139
+ }, [resolveUnitFrame, resolveUnitFrameInternal]);
10936
11140
  const features = useMemo(
10937
11141
  () => featuresProp.map((f3) => {
10938
11142
  if (f3.type) return f3;
@@ -11016,9 +11220,10 @@ function IsometricCanvas({
11016
11220
  }
11017
11221
  }
11018
11222
  if (effectSpriteUrls) urls.push(...effectSpriteUrls);
11223
+ if (atlasSheetUrls.length) urls.push(...atlasSheetUrls);
11019
11224
  if (backgroundImage) urls.push(backgroundImage);
11020
11225
  return [...new Set(urls.filter(Boolean))];
11021
- }, [sortedTiles, features, units, getTerrainSprite, getFeatureSprite, getUnitSprite, effectSpriteUrls, backgroundImage, assetManifest, resolveManifestUrl]);
11226
+ }, [sortedTiles, features, units, getTerrainSprite, getFeatureSprite, getUnitSprite, effectSpriteUrls, atlasSheetUrls, backgroundImage, assetManifest, resolveManifestUrl]);
11022
11227
  const { getImage, pendingCount } = useImageCache(spriteUrls);
11023
11228
  useEffect(() => {
11024
11229
  if (typeof window === "undefined") return;
@@ -11305,7 +11510,7 @@ function IsometricCanvas({
11305
11510
  ctx.lineWidth = 3;
11306
11511
  ctx.stroke();
11307
11512
  }
11308
- const frame = resolveUnitFrame?.(unit.id) ?? null;
11513
+ const frame = resolveFrameForUnit(unit.id);
11309
11514
  const frameImg = frame ? getImage(frame.sheetUrl) : null;
11310
11515
  if (frame && frameImg) {
11311
11516
  const frameAr = frame.sw / frame.sh;
@@ -11415,7 +11620,7 @@ function IsometricCanvas({
11415
11620
  resolveTerrainSpriteUrl,
11416
11621
  resolveFeatureSpriteUrl,
11417
11622
  resolveUnitSpriteUrl,
11418
- resolveUnitFrame,
11623
+ resolveFrameForUnit,
11419
11624
  getImage,
11420
11625
  gridWidth,
11421
11626
  gridHeight,
@@ -11447,7 +11652,7 @@ function IsometricCanvas({
11447
11652
  };
11448
11653
  }, [selectedUnitId, units, scale, baseOffsetX, scaledTileWidth, scaledDiamondTopY, scaledFloorHeight, viewportSize, targetCameraRef]);
11449
11654
  useEffect(() => {
11450
- const hasAnimations = units.length > 0 || validMoves.length > 0 || attackTargets.length > 0 || selectedUnitId != null || targetCameraRef.current != null || hasActiveEffects2 || pendingCount > 0;
11655
+ const hasAnimations = units.length > 0 || validMoves.length > 0 || attackTargets.length > 0 || selectedUnitId != null || targetCameraRef.current != null || hasActiveEffects2 || pendingCount > 0 || atlasPending > 0;
11451
11656
  draw(animTimeRef.current);
11452
11657
  if (!hasAnimations) return;
11453
11658
  let running = true;
@@ -11463,7 +11668,7 @@ function IsometricCanvas({
11463
11668
  running = false;
11464
11669
  cancelAnimationFrame(rafIdRef.current);
11465
11670
  };
11466
- }, [draw, units.length, validMoves.length, attackTargets.length, selectedUnitId, hasActiveEffects2, pendingCount, lerpToTarget, targetCameraRef]);
11671
+ }, [draw, units.length, validMoves.length, attackTargets.length, selectedUnitId, hasActiveEffects2, pendingCount, atlasPending, lerpToTarget, targetCameraRef]);
11467
11672
  const handleMouseMoveWithCamera = useCallback((e) => {
11468
11673
  if (enableCamera) {
11469
11674
  const wasPanning = handleMouseMove(e, () => draw(animTimeRef.current));
@@ -11608,6 +11813,7 @@ var init_IsometricCanvas = __esm({
11608
11813
  init_ErrorState();
11609
11814
  init_useImageCache();
11610
11815
  init_useCamera();
11816
+ init_useUnitSpriteAtlas();
11611
11817
  init_verificationRegistry();
11612
11818
  init_isometric();
11613
11819
  IsometricCanvas.displayName = "IsometricCanvas";
@@ -29190,13 +29396,13 @@ var init_MapView = __esm({
29190
29396
  shadowSize: [41, 41]
29191
29397
  });
29192
29398
  L.Marker.prototype.options.icon = defaultIcon;
29193
- const { useEffect: useEffect78, useRef: useRef74, useCallback: useCallback117, useState: useState108 } = React82__default;
29399
+ const { useEffect: useEffect79, useRef: useRef75, useCallback: useCallback118, useState: useState109 } = React82__default;
29194
29400
  const { Typography: Typography2 } = await Promise.resolve().then(() => (init_Typography(), Typography_exports));
29195
29401
  const { useEventBus: useEventBus3 } = await Promise.resolve().then(() => (init_useEventBus(), useEventBus_exports));
29196
29402
  function MapUpdater({ centerLat, centerLng, zoom }) {
29197
29403
  const map = useMap();
29198
- const prevRef = useRef74({ centerLat, centerLng, zoom });
29199
- useEffect78(() => {
29404
+ const prevRef = useRef75({ centerLat, centerLng, zoom });
29405
+ useEffect79(() => {
29200
29406
  const prev = prevRef.current;
29201
29407
  if (prev.centerLat !== centerLat || prev.centerLng !== centerLng || prev.zoom !== zoom) {
29202
29408
  map.setView([centerLat, centerLng], zoom);
@@ -29207,7 +29413,7 @@ var init_MapView = __esm({
29207
29413
  }
29208
29414
  function MapClickHandler({ onMapClick }) {
29209
29415
  const map = useMap();
29210
- useEffect78(() => {
29416
+ useEffect79(() => {
29211
29417
  if (!onMapClick) return;
29212
29418
  const handler = (e) => {
29213
29419
  onMapClick(e.latlng.lat, e.latlng.lng);
@@ -29235,8 +29441,8 @@ var init_MapView = __esm({
29235
29441
  showAttribution = true
29236
29442
  }) {
29237
29443
  const eventBus = useEventBus3();
29238
- const [clickedPosition, setClickedPosition] = useState108(null);
29239
- const handleMapClick = useCallback117((lat, lng) => {
29444
+ const [clickedPosition, setClickedPosition] = useState109(null);
29445
+ const handleMapClick = useCallback118((lat, lng) => {
29240
29446
  if (showClickedPin) {
29241
29447
  setClickedPosition({ lat, lng });
29242
29448
  }
@@ -29245,7 +29451,7 @@ var init_MapView = __esm({
29245
29451
  eventBus.emit(`UI:${mapClickEvent}`, { latitude: lat, longitude: lng });
29246
29452
  }
29247
29453
  }, [onMapClick, mapClickEvent, eventBus, showClickedPin]);
29248
- const handleMarkerClick = useCallback117((marker) => {
29454
+ const handleMarkerClick = useCallback118((marker) => {
29249
29455
  onMarkerClick?.(marker);
29250
29456
  if (markerClickEvent) {
29251
29457
  eventBus.emit(`UI:${markerClickEvent}`, { ...marker });
@@ -40963,7 +41169,7 @@ var init_AssetLoader = __esm({
40963
41169
  __publicField(this, "textureCache");
40964
41170
  __publicField(this, "loadingPromises");
40965
41171
  this.objLoader = new OBJLoader();
40966
- this.textureLoader = new THREE.TextureLoader();
41172
+ this.textureLoader = new THREE3.TextureLoader();
40967
41173
  this.modelCache = /* @__PURE__ */ new Map();
40968
41174
  this.textureCache = /* @__PURE__ */ new Map();
40969
41175
  this.loadingPromises = /* @__PURE__ */ new Map();
@@ -41037,7 +41243,7 @@ var init_AssetLoader = __esm({
41037
41243
  return this.loadingPromises.get(`texture:${url}`);
41038
41244
  }
41039
41245
  const loadPromise = this.textureLoader.loadAsync(url).then((texture) => {
41040
- texture.colorSpace = THREE.SRGBColorSpace;
41246
+ texture.colorSpace = THREE3.SRGBColorSpace;
41041
41247
  this.textureCache.set(url, texture);
41042
41248
  this.loadingPromises.delete(`texture:${url}`);
41043
41249
  return texture;
@@ -41111,7 +41317,7 @@ var init_AssetLoader = __esm({
41111
41317
  });
41112
41318
  this.modelCache.forEach((model) => {
41113
41319
  model.scene.traverse((child) => {
41114
- if (child instanceof THREE.Mesh) {
41320
+ if (child instanceof THREE3.Mesh) {
41115
41321
  child.geometry.dispose();
41116
41322
  if (Array.isArray(child.material)) {
41117
41323
  child.material.forEach((m) => m.dispose());
@@ -41667,7 +41873,7 @@ function ModelLoader({
41667
41873
  if (!loadedModel) return null;
41668
41874
  const cloned = loadedModel.clone();
41669
41875
  cloned.traverse((child) => {
41670
- if (child instanceof THREE.Mesh) {
41876
+ if (child instanceof THREE3.Mesh) {
41671
41877
  child.castShadow = castShadow;
41672
41878
  child.receiveShadow = receiveShadow;
41673
41879
  }
@@ -41781,6 +41987,47 @@ function CameraController({
41781
41987
  }, [camera.position, onCameraChange]);
41782
41988
  return null;
41783
41989
  }
41990
+ function UnitSpriteBillboard({
41991
+ sheetUrl,
41992
+ resolveFrame,
41993
+ height = 1.2
41994
+ }) {
41995
+ const texture = useLoader(THREE3.TextureLoader, sheetUrl);
41996
+ const meshRef = useRef(null);
41997
+ const matRef = useRef(null);
41998
+ const [aspect, setAspect] = useState(1);
41999
+ useFrame(() => {
42000
+ const frame = resolveFrame();
42001
+ if (!frame || !texture.image) return;
42002
+ const imgW = texture.image.width;
42003
+ const imgH = texture.image.height;
42004
+ if (!imgW || !imgH) return;
42005
+ texture.repeat.set((frame.flipX ? -1 : 1) * (frame.sw / imgW), frame.sh / imgH);
42006
+ texture.offset.set(
42007
+ frame.flipX ? (frame.sx + frame.sw) / imgW : frame.sx / imgW,
42008
+ 1 - (frame.sy + frame.sh) / imgH
42009
+ );
42010
+ texture.magFilter = THREE3.NearestFilter;
42011
+ texture.minFilter = THREE3.NearestFilter;
42012
+ texture.needsUpdate = true;
42013
+ const nextAspect = frame.sw / frame.sh;
42014
+ if (Math.abs(nextAspect - aspect) > 1e-3) setAspect(nextAspect);
42015
+ if (matRef.current) matRef.current.needsUpdate = true;
42016
+ });
42017
+ return /* @__PURE__ */ jsxs("mesh", { ref: meshRef, position: [0, height / 2, 0], children: [
42018
+ /* @__PURE__ */ jsx("planeGeometry", { args: [height * aspect, height] }),
42019
+ /* @__PURE__ */ jsx(
42020
+ "meshBasicMaterial",
42021
+ {
42022
+ ref: matRef,
42023
+ map: texture,
42024
+ transparent: true,
42025
+ alphaTest: 0.1,
42026
+ side: THREE3.DoubleSide
42027
+ }
42028
+ )
42029
+ ] });
42030
+ }
41784
42031
  var DEFAULT_GRID_CONFIG, GameCanvas3D;
41785
42032
  var init_GameCanvas3D2 = __esm({
41786
42033
  "components/game/molecules/GameCanvas3D.tsx"() {
@@ -41791,6 +42038,7 @@ var init_GameCanvas3D2 = __esm({
41791
42038
  init_Canvas3DLoadingState2();
41792
42039
  init_Canvas3DErrorBoundary2();
41793
42040
  init_ModelLoader();
42041
+ init_useUnitSpriteAtlas();
41794
42042
  init_cn();
41795
42043
  init_GameCanvas3D();
41796
42044
  DEFAULT_GRID_CONFIG = {
@@ -41847,8 +42095,10 @@ var init_GameCanvas3D2 = __esm({
41847
42095
  const controlsRef = useRef(null);
41848
42096
  const [hoveredTile, setHoveredTile] = useState(null);
41849
42097
  const [internalError, setInternalError] = useState(null);
42098
+ const { sheetUrls: atlasSheetUrls, resolveUnitFrame } = useUnitSpriteAtlas(units);
42099
+ const preloadUrls = useMemo(() => [...preloadAssets, ...atlasSheetUrls], [preloadAssets, atlasSheetUrls]);
41850
42100
  const { isLoading: assetsLoading, progress, loaded, total } = useAssetLoader({
41851
- preloadUrls: preloadAssets,
42101
+ preloadUrls,
41852
42102
  loader: customAssetLoader
41853
42103
  });
41854
42104
  const eventHandlers = useGameCanvas3DEvents({
@@ -41907,7 +42157,7 @@ var init_GameCanvas3D2 = __esm({
41907
42157
  getCameraPosition: () => {
41908
42158
  if (controlsRef.current) {
41909
42159
  const pos = controlsRef.current.object.position;
41910
- return new THREE.Vector3(pos.x, pos.y, pos.z);
42160
+ return new THREE3.Vector3(pos.x, pos.y, pos.z);
41911
42161
  }
41912
42162
  return null;
41913
42163
  },
@@ -42059,6 +42309,8 @@ var init_GameCanvas3D2 = __esm({
42059
42309
  ({ unit, position }) => {
42060
42310
  const isSelected = selectedUnitId === unit.id;
42061
42311
  const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
42312
+ const hasAtlas = unitAtlasUrl(unit) !== null;
42313
+ const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
42062
42314
  return /* @__PURE__ */ jsxs(
42063
42315
  "group",
42064
42316
  {
@@ -42070,7 +42322,16 @@ var init_GameCanvas3D2 = __esm({
42070
42322
  /* @__PURE__ */ jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
42071
42323
  /* @__PURE__ */ jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
42072
42324
  ] }),
42073
- unit.modelUrl ? (
42325
+ hasAtlas && initialFrame ? (
42326
+ /* Animated sprite-sheet billboard — single cropped frame, by state */
42327
+ /* @__PURE__ */ jsx(Billboard, { children: /* @__PURE__ */ jsx(
42328
+ UnitSpriteBillboard,
42329
+ {
42330
+ sheetUrl: initialFrame.sheetUrl,
42331
+ resolveFrame: () => resolveUnitFrame(unit.id)
42332
+ }
42333
+ ) })
42334
+ ) : unit.modelUrl ? (
42074
42335
  /* GLB unit model (box fallback while loading / on error) */
42075
42336
  /* @__PURE__ */ jsx(
42076
42337
  ModelLoader,
@@ -42124,7 +42385,7 @@ var init_GameCanvas3D2 = __esm({
42124
42385
  }
42125
42386
  );
42126
42387
  },
42127
- [selectedUnitId, handleUnitClick]
42388
+ [selectedUnitId, handleUnitClick, resolveUnitFrame]
42128
42389
  );
42129
42390
  const DefaultFeatureRenderer = useCallback(
42130
42391
  ({