@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,7 +41,7 @@ var core$1 = require('@dnd-kit/core');
41
41
  var sortable = require('@dnd-kit/sortable');
42
42
  var utilities = require('@dnd-kit/utilities');
43
43
  var react = require('@xyflow/react');
44
- var THREE = require('three');
44
+ var THREE3 = require('three');
45
45
  var GLTFLoader_js = require('three/examples/jsm/loaders/GLTFLoader.js');
46
46
  var OBJLoader_js = require('three/examples/jsm/loaders/OBJLoader.js');
47
47
  var GLTFLoader = require('three/examples/jsm/loaders/GLTFLoader');
@@ -94,7 +94,7 @@ var ReactMarkdown__default = /*#__PURE__*/_interopDefault(ReactMarkdown);
94
94
  var remarkGfm__default = /*#__PURE__*/_interopDefault(remarkGfm);
95
95
  var remarkMath__default = /*#__PURE__*/_interopDefault(remarkMath);
96
96
  var rehypeKatex__default = /*#__PURE__*/_interopDefault(rehypeKatex);
97
- var THREE__namespace = /*#__PURE__*/_interopNamespace(THREE);
97
+ var THREE3__namespace = /*#__PURE__*/_interopNamespace(THREE3);
98
98
 
99
99
  var __defProp = Object.defineProperty;
100
100
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -7058,6 +7058,52 @@ var init_ControlButton = __esm({
7058
7058
  ControlButton.displayName = "ControlButton";
7059
7059
  }
7060
7060
  });
7061
+
7062
+ // components/game/organisms/utils/spriteAnimation.ts
7063
+ function inferDirection(dx, dy) {
7064
+ if (dx === 0 && dy === 0) return "se";
7065
+ if (dx >= 0 && dy >= 0) return "se";
7066
+ if (dx <= 0 && dy >= 0) return "sw";
7067
+ if (dx >= 0 && dy <= 0) return "ne";
7068
+ return "nw";
7069
+ }
7070
+ function resolveSheetDirection(facing) {
7071
+ switch (facing) {
7072
+ case "se":
7073
+ return { sheetDir: "se", flipX: false };
7074
+ case "sw":
7075
+ return { sheetDir: "sw", flipX: false };
7076
+ case "ne":
7077
+ return { sheetDir: "sw", flipX: true };
7078
+ case "nw":
7079
+ return { sheetDir: "se", flipX: true };
7080
+ }
7081
+ }
7082
+ function frameRect(frame, row, columns, frameWidth, frameHeight) {
7083
+ return {
7084
+ sx: frame % columns * frameWidth,
7085
+ sy: row * frameHeight,
7086
+ sw: frameWidth,
7087
+ sh: frameHeight
7088
+ };
7089
+ }
7090
+ function getCurrentFrameFromDef(def, elapsed) {
7091
+ const frameDuration = 1e3 / def.frameRate;
7092
+ const totalDuration = def.frames * frameDuration;
7093
+ if (def.loop) {
7094
+ const frame2 = Math.floor(elapsed % totalDuration / frameDuration);
7095
+ return { frame: frame2, finished: false };
7096
+ }
7097
+ if (elapsed >= totalDuration) {
7098
+ return { frame: def.frames - 1, finished: true };
7099
+ }
7100
+ const frame = Math.floor(elapsed / frameDuration);
7101
+ return { frame, finished: false };
7102
+ }
7103
+ var init_spriteAnimation = __esm({
7104
+ "components/game/organisms/utils/spriteAnimation.ts"() {
7105
+ }
7106
+ });
7061
7107
  function Sprite({
7062
7108
  spritesheet = "https://almadar-kflow-assets.web.app/shared/isometric-blocks/Spritesheet/allTiles_sheet.png",
7063
7109
  frameWidth = 64,
@@ -7078,12 +7124,8 @@ function Sprite({
7078
7124
  }) {
7079
7125
  const eventBus = useEventBus();
7080
7126
  const sourcePosition = React82.useMemo(() => {
7081
- const frameX = frame % columns;
7082
- const frameY = Math.floor(frame / columns);
7083
- return {
7084
- x: frameX * frameWidth,
7085
- y: frameY * frameHeight
7086
- };
7127
+ const { sx, sy } = frameRect(frame, Math.floor(frame / columns), columns, frameWidth, frameHeight);
7128
+ return { x: sx, y: sy };
7087
7129
  }, [frame, columns, frameWidth, frameHeight]);
7088
7130
  const transform = React82.useMemo(() => {
7089
7131
  const transforms = [
@@ -7131,6 +7173,7 @@ var init_Sprite = __esm({
7131
7173
  "components/game/atoms/Sprite.tsx"() {
7132
7174
  "use client";
7133
7175
  init_useEventBus();
7176
+ init_spriteAnimation();
7134
7177
  }
7135
7178
  });
7136
7179
  function StateIndicator({
@@ -10866,6 +10909,163 @@ var init_useCamera = __esm({
10866
10909
  "use client";
10867
10910
  }
10868
10911
  });
10912
+ function unitAtlasUrl(unit) {
10913
+ if (unit.spriteSheet) return unit.spriteSheet;
10914
+ const sprite = unit.sprite;
10915
+ if (!sprite) return null;
10916
+ const match = /^(.*-sprite-sheet)(?:-(?:se|sw))?(?:-v\d+)?\.png$/.exec(sprite);
10917
+ if (!match) return null;
10918
+ return `${match[1]}.json`;
10919
+ }
10920
+ function resolveSheetUrl(atlasUrl, relativeSheetPath) {
10921
+ try {
10922
+ return new URL(relativeSheetPath, atlasUrl).toString();
10923
+ } catch {
10924
+ const base = atlasUrl.slice(0, atlasUrl.lastIndexOf("/") + 1);
10925
+ return `${base}${relativeSheetPath.replace(/^\.\//, "")}`;
10926
+ }
10927
+ }
10928
+ function useUnitSpriteAtlas(units) {
10929
+ const atlasCacheRef = React82.useRef(/* @__PURE__ */ new Map());
10930
+ const loadingRef = React82.useRef(/* @__PURE__ */ new Set());
10931
+ const [pendingCount, setPendingCount] = React82.useState(0);
10932
+ const [, forceTick] = React82.useState(0);
10933
+ const animStatesRef = React82.useRef(/* @__PURE__ */ new Map());
10934
+ const lastTickRef = React82.useRef(0);
10935
+ const rafRef = React82.useRef(0);
10936
+ const atlasUrls = React82.useMemo(() => {
10937
+ const set = /* @__PURE__ */ new Set();
10938
+ for (const unit of units) {
10939
+ const url = unitAtlasUrl(unit);
10940
+ if (url) set.add(url);
10941
+ }
10942
+ return [...set];
10943
+ }, [units]);
10944
+ React82.useEffect(() => {
10945
+ const cache = atlasCacheRef.current;
10946
+ const loading = loadingRef.current;
10947
+ const toLoad = atlasUrls.filter((url) => !cache.has(url) && !loading.has(url));
10948
+ if (toLoad.length === 0) return;
10949
+ let cancelled = false;
10950
+ setPendingCount((prev) => prev + toLoad.length);
10951
+ for (const url of toLoad) {
10952
+ loading.add(url);
10953
+ fetch(url).then((res) => res.ok ? res.json() : Promise.reject(new Error(String(res.status)))).then((atlas) => {
10954
+ if (cancelled) return;
10955
+ cache.set(url, atlas);
10956
+ }).catch(() => {
10957
+ }).finally(() => {
10958
+ if (cancelled) return;
10959
+ loading.delete(url);
10960
+ setPendingCount((prev) => Math.max(0, prev - 1));
10961
+ forceTick((n) => n + 1);
10962
+ });
10963
+ }
10964
+ return () => {
10965
+ cancelled = true;
10966
+ };
10967
+ }, [atlasUrls]);
10968
+ const sheetUrls = React82.useMemo(() => {
10969
+ const urls = /* @__PURE__ */ new Set();
10970
+ for (const unit of units) {
10971
+ const atlasUrl = unitAtlasUrl(unit);
10972
+ if (!atlasUrl) continue;
10973
+ const atlas = atlasCacheRef.current.get(atlasUrl);
10974
+ if (!atlas) continue;
10975
+ for (const rel of Object.values(atlas.sheets)) {
10976
+ if (rel) urls.add(resolveSheetUrl(atlasUrl, rel));
10977
+ }
10978
+ }
10979
+ return [...urls];
10980
+ }, [units, pendingCount]);
10981
+ React82.useEffect(() => {
10982
+ const hasAtlasUnits = units.some((u) => unitAtlasUrl(u) !== null);
10983
+ if (!hasAtlasUnits) return;
10984
+ let running = true;
10985
+ const tick = (ts) => {
10986
+ if (!running) return;
10987
+ const last = lastTickRef.current || ts;
10988
+ const delta = ts - last;
10989
+ lastTickRef.current = ts;
10990
+ const states = animStatesRef.current;
10991
+ const currentIds = /* @__PURE__ */ new Set();
10992
+ for (const unit of units) {
10993
+ if (unitAtlasUrl(unit) === null) continue;
10994
+ currentIds.add(unit.id);
10995
+ let state = states.get(unit.id);
10996
+ if (!state) {
10997
+ state = { animation: "idle", direction: "se", elapsed: 0, walkHold: 0, prev: null };
10998
+ states.set(unit.id, state);
10999
+ }
11000
+ const posX = unit.position?.x ?? unit.x ?? 0;
11001
+ const posY = unit.position?.y ?? unit.y ?? 0;
11002
+ if (state.prev) {
11003
+ const dx = posX - state.prev.x;
11004
+ const dy = posY - state.prev.y;
11005
+ if (dx !== 0 || dy !== 0) {
11006
+ state.animation = "walk";
11007
+ state.direction = inferDirection(dx, dy);
11008
+ state.walkHold = WALK_HOLD_MS;
11009
+ } else if (state.animation === "walk") {
11010
+ state.walkHold -= delta;
11011
+ if (state.walkHold <= 0) state.animation = "idle";
11012
+ }
11013
+ }
11014
+ state.prev = { x: posX, y: posY };
11015
+ state.elapsed += delta;
11016
+ }
11017
+ for (const id of states.keys()) {
11018
+ if (!currentIds.has(id)) states.delete(id);
11019
+ }
11020
+ rafRef.current = requestAnimationFrame(tick);
11021
+ };
11022
+ rafRef.current = requestAnimationFrame(tick);
11023
+ return () => {
11024
+ running = false;
11025
+ cancelAnimationFrame(rafRef.current);
11026
+ lastTickRef.current = 0;
11027
+ };
11028
+ }, [units]);
11029
+ const resolveUnitFrame = React82.useCallback((unitId) => {
11030
+ const unit = units.find((u) => u.id === unitId);
11031
+ if (!unit) return null;
11032
+ const atlasUrl = unitAtlasUrl(unit);
11033
+ if (!atlasUrl) return null;
11034
+ const atlas = atlasCacheRef.current.get(atlasUrl);
11035
+ if (!atlas) return null;
11036
+ const state = animStatesRef.current.get(unitId);
11037
+ const animation = state?.animation ?? "idle";
11038
+ const direction = state?.direction ?? "se";
11039
+ const elapsed = state?.elapsed ?? 0;
11040
+ const def = atlas.animations[animation] ?? atlas.animations.idle;
11041
+ if (!def) return null;
11042
+ const { sheetDir, flipX } = resolveSheetDirection(direction);
11043
+ const rel = atlas.sheets[sheetDir] ?? atlas.sheets.se ?? atlas.sheets.sw;
11044
+ if (!rel) return null;
11045
+ const sheetUrl = resolveSheetUrl(atlasUrl, rel);
11046
+ const isIdle = animation === "idle";
11047
+ const frame = isIdle ? 0 : getCurrentFrameFromDef(def, elapsed).frame;
11048
+ const rect = frameRect(frame, def.row, atlas.columns, atlas.frameWidth, atlas.frameHeight);
11049
+ return {
11050
+ sheetUrl,
11051
+ sx: rect.sx,
11052
+ sy: rect.sy,
11053
+ sw: rect.sw,
11054
+ sh: rect.sh,
11055
+ flipX,
11056
+ applyBreathing: isIdle
11057
+ };
11058
+ }, [units]);
11059
+ return { sheetUrls, resolveUnitFrame, pendingCount };
11060
+ }
11061
+ var WALK_HOLD_MS;
11062
+ var init_useUnitSpriteAtlas = __esm({
11063
+ "components/game/molecules/useUnitSpriteAtlas.ts"() {
11064
+ "use client";
11065
+ init_spriteAnimation();
11066
+ WALK_HOLD_MS = 600;
11067
+ }
11068
+ });
10869
11069
 
10870
11070
  // components/game/organisms/utils/isometric.ts
10871
11071
  function isoToScreen(tileX, tileY, scale, baseOffsetX) {
@@ -10980,6 +11180,10 @@ function IsometricCanvas({
10980
11180
  () => unitsProp.map((u) => u.position ? u : { ...u, position: { x: u.x ?? 0, y: u.y ?? 0 } }),
10981
11181
  [unitsProp]
10982
11182
  );
11183
+ const { sheetUrls: atlasSheetUrls, resolveUnitFrame: resolveUnitFrameInternal, pendingCount: atlasPending } = useUnitSpriteAtlas(units);
11184
+ const resolveFrameForUnit = React82.useCallback((unitId) => {
11185
+ return resolveUnitFrame?.(unitId) ?? resolveUnitFrameInternal(unitId);
11186
+ }, [resolveUnitFrame, resolveUnitFrameInternal]);
10983
11187
  const features = React82.useMemo(
10984
11188
  () => featuresProp.map((f3) => {
10985
11189
  if (f3.type) return f3;
@@ -11063,9 +11267,10 @@ function IsometricCanvas({
11063
11267
  }
11064
11268
  }
11065
11269
  if (effectSpriteUrls) urls.push(...effectSpriteUrls);
11270
+ if (atlasSheetUrls.length) urls.push(...atlasSheetUrls);
11066
11271
  if (backgroundImage) urls.push(backgroundImage);
11067
11272
  return [...new Set(urls.filter(Boolean))];
11068
- }, [sortedTiles, features, units, getTerrainSprite, getFeatureSprite, getUnitSprite, effectSpriteUrls, backgroundImage, assetManifest, resolveManifestUrl]);
11273
+ }, [sortedTiles, features, units, getTerrainSprite, getFeatureSprite, getUnitSprite, effectSpriteUrls, atlasSheetUrls, backgroundImage, assetManifest, resolveManifestUrl]);
11069
11274
  const { getImage, pendingCount } = useImageCache(spriteUrls);
11070
11275
  React82.useEffect(() => {
11071
11276
  if (typeof window === "undefined") return;
@@ -11352,7 +11557,7 @@ function IsometricCanvas({
11352
11557
  ctx.lineWidth = 3;
11353
11558
  ctx.stroke();
11354
11559
  }
11355
- const frame = resolveUnitFrame?.(unit.id) ?? null;
11560
+ const frame = resolveFrameForUnit(unit.id);
11356
11561
  const frameImg = frame ? getImage(frame.sheetUrl) : null;
11357
11562
  if (frame && frameImg) {
11358
11563
  const frameAr = frame.sw / frame.sh;
@@ -11462,7 +11667,7 @@ function IsometricCanvas({
11462
11667
  resolveTerrainSpriteUrl,
11463
11668
  resolveFeatureSpriteUrl,
11464
11669
  resolveUnitSpriteUrl,
11465
- resolveUnitFrame,
11670
+ resolveFrameForUnit,
11466
11671
  getImage,
11467
11672
  gridWidth,
11468
11673
  gridHeight,
@@ -11494,7 +11699,7 @@ function IsometricCanvas({
11494
11699
  };
11495
11700
  }, [selectedUnitId, units, scale, baseOffsetX, scaledTileWidth, scaledDiamondTopY, scaledFloorHeight, viewportSize, targetCameraRef]);
11496
11701
  React82.useEffect(() => {
11497
- const hasAnimations = units.length > 0 || validMoves.length > 0 || attackTargets.length > 0 || selectedUnitId != null || targetCameraRef.current != null || hasActiveEffects2 || pendingCount > 0;
11702
+ const hasAnimations = units.length > 0 || validMoves.length > 0 || attackTargets.length > 0 || selectedUnitId != null || targetCameraRef.current != null || hasActiveEffects2 || pendingCount > 0 || atlasPending > 0;
11498
11703
  draw(animTimeRef.current);
11499
11704
  if (!hasAnimations) return;
11500
11705
  let running = true;
@@ -11510,7 +11715,7 @@ function IsometricCanvas({
11510
11715
  running = false;
11511
11716
  cancelAnimationFrame(rafIdRef.current);
11512
11717
  };
11513
- }, [draw, units.length, validMoves.length, attackTargets.length, selectedUnitId, hasActiveEffects2, pendingCount, lerpToTarget, targetCameraRef]);
11718
+ }, [draw, units.length, validMoves.length, attackTargets.length, selectedUnitId, hasActiveEffects2, pendingCount, atlasPending, lerpToTarget, targetCameraRef]);
11514
11719
  const handleMouseMoveWithCamera = React82.useCallback((e) => {
11515
11720
  if (enableCamera) {
11516
11721
  const wasPanning = handleMouseMove(e, () => draw(animTimeRef.current));
@@ -11655,6 +11860,7 @@ var init_IsometricCanvas = __esm({
11655
11860
  init_ErrorState();
11656
11861
  init_useImageCache();
11657
11862
  init_useCamera();
11863
+ init_useUnitSpriteAtlas();
11658
11864
  init_verificationRegistry();
11659
11865
  init_isometric();
11660
11866
  IsometricCanvas.displayName = "IsometricCanvas";
@@ -29237,13 +29443,13 @@ var init_MapView = __esm({
29237
29443
  shadowSize: [41, 41]
29238
29444
  });
29239
29445
  L.Marker.prototype.options.icon = defaultIcon;
29240
- const { useEffect: useEffect78, useRef: useRef74, useCallback: useCallback117, useState: useState108 } = React82__namespace.default;
29446
+ const { useEffect: useEffect79, useRef: useRef75, useCallback: useCallback118, useState: useState109 } = React82__namespace.default;
29241
29447
  const { Typography: Typography2 } = await Promise.resolve().then(() => (init_Typography(), Typography_exports));
29242
29448
  const { useEventBus: useEventBus3 } = await Promise.resolve().then(() => (init_useEventBus(), useEventBus_exports));
29243
29449
  function MapUpdater({ centerLat, centerLng, zoom }) {
29244
29450
  const map = useMap();
29245
- const prevRef = useRef74({ centerLat, centerLng, zoom });
29246
- useEffect78(() => {
29451
+ const prevRef = useRef75({ centerLat, centerLng, zoom });
29452
+ useEffect79(() => {
29247
29453
  const prev = prevRef.current;
29248
29454
  if (prev.centerLat !== centerLat || prev.centerLng !== centerLng || prev.zoom !== zoom) {
29249
29455
  map.setView([centerLat, centerLng], zoom);
@@ -29254,7 +29460,7 @@ var init_MapView = __esm({
29254
29460
  }
29255
29461
  function MapClickHandler({ onMapClick }) {
29256
29462
  const map = useMap();
29257
- useEffect78(() => {
29463
+ useEffect79(() => {
29258
29464
  if (!onMapClick) return;
29259
29465
  const handler = (e) => {
29260
29466
  onMapClick(e.latlng.lat, e.latlng.lng);
@@ -29282,8 +29488,8 @@ var init_MapView = __esm({
29282
29488
  showAttribution = true
29283
29489
  }) {
29284
29490
  const eventBus = useEventBus3();
29285
- const [clickedPosition, setClickedPosition] = useState108(null);
29286
- const handleMapClick = useCallback117((lat, lng) => {
29491
+ const [clickedPosition, setClickedPosition] = useState109(null);
29492
+ const handleMapClick = useCallback118((lat, lng) => {
29287
29493
  if (showClickedPin) {
29288
29494
  setClickedPosition({ lat, lng });
29289
29495
  }
@@ -29292,7 +29498,7 @@ var init_MapView = __esm({
29292
29498
  eventBus.emit(`UI:${mapClickEvent}`, { latitude: lat, longitude: lng });
29293
29499
  }
29294
29500
  }, [onMapClick, mapClickEvent, eventBus, showClickedPin]);
29295
- const handleMarkerClick = useCallback117((marker) => {
29501
+ const handleMarkerClick = useCallback118((marker) => {
29296
29502
  onMarkerClick?.(marker);
29297
29503
  if (markerClickEvent) {
29298
29504
  eventBus.emit(`UI:${markerClickEvent}`, { ...marker });
@@ -41010,7 +41216,7 @@ var init_AssetLoader = __esm({
41010
41216
  __publicField(this, "textureCache");
41011
41217
  __publicField(this, "loadingPromises");
41012
41218
  this.objLoader = new OBJLoader_js.OBJLoader();
41013
- this.textureLoader = new THREE__namespace.TextureLoader();
41219
+ this.textureLoader = new THREE3__namespace.TextureLoader();
41014
41220
  this.modelCache = /* @__PURE__ */ new Map();
41015
41221
  this.textureCache = /* @__PURE__ */ new Map();
41016
41222
  this.loadingPromises = /* @__PURE__ */ new Map();
@@ -41084,7 +41290,7 @@ var init_AssetLoader = __esm({
41084
41290
  return this.loadingPromises.get(`texture:${url}`);
41085
41291
  }
41086
41292
  const loadPromise = this.textureLoader.loadAsync(url).then((texture) => {
41087
- texture.colorSpace = THREE__namespace.SRGBColorSpace;
41293
+ texture.colorSpace = THREE3__namespace.SRGBColorSpace;
41088
41294
  this.textureCache.set(url, texture);
41089
41295
  this.loadingPromises.delete(`texture:${url}`);
41090
41296
  return texture;
@@ -41158,7 +41364,7 @@ var init_AssetLoader = __esm({
41158
41364
  });
41159
41365
  this.modelCache.forEach((model) => {
41160
41366
  model.scene.traverse((child) => {
41161
- if (child instanceof THREE__namespace.Mesh) {
41367
+ if (child instanceof THREE3__namespace.Mesh) {
41162
41368
  child.geometry.dispose();
41163
41369
  if (Array.isArray(child.material)) {
41164
41370
  child.material.forEach((m) => m.dispose());
@@ -41714,7 +41920,7 @@ function ModelLoader({
41714
41920
  if (!loadedModel) return null;
41715
41921
  const cloned = loadedModel.clone();
41716
41922
  cloned.traverse((child) => {
41717
- if (child instanceof THREE__namespace.Mesh) {
41923
+ if (child instanceof THREE3__namespace.Mesh) {
41718
41924
  child.castShadow = castShadow;
41719
41925
  child.receiveShadow = receiveShadow;
41720
41926
  }
@@ -41828,6 +42034,47 @@ function CameraController({
41828
42034
  }, [camera.position, onCameraChange]);
41829
42035
  return null;
41830
42036
  }
42037
+ function UnitSpriteBillboard({
42038
+ sheetUrl,
42039
+ resolveFrame,
42040
+ height = 1.2
42041
+ }) {
42042
+ const texture = fiber.useLoader(THREE3__namespace.TextureLoader, sheetUrl);
42043
+ const meshRef = React82.useRef(null);
42044
+ const matRef = React82.useRef(null);
42045
+ const [aspect, setAspect] = React82.useState(1);
42046
+ fiber.useFrame(() => {
42047
+ const frame = resolveFrame();
42048
+ if (!frame || !texture.image) return;
42049
+ const imgW = texture.image.width;
42050
+ const imgH = texture.image.height;
42051
+ if (!imgW || !imgH) return;
42052
+ texture.repeat.set((frame.flipX ? -1 : 1) * (frame.sw / imgW), frame.sh / imgH);
42053
+ texture.offset.set(
42054
+ frame.flipX ? (frame.sx + frame.sw) / imgW : frame.sx / imgW,
42055
+ 1 - (frame.sy + frame.sh) / imgH
42056
+ );
42057
+ texture.magFilter = THREE3__namespace.NearestFilter;
42058
+ texture.minFilter = THREE3__namespace.NearestFilter;
42059
+ texture.needsUpdate = true;
42060
+ const nextAspect = frame.sw / frame.sh;
42061
+ if (Math.abs(nextAspect - aspect) > 1e-3) setAspect(nextAspect);
42062
+ if (matRef.current) matRef.current.needsUpdate = true;
42063
+ });
42064
+ return /* @__PURE__ */ jsxRuntime.jsxs("mesh", { ref: meshRef, position: [0, height / 2, 0], children: [
42065
+ /* @__PURE__ */ jsxRuntime.jsx("planeGeometry", { args: [height * aspect, height] }),
42066
+ /* @__PURE__ */ jsxRuntime.jsx(
42067
+ "meshBasicMaterial",
42068
+ {
42069
+ ref: matRef,
42070
+ map: texture,
42071
+ transparent: true,
42072
+ alphaTest: 0.1,
42073
+ side: THREE3__namespace.DoubleSide
42074
+ }
42075
+ )
42076
+ ] });
42077
+ }
41831
42078
  var DEFAULT_GRID_CONFIG, GameCanvas3D;
41832
42079
  var init_GameCanvas3D2 = __esm({
41833
42080
  "components/game/molecules/GameCanvas3D.tsx"() {
@@ -41838,6 +42085,7 @@ var init_GameCanvas3D2 = __esm({
41838
42085
  init_Canvas3DLoadingState2();
41839
42086
  init_Canvas3DErrorBoundary2();
41840
42087
  init_ModelLoader();
42088
+ init_useUnitSpriteAtlas();
41841
42089
  init_cn();
41842
42090
  init_GameCanvas3D();
41843
42091
  DEFAULT_GRID_CONFIG = {
@@ -41894,8 +42142,10 @@ var init_GameCanvas3D2 = __esm({
41894
42142
  const controlsRef = React82.useRef(null);
41895
42143
  const [hoveredTile, setHoveredTile] = React82.useState(null);
41896
42144
  const [internalError, setInternalError] = React82.useState(null);
42145
+ const { sheetUrls: atlasSheetUrls, resolveUnitFrame } = useUnitSpriteAtlas(units);
42146
+ const preloadUrls = React82.useMemo(() => [...preloadAssets, ...atlasSheetUrls], [preloadAssets, atlasSheetUrls]);
41897
42147
  const { isLoading: assetsLoading, progress, loaded, total } = useAssetLoader({
41898
- preloadUrls: preloadAssets,
42148
+ preloadUrls,
41899
42149
  loader: customAssetLoader
41900
42150
  });
41901
42151
  const eventHandlers = useGameCanvas3DEvents({
@@ -41954,7 +42204,7 @@ var init_GameCanvas3D2 = __esm({
41954
42204
  getCameraPosition: () => {
41955
42205
  if (controlsRef.current) {
41956
42206
  const pos = controlsRef.current.object.position;
41957
- return new THREE__namespace.Vector3(pos.x, pos.y, pos.z);
42207
+ return new THREE3__namespace.Vector3(pos.x, pos.y, pos.z);
41958
42208
  }
41959
42209
  return null;
41960
42210
  },
@@ -42106,6 +42356,8 @@ var init_GameCanvas3D2 = __esm({
42106
42356
  ({ unit, position }) => {
42107
42357
  const isSelected = selectedUnitId === unit.id;
42108
42358
  const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
42359
+ const hasAtlas = unitAtlasUrl(unit) !== null;
42360
+ const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
42109
42361
  return /* @__PURE__ */ jsxRuntime.jsxs(
42110
42362
  "group",
42111
42363
  {
@@ -42117,7 +42369,16 @@ var init_GameCanvas3D2 = __esm({
42117
42369
  /* @__PURE__ */ jsxRuntime.jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
42118
42370
  /* @__PURE__ */ jsxRuntime.jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
42119
42371
  ] }),
42120
- unit.modelUrl ? (
42372
+ hasAtlas && initialFrame ? (
42373
+ /* Animated sprite-sheet billboard — single cropped frame, by state */
42374
+ /* @__PURE__ */ jsxRuntime.jsx(drei.Billboard, { children: /* @__PURE__ */ jsxRuntime.jsx(
42375
+ UnitSpriteBillboard,
42376
+ {
42377
+ sheetUrl: initialFrame.sheetUrl,
42378
+ resolveFrame: () => resolveUnitFrame(unit.id)
42379
+ }
42380
+ ) })
42381
+ ) : unit.modelUrl ? (
42121
42382
  /* GLB unit model (box fallback while loading / on error) */
42122
42383
  /* @__PURE__ */ jsxRuntime.jsx(
42123
42384
  ModelLoader,
@@ -42171,7 +42432,7 @@ var init_GameCanvas3D2 = __esm({
42171
42432
  }
42172
42433
  );
42173
42434
  },
42174
- [selectedUnitId, handleUnitClick]
42435
+ [selectedUnitId, handleUnitClick, resolveUnitFrame]
42175
42436
  );
42176
42437
  const DefaultFeatureRenderer = React82.useCallback(
42177
42438
  ({