@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 = 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;
@@ -9419,6 +9419,291 @@ var init_useCamera = __esm({
9419
9419
  }
9420
9420
  });
9421
9421
 
9422
+ // components/game/organisms/utils/spriteSheetConstants.ts
9423
+ exports.SHEET_COLUMNS = void 0; exports.SPRITE_SHEET_LAYOUT = void 0;
9424
+ var init_spriteSheetConstants = __esm({
9425
+ "components/game/organisms/utils/spriteSheetConstants.ts"() {
9426
+ exports.SHEET_COLUMNS = 8;
9427
+ exports.SPRITE_SHEET_LAYOUT = {
9428
+ idle: { row: 0, frames: 4, frameRate: 6, loop: true },
9429
+ walk: { row: 1, frames: 8, frameRate: 10, loop: true },
9430
+ attack: { row: 2, frames: 6, frameRate: 12, loop: false },
9431
+ hit: { row: 3, frames: 3, frameRate: 8, loop: false },
9432
+ death: { row: 4, frames: 6, frameRate: 8, loop: false }
9433
+ };
9434
+ }
9435
+ });
9436
+
9437
+ // components/game/organisms/utils/spriteAnimation.ts
9438
+ function inferDirection(dx, dy) {
9439
+ if (dx === 0 && dy === 0) return "se";
9440
+ if (dx >= 0 && dy >= 0) return "se";
9441
+ if (dx <= 0 && dy >= 0) return "sw";
9442
+ if (dx >= 0 && dy <= 0) return "ne";
9443
+ return "nw";
9444
+ }
9445
+ function resolveSheetDirection(facing) {
9446
+ switch (facing) {
9447
+ case "se":
9448
+ return { sheetDir: "se", flipX: false };
9449
+ case "sw":
9450
+ return { sheetDir: "sw", flipX: false };
9451
+ case "ne":
9452
+ return { sheetDir: "sw", flipX: true };
9453
+ case "nw":
9454
+ return { sheetDir: "se", flipX: true };
9455
+ }
9456
+ }
9457
+ function frameRect(frame, row, columns, frameWidth, frameHeight) {
9458
+ return {
9459
+ sx: frame % columns * frameWidth,
9460
+ sy: row * frameHeight,
9461
+ sw: frameWidth,
9462
+ sh: frameHeight
9463
+ };
9464
+ }
9465
+ function getCurrentFrameFromDef(def, elapsed) {
9466
+ const frameDuration = 1e3 / def.frameRate;
9467
+ const totalDuration = def.frames * frameDuration;
9468
+ if (def.loop) {
9469
+ const frame2 = Math.floor(elapsed % totalDuration / frameDuration);
9470
+ return { frame: frame2, finished: false };
9471
+ }
9472
+ if (elapsed >= totalDuration) {
9473
+ return { frame: def.frames - 1, finished: true };
9474
+ }
9475
+ const frame = Math.floor(elapsed / frameDuration);
9476
+ return { frame, finished: false };
9477
+ }
9478
+ function getCurrentFrame(animName, elapsed) {
9479
+ return getCurrentFrameFromDef(exports.SPRITE_SHEET_LAYOUT[animName], elapsed);
9480
+ }
9481
+ function resolveFrame(sheetUrls, frameDims, animState) {
9482
+ if (!sheetUrls) return null;
9483
+ const { sheetDir, flipX } = resolveSheetDirection(animState.direction);
9484
+ const sheetUrl = sheetUrls[sheetDir];
9485
+ if (!sheetUrl) return null;
9486
+ const def = exports.SPRITE_SHEET_LAYOUT[animState.animation];
9487
+ const { frame } = getCurrentFrame(animState.animation, animState.elapsed);
9488
+ const rect = frameRect(frame, def.row, def.frames, frameDims.width, frameDims.height);
9489
+ return {
9490
+ sheetUrl,
9491
+ sx: rect.sx,
9492
+ sy: rect.sy,
9493
+ sw: rect.sw,
9494
+ sh: rect.sh,
9495
+ flipX
9496
+ };
9497
+ }
9498
+ function createUnitAnimationState(unitId) {
9499
+ return {
9500
+ unitId,
9501
+ animation: "idle",
9502
+ direction: "se",
9503
+ frame: 0,
9504
+ elapsed: 0,
9505
+ queuedAnimation: null,
9506
+ finished: false
9507
+ };
9508
+ }
9509
+ function transitionAnimation(state, newAnim, direction) {
9510
+ if (state.animation === "death" && state.finished) return state;
9511
+ if (state.animation === newAnim && exports.SPRITE_SHEET_LAYOUT[newAnim].loop) {
9512
+ return direction ? { ...state, direction } : state;
9513
+ }
9514
+ return {
9515
+ ...state,
9516
+ animation: newAnim,
9517
+ direction: direction ?? state.direction,
9518
+ frame: 0,
9519
+ elapsed: 0,
9520
+ queuedAnimation: null,
9521
+ finished: false
9522
+ };
9523
+ }
9524
+ function tickAnimationState(state, deltaMs) {
9525
+ const newElapsed = state.elapsed + deltaMs;
9526
+ const { frame, finished } = getCurrentFrame(state.animation, newElapsed);
9527
+ const def = exports.SPRITE_SHEET_LAYOUT[state.animation];
9528
+ if (finished && !def.loop && !state.finished) {
9529
+ if (state.animation === "death") {
9530
+ return { ...state, elapsed: newElapsed, frame, finished: true };
9531
+ }
9532
+ const nextAnim = state.queuedAnimation ?? "idle";
9533
+ return {
9534
+ ...state,
9535
+ animation: nextAnim,
9536
+ elapsed: 0,
9537
+ frame: 0,
9538
+ queuedAnimation: null,
9539
+ finished: false
9540
+ };
9541
+ }
9542
+ return { ...state, elapsed: newElapsed, frame, finished };
9543
+ }
9544
+ var init_spriteAnimation = __esm({
9545
+ "components/game/organisms/utils/spriteAnimation.ts"() {
9546
+ init_spriteSheetConstants();
9547
+ }
9548
+ });
9549
+ function unitAtlasUrl(unit) {
9550
+ if (unit.spriteSheet) return unit.spriteSheet;
9551
+ const sprite = unit.sprite;
9552
+ if (!sprite) return null;
9553
+ const match = /^(.*-sprite-sheet)(?:-(?:se|sw))?(?:-v\d+)?\.png$/.exec(sprite);
9554
+ if (!match) return null;
9555
+ return `${match[1]}.json`;
9556
+ }
9557
+ function resolveSheetUrl(atlasUrl, relativeSheetPath) {
9558
+ try {
9559
+ return new URL(relativeSheetPath, atlasUrl).toString();
9560
+ } catch {
9561
+ const base = atlasUrl.slice(0, atlasUrl.lastIndexOf("/") + 1);
9562
+ return `${base}${relativeSheetPath.replace(/^\.\//, "")}`;
9563
+ }
9564
+ }
9565
+ function useUnitSpriteAtlas(units) {
9566
+ const atlasCacheRef = React76.useRef(/* @__PURE__ */ new Map());
9567
+ const loadingRef = React76.useRef(/* @__PURE__ */ new Set());
9568
+ const [pendingCount, setPendingCount] = React76.useState(0);
9569
+ const [, forceTick] = React76.useState(0);
9570
+ const animStatesRef = React76.useRef(/* @__PURE__ */ new Map());
9571
+ const lastTickRef = React76.useRef(0);
9572
+ const rafRef = React76.useRef(0);
9573
+ const atlasUrls = React76.useMemo(() => {
9574
+ const set = /* @__PURE__ */ new Set();
9575
+ for (const unit of units) {
9576
+ const url = unitAtlasUrl(unit);
9577
+ if (url) set.add(url);
9578
+ }
9579
+ return [...set];
9580
+ }, [units]);
9581
+ React76.useEffect(() => {
9582
+ const cache = atlasCacheRef.current;
9583
+ const loading = loadingRef.current;
9584
+ const toLoad = atlasUrls.filter((url) => !cache.has(url) && !loading.has(url));
9585
+ if (toLoad.length === 0) return;
9586
+ let cancelled = false;
9587
+ setPendingCount((prev) => prev + toLoad.length);
9588
+ for (const url of toLoad) {
9589
+ loading.add(url);
9590
+ fetch(url).then((res) => res.ok ? res.json() : Promise.reject(new Error(String(res.status)))).then((atlas) => {
9591
+ if (cancelled) return;
9592
+ cache.set(url, atlas);
9593
+ }).catch(() => {
9594
+ }).finally(() => {
9595
+ if (cancelled) return;
9596
+ loading.delete(url);
9597
+ setPendingCount((prev) => Math.max(0, prev - 1));
9598
+ forceTick((n) => n + 1);
9599
+ });
9600
+ }
9601
+ return () => {
9602
+ cancelled = true;
9603
+ };
9604
+ }, [atlasUrls]);
9605
+ const sheetUrls = React76.useMemo(() => {
9606
+ const urls = /* @__PURE__ */ new Set();
9607
+ for (const unit of units) {
9608
+ const atlasUrl = unitAtlasUrl(unit);
9609
+ if (!atlasUrl) continue;
9610
+ const atlas = atlasCacheRef.current.get(atlasUrl);
9611
+ if (!atlas) continue;
9612
+ for (const rel of Object.values(atlas.sheets)) {
9613
+ if (rel) urls.add(resolveSheetUrl(atlasUrl, rel));
9614
+ }
9615
+ }
9616
+ return [...urls];
9617
+ }, [units, pendingCount]);
9618
+ React76.useEffect(() => {
9619
+ const hasAtlasUnits = units.some((u) => unitAtlasUrl(u) !== null);
9620
+ if (!hasAtlasUnits) return;
9621
+ let running = true;
9622
+ const tick = (ts) => {
9623
+ if (!running) return;
9624
+ const last = lastTickRef.current || ts;
9625
+ const delta = ts - last;
9626
+ lastTickRef.current = ts;
9627
+ const states = animStatesRef.current;
9628
+ const currentIds = /* @__PURE__ */ new Set();
9629
+ for (const unit of units) {
9630
+ if (unitAtlasUrl(unit) === null) continue;
9631
+ currentIds.add(unit.id);
9632
+ let state = states.get(unit.id);
9633
+ if (!state) {
9634
+ state = { animation: "idle", direction: "se", elapsed: 0, walkHold: 0, prev: null };
9635
+ states.set(unit.id, state);
9636
+ }
9637
+ const posX = unit.position?.x ?? unit.x ?? 0;
9638
+ const posY = unit.position?.y ?? unit.y ?? 0;
9639
+ if (state.prev) {
9640
+ const dx = posX - state.prev.x;
9641
+ const dy = posY - state.prev.y;
9642
+ if (dx !== 0 || dy !== 0) {
9643
+ state.animation = "walk";
9644
+ state.direction = inferDirection(dx, dy);
9645
+ state.walkHold = WALK_HOLD_MS;
9646
+ } else if (state.animation === "walk") {
9647
+ state.walkHold -= delta;
9648
+ if (state.walkHold <= 0) state.animation = "idle";
9649
+ }
9650
+ }
9651
+ state.prev = { x: posX, y: posY };
9652
+ state.elapsed += delta;
9653
+ }
9654
+ for (const id of states.keys()) {
9655
+ if (!currentIds.has(id)) states.delete(id);
9656
+ }
9657
+ rafRef.current = requestAnimationFrame(tick);
9658
+ };
9659
+ rafRef.current = requestAnimationFrame(tick);
9660
+ return () => {
9661
+ running = false;
9662
+ cancelAnimationFrame(rafRef.current);
9663
+ lastTickRef.current = 0;
9664
+ };
9665
+ }, [units]);
9666
+ const resolveUnitFrame = React76.useCallback((unitId) => {
9667
+ const unit = units.find((u) => u.id === unitId);
9668
+ if (!unit) return null;
9669
+ const atlasUrl = unitAtlasUrl(unit);
9670
+ if (!atlasUrl) return null;
9671
+ const atlas = atlasCacheRef.current.get(atlasUrl);
9672
+ if (!atlas) return null;
9673
+ const state = animStatesRef.current.get(unitId);
9674
+ const animation = state?.animation ?? "idle";
9675
+ const direction = state?.direction ?? "se";
9676
+ const elapsed = state?.elapsed ?? 0;
9677
+ const def = atlas.animations[animation] ?? atlas.animations.idle;
9678
+ if (!def) return null;
9679
+ const { sheetDir, flipX } = resolveSheetDirection(direction);
9680
+ const rel = atlas.sheets[sheetDir] ?? atlas.sheets.se ?? atlas.sheets.sw;
9681
+ if (!rel) return null;
9682
+ const sheetUrl = resolveSheetUrl(atlasUrl, rel);
9683
+ const isIdle = animation === "idle";
9684
+ const frame = isIdle ? 0 : getCurrentFrameFromDef(def, elapsed).frame;
9685
+ const rect = frameRect(frame, def.row, atlas.columns, atlas.frameWidth, atlas.frameHeight);
9686
+ return {
9687
+ sheetUrl,
9688
+ sx: rect.sx,
9689
+ sy: rect.sy,
9690
+ sw: rect.sw,
9691
+ sh: rect.sh,
9692
+ flipX,
9693
+ applyBreathing: isIdle
9694
+ };
9695
+ }, [units]);
9696
+ return { sheetUrls, resolveUnitFrame, pendingCount };
9697
+ }
9698
+ var WALK_HOLD_MS;
9699
+ var init_useUnitSpriteAtlas = __esm({
9700
+ "components/game/molecules/useUnitSpriteAtlas.ts"() {
9701
+ "use client";
9702
+ init_spriteAnimation();
9703
+ WALK_HOLD_MS = 600;
9704
+ }
9705
+ });
9706
+
9422
9707
  // components/game/organisms/utils/isometric.ts
9423
9708
  function isoToScreen(tileX, tileY, scale, baseOffsetX) {
9424
9709
  const scaledTileWidth = exports.TILE_WIDTH * scale;
@@ -9532,6 +9817,10 @@ function IsometricCanvas({
9532
9817
  () => unitsProp.map((u) => u.position ? u : { ...u, position: { x: u.x ?? 0, y: u.y ?? 0 } }),
9533
9818
  [unitsProp]
9534
9819
  );
9820
+ const { sheetUrls: atlasSheetUrls, resolveUnitFrame: resolveUnitFrameInternal, pendingCount: atlasPending } = useUnitSpriteAtlas(units);
9821
+ const resolveFrameForUnit = React76.useCallback((unitId) => {
9822
+ return resolveUnitFrame?.(unitId) ?? resolveUnitFrameInternal(unitId);
9823
+ }, [resolveUnitFrame, resolveUnitFrameInternal]);
9535
9824
  const features = React76.useMemo(
9536
9825
  () => featuresProp.map((f3) => {
9537
9826
  if (f3.type) return f3;
@@ -9615,9 +9904,10 @@ function IsometricCanvas({
9615
9904
  }
9616
9905
  }
9617
9906
  if (effectSpriteUrls) urls.push(...effectSpriteUrls);
9907
+ if (atlasSheetUrls.length) urls.push(...atlasSheetUrls);
9618
9908
  if (backgroundImage) urls.push(backgroundImage);
9619
9909
  return [...new Set(urls.filter(Boolean))];
9620
- }, [sortedTiles, features, units, getTerrainSprite, getFeatureSprite, getUnitSprite, effectSpriteUrls, backgroundImage, assetManifest, resolveManifestUrl]);
9910
+ }, [sortedTiles, features, units, getTerrainSprite, getFeatureSprite, getUnitSprite, effectSpriteUrls, atlasSheetUrls, backgroundImage, assetManifest, resolveManifestUrl]);
9621
9911
  const { getImage, pendingCount } = useImageCache(spriteUrls);
9622
9912
  React76.useEffect(() => {
9623
9913
  if (typeof window === "undefined") return;
@@ -9905,7 +10195,7 @@ function IsometricCanvas({
9905
10195
  ctx.lineWidth = 3;
9906
10196
  ctx.stroke();
9907
10197
  }
9908
- const frame = resolveUnitFrame?.(unit.id) ?? null;
10198
+ const frame = resolveFrameForUnit(unit.id);
9909
10199
  const frameImg = frame ? getImage(frame.sheetUrl) : null;
9910
10200
  if (frame && frameImg) {
9911
10201
  const frameAr = frame.sw / frame.sh;
@@ -10015,7 +10305,7 @@ function IsometricCanvas({
10015
10305
  resolveTerrainSpriteUrl,
10016
10306
  resolveFeatureSpriteUrl,
10017
10307
  resolveUnitSpriteUrl,
10018
- resolveUnitFrame,
10308
+ resolveFrameForUnit,
10019
10309
  getImage,
10020
10310
  gridWidth,
10021
10311
  gridHeight,
@@ -10047,7 +10337,7 @@ function IsometricCanvas({
10047
10337
  };
10048
10338
  }, [selectedUnitId, units, scale, baseOffsetX, scaledTileWidth, scaledDiamondTopY, scaledFloorHeight, viewportSize, targetCameraRef]);
10049
10339
  React76.useEffect(() => {
10050
- const hasAnimations = units.length > 0 || validMoves.length > 0 || attackTargets.length > 0 || selectedUnitId != null || targetCameraRef.current != null || hasActiveEffects2 || pendingCount > 0;
10340
+ const hasAnimations = units.length > 0 || validMoves.length > 0 || attackTargets.length > 0 || selectedUnitId != null || targetCameraRef.current != null || hasActiveEffects2 || pendingCount > 0 || atlasPending > 0;
10051
10341
  draw(animTimeRef.current);
10052
10342
  if (!hasAnimations) return;
10053
10343
  let running = true;
@@ -10063,7 +10353,7 @@ function IsometricCanvas({
10063
10353
  running = false;
10064
10354
  cancelAnimationFrame(rafIdRef.current);
10065
10355
  };
10066
- }, [draw, units.length, validMoves.length, attackTargets.length, selectedUnitId, hasActiveEffects2, pendingCount, lerpToTarget, targetCameraRef]);
10356
+ }, [draw, units.length, validMoves.length, attackTargets.length, selectedUnitId, hasActiveEffects2, pendingCount, atlasPending, lerpToTarget, targetCameraRef]);
10067
10357
  const handleMouseMoveWithCamera = React76.useCallback((e) => {
10068
10358
  if (enableCamera) {
10069
10359
  const wasPanning = handleMouseMove(e, () => draw(animTimeRef.current));
@@ -10208,6 +10498,7 @@ var init_IsometricCanvas = __esm({
10208
10498
  init_ErrorState();
10209
10499
  init_useImageCache();
10210
10500
  init_useCamera();
10501
+ init_useUnitSpriteAtlas();
10211
10502
  init_verificationRegistry();
10212
10503
  init_isometric();
10213
10504
  IsometricCanvas.displayName = "IsometricCanvas";
@@ -29578,13 +29869,13 @@ var init_MapView = __esm({
29578
29869
  shadowSize: [41, 41]
29579
29870
  });
29580
29871
  L.Marker.prototype.options.icon = defaultIcon;
29581
- const { useEffect: useEffect79, useRef: useRef77, useCallback: useCallback119, useState: useState109 } = React76__namespace.default;
29872
+ const { useEffect: useEffect80, useRef: useRef78, useCallback: useCallback120, useState: useState110 } = React76__namespace.default;
29582
29873
  const { Typography: Typography2 } = await Promise.resolve().then(() => (init_Typography(), Typography_exports));
29583
29874
  const { useEventBus: useEventBus3 } = await Promise.resolve().then(() => (init_useEventBus(), useEventBus_exports));
29584
29875
  function MapUpdater({ centerLat, centerLng, zoom }) {
29585
29876
  const map = useMap();
29586
- const prevRef = useRef77({ centerLat, centerLng, zoom });
29587
- useEffect79(() => {
29877
+ const prevRef = useRef78({ centerLat, centerLng, zoom });
29878
+ useEffect80(() => {
29588
29879
  const prev = prevRef.current;
29589
29880
  if (prev.centerLat !== centerLat || prev.centerLng !== centerLng || prev.zoom !== zoom) {
29590
29881
  map.setView([centerLat, centerLng], zoom);
@@ -29595,7 +29886,7 @@ var init_MapView = __esm({
29595
29886
  }
29596
29887
  function MapClickHandler({ onMapClick }) {
29597
29888
  const map = useMap();
29598
- useEffect79(() => {
29889
+ useEffect80(() => {
29599
29890
  if (!onMapClick) return;
29600
29891
  const handler = (e) => {
29601
29892
  onMapClick(e.latlng.lat, e.latlng.lng);
@@ -29623,8 +29914,8 @@ var init_MapView = __esm({
29623
29914
  showAttribution = true
29624
29915
  }) {
29625
29916
  const eventBus = useEventBus3();
29626
- const [clickedPosition, setClickedPosition] = useState109(null);
29627
- const handleMapClick = useCallback119((lat, lng) => {
29917
+ const [clickedPosition, setClickedPosition] = useState110(null);
29918
+ const handleMapClick = useCallback120((lat, lng) => {
29628
29919
  if (showClickedPin) {
29629
29920
  setClickedPosition({ lat, lng });
29630
29921
  }
@@ -29633,7 +29924,7 @@ var init_MapView = __esm({
29633
29924
  eventBus.emit(`UI:${mapClickEvent}`, { latitude: lat, longitude: lng });
29634
29925
  }
29635
29926
  }, [onMapClick, mapClickEvent, eventBus, showClickedPin]);
29636
- const handleMarkerClick = useCallback119((marker) => {
29927
+ const handleMarkerClick = useCallback120((marker) => {
29637
29928
  onMarkerClick?.(marker);
29638
29929
  if (markerClickEvent) {
29639
29930
  eventBus.emit(`UI:${markerClickEvent}`, { ...marker });
@@ -41678,7 +41969,7 @@ var init_AssetLoader = __esm({
41678
41969
  __publicField(this, "textureCache");
41679
41970
  __publicField(this, "loadingPromises");
41680
41971
  this.objLoader = new OBJLoader_js.OBJLoader();
41681
- this.textureLoader = new THREE__namespace.TextureLoader();
41972
+ this.textureLoader = new THREE3__namespace.TextureLoader();
41682
41973
  this.modelCache = /* @__PURE__ */ new Map();
41683
41974
  this.textureCache = /* @__PURE__ */ new Map();
41684
41975
  this.loadingPromises = /* @__PURE__ */ new Map();
@@ -41752,7 +42043,7 @@ var init_AssetLoader = __esm({
41752
42043
  return this.loadingPromises.get(`texture:${url}`);
41753
42044
  }
41754
42045
  const loadPromise = this.textureLoader.loadAsync(url).then((texture) => {
41755
- texture.colorSpace = THREE__namespace.SRGBColorSpace;
42046
+ texture.colorSpace = THREE3__namespace.SRGBColorSpace;
41756
42047
  this.textureCache.set(url, texture);
41757
42048
  this.loadingPromises.delete(`texture:${url}`);
41758
42049
  return texture;
@@ -41826,7 +42117,7 @@ var init_AssetLoader = __esm({
41826
42117
  });
41827
42118
  this.modelCache.forEach((model) => {
41828
42119
  model.scene.traverse((child) => {
41829
- if (child instanceof THREE__namespace.Mesh) {
42120
+ if (child instanceof THREE3__namespace.Mesh) {
41830
42121
  child.geometry.dispose();
41831
42122
  if (Array.isArray(child.material)) {
41832
42123
  child.material.forEach((m) => m.dispose());
@@ -42382,7 +42673,7 @@ function ModelLoader({
42382
42673
  if (!loadedModel) return null;
42383
42674
  const cloned = loadedModel.clone();
42384
42675
  cloned.traverse((child) => {
42385
- if (child instanceof THREE__namespace.Mesh) {
42676
+ if (child instanceof THREE3__namespace.Mesh) {
42386
42677
  child.castShadow = castShadow;
42387
42678
  child.receiveShadow = receiveShadow;
42388
42679
  }
@@ -42496,6 +42787,47 @@ function CameraController({
42496
42787
  }, [camera.position, onCameraChange]);
42497
42788
  return null;
42498
42789
  }
42790
+ function UnitSpriteBillboard({
42791
+ sheetUrl,
42792
+ resolveFrame: resolveFrame2,
42793
+ height = 1.2
42794
+ }) {
42795
+ const texture = fiber.useLoader(THREE3__namespace.TextureLoader, sheetUrl);
42796
+ const meshRef = React76.useRef(null);
42797
+ const matRef = React76.useRef(null);
42798
+ const [aspect, setAspect] = React76.useState(1);
42799
+ fiber.useFrame(() => {
42800
+ const frame = resolveFrame2();
42801
+ if (!frame || !texture.image) return;
42802
+ const imgW = texture.image.width;
42803
+ const imgH = texture.image.height;
42804
+ if (!imgW || !imgH) return;
42805
+ texture.repeat.set((frame.flipX ? -1 : 1) * (frame.sw / imgW), frame.sh / imgH);
42806
+ texture.offset.set(
42807
+ frame.flipX ? (frame.sx + frame.sw) / imgW : frame.sx / imgW,
42808
+ 1 - (frame.sy + frame.sh) / imgH
42809
+ );
42810
+ texture.magFilter = THREE3__namespace.NearestFilter;
42811
+ texture.minFilter = THREE3__namespace.NearestFilter;
42812
+ texture.needsUpdate = true;
42813
+ const nextAspect = frame.sw / frame.sh;
42814
+ if (Math.abs(nextAspect - aspect) > 1e-3) setAspect(nextAspect);
42815
+ if (matRef.current) matRef.current.needsUpdate = true;
42816
+ });
42817
+ return /* @__PURE__ */ jsxRuntime.jsxs("mesh", { ref: meshRef, position: [0, height / 2, 0], children: [
42818
+ /* @__PURE__ */ jsxRuntime.jsx("planeGeometry", { args: [height * aspect, height] }),
42819
+ /* @__PURE__ */ jsxRuntime.jsx(
42820
+ "meshBasicMaterial",
42821
+ {
42822
+ ref: matRef,
42823
+ map: texture,
42824
+ transparent: true,
42825
+ alphaTest: 0.1,
42826
+ side: THREE3__namespace.DoubleSide
42827
+ }
42828
+ )
42829
+ ] });
42830
+ }
42499
42831
  var DEFAULT_GRID_CONFIG, GameCanvas3D;
42500
42832
  var init_GameCanvas3D2 = __esm({
42501
42833
  "components/game/molecules/GameCanvas3D.tsx"() {
@@ -42506,6 +42838,7 @@ var init_GameCanvas3D2 = __esm({
42506
42838
  init_Canvas3DLoadingState2();
42507
42839
  init_Canvas3DErrorBoundary2();
42508
42840
  init_ModelLoader();
42841
+ init_useUnitSpriteAtlas();
42509
42842
  init_cn();
42510
42843
  init_GameCanvas3D();
42511
42844
  DEFAULT_GRID_CONFIG = {
@@ -42562,8 +42895,10 @@ var init_GameCanvas3D2 = __esm({
42562
42895
  const controlsRef = React76.useRef(null);
42563
42896
  const [hoveredTile, setHoveredTile] = React76.useState(null);
42564
42897
  const [internalError, setInternalError] = React76.useState(null);
42898
+ const { sheetUrls: atlasSheetUrls, resolveUnitFrame } = useUnitSpriteAtlas(units);
42899
+ const preloadUrls = React76.useMemo(() => [...preloadAssets, ...atlasSheetUrls], [preloadAssets, atlasSheetUrls]);
42565
42900
  const { isLoading: assetsLoading, progress, loaded, total } = useAssetLoader({
42566
- preloadUrls: preloadAssets,
42901
+ preloadUrls,
42567
42902
  loader: customAssetLoader
42568
42903
  });
42569
42904
  const eventHandlers = useGameCanvas3DEvents({
@@ -42622,7 +42957,7 @@ var init_GameCanvas3D2 = __esm({
42622
42957
  getCameraPosition: () => {
42623
42958
  if (controlsRef.current) {
42624
42959
  const pos = controlsRef.current.object.position;
42625
- return new THREE__namespace.Vector3(pos.x, pos.y, pos.z);
42960
+ return new THREE3__namespace.Vector3(pos.x, pos.y, pos.z);
42626
42961
  }
42627
42962
  return null;
42628
42963
  },
@@ -42774,6 +43109,8 @@ var init_GameCanvas3D2 = __esm({
42774
43109
  ({ unit, position }) => {
42775
43110
  const isSelected = selectedUnitId === unit.id;
42776
43111
  const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
43112
+ const hasAtlas = unitAtlasUrl(unit) !== null;
43113
+ const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
42777
43114
  return /* @__PURE__ */ jsxRuntime.jsxs(
42778
43115
  "group",
42779
43116
  {
@@ -42785,7 +43122,16 @@ var init_GameCanvas3D2 = __esm({
42785
43122
  /* @__PURE__ */ jsxRuntime.jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
42786
43123
  /* @__PURE__ */ jsxRuntime.jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
42787
43124
  ] }),
42788
- unit.modelUrl ? (
43125
+ hasAtlas && initialFrame ? (
43126
+ /* Animated sprite-sheet billboard — single cropped frame, by state */
43127
+ /* @__PURE__ */ jsxRuntime.jsx(drei.Billboard, { children: /* @__PURE__ */ jsxRuntime.jsx(
43128
+ UnitSpriteBillboard,
43129
+ {
43130
+ sheetUrl: initialFrame.sheetUrl,
43131
+ resolveFrame: () => resolveUnitFrame(unit.id)
43132
+ }
43133
+ ) })
43134
+ ) : unit.modelUrl ? (
42789
43135
  /* GLB unit model (box fallback while loading / on error) */
42790
43136
  /* @__PURE__ */ jsxRuntime.jsx(
42791
43137
  ModelLoader,
@@ -42839,7 +43185,7 @@ var init_GameCanvas3D2 = __esm({
42839
43185
  }
42840
43186
  );
42841
43187
  },
42842
- [selectedUnitId, handleUnitClick]
43188
+ [selectedUnitId, handleUnitClick, resolveUnitFrame]
42843
43189
  );
42844
43190
  const DefaultFeatureRenderer = React76.useCallback(
42845
43191
  ({
@@ -48543,12 +48889,8 @@ function Sprite({
48543
48889
  }) {
48544
48890
  const eventBus = useEventBus();
48545
48891
  const sourcePosition = React76.useMemo(() => {
48546
- const frameX = frame % columns;
48547
- const frameY = Math.floor(frame / columns);
48548
- return {
48549
- x: frameX * frameWidth,
48550
- y: frameY * frameHeight
48551
- };
48892
+ const { sx, sy } = frameRect(frame, Math.floor(frame / columns), columns, frameWidth, frameHeight);
48893
+ return { x: sx, y: sy };
48552
48894
  }, [frame, columns, frameWidth, frameHeight]);
48553
48895
  const transform = React76.useMemo(() => {
48554
48896
  const transforms = [
@@ -48606,8 +48948,7 @@ function drawSprite(ctx, image, props) {
48606
48948
  opacity = 1,
48607
48949
  columns = 16
48608
48950
  } = props;
48609
- const sourceX = frame % columns * frameWidth;
48610
- const sourceY = Math.floor(frame / columns) * frameHeight;
48951
+ const { sx: sourceX, sy: sourceY } = frameRect(frame, Math.floor(frame / columns), columns, frameWidth, frameHeight);
48611
48952
  ctx.save();
48612
48953
  ctx.globalAlpha = opacity;
48613
48954
  ctx.translate(x + frameWidth / 2, y + frameHeight / 2);
@@ -48632,6 +48973,7 @@ var init_Sprite = __esm({
48632
48973
  "components/game/atoms/Sprite.tsx"() {
48633
48974
  "use client";
48634
48975
  init_useEventBus();
48976
+ init_spriteAnimation();
48635
48977
  }
48636
48978
  });
48637
48979
  exports.StatCard = void 0;
@@ -52639,121 +52981,15 @@ init_useGameAudio();
52639
52981
  init_useImageCache();
52640
52982
  init_useCamera();
52641
52983
 
52642
- // components/game/organisms/utils/spriteSheetConstants.ts
52643
- var SHEET_COLUMNS = 8;
52644
- var SPRITE_SHEET_LAYOUT = {
52645
- idle: { row: 0, frames: 4, frameRate: 6, loop: true },
52646
- walk: { row: 1, frames: 8, frameRate: 10, loop: true },
52647
- attack: { row: 2, frames: 6, frameRate: 12, loop: false },
52648
- hit: { row: 3, frames: 3, frameRate: 8, loop: false },
52649
- death: { row: 4, frames: 6, frameRate: 8, loop: false }
52650
- };
52651
-
52652
- // components/game/organisms/utils/spriteAnimation.ts
52653
- function inferDirection(dx, dy) {
52654
- if (dx === 0 && dy === 0) return "se";
52655
- if (dx >= 0 && dy >= 0) return "se";
52656
- if (dx <= 0 && dy >= 0) return "sw";
52657
- if (dx >= 0 && dy <= 0) return "ne";
52658
- return "nw";
52659
- }
52660
- function resolveSheetDirection(facing) {
52661
- switch (facing) {
52662
- case "se":
52663
- return { sheetDir: "se", flipX: false };
52664
- case "sw":
52665
- return { sheetDir: "sw", flipX: false };
52666
- case "ne":
52667
- return { sheetDir: "sw", flipX: true };
52668
- case "nw":
52669
- return { sheetDir: "se", flipX: true };
52670
- }
52671
- }
52672
- function getCurrentFrame(animName, elapsed) {
52673
- const def = SPRITE_SHEET_LAYOUT[animName];
52674
- const frameDuration = 1e3 / def.frameRate;
52675
- const totalDuration = def.frames * frameDuration;
52676
- if (def.loop) {
52677
- const frame2 = Math.floor(elapsed % totalDuration / frameDuration);
52678
- return { frame: frame2, finished: false };
52679
- }
52680
- if (elapsed >= totalDuration) {
52681
- return { frame: def.frames - 1, finished: true };
52682
- }
52683
- const frame = Math.floor(elapsed / frameDuration);
52684
- return { frame, finished: false };
52685
- }
52686
- function resolveFrame(sheetUrls, frameDims, animState) {
52687
- if (!sheetUrls) return null;
52688
- const { sheetDir, flipX } = resolveSheetDirection(animState.direction);
52689
- const sheetUrl = sheetUrls[sheetDir];
52690
- if (!sheetUrl) return null;
52691
- const def = SPRITE_SHEET_LAYOUT[animState.animation];
52692
- const { frame } = getCurrentFrame(animState.animation, animState.elapsed);
52693
- return {
52694
- sheetUrl,
52695
- sx: frame * frameDims.width,
52696
- sy: def.row * frameDims.height,
52697
- sw: frameDims.width,
52698
- sh: frameDims.height,
52699
- flipX
52700
- };
52701
- }
52702
- function createUnitAnimationState(unitId) {
52703
- return {
52704
- unitId,
52705
- animation: "idle",
52706
- direction: "se",
52707
- frame: 0,
52708
- elapsed: 0,
52709
- queuedAnimation: null,
52710
- finished: false
52711
- };
52712
- }
52713
- function transitionAnimation(state, newAnim, direction) {
52714
- if (state.animation === "death" && state.finished) return state;
52715
- if (state.animation === newAnim && SPRITE_SHEET_LAYOUT[newAnim].loop) {
52716
- return direction ? { ...state, direction } : state;
52717
- }
52718
- return {
52719
- ...state,
52720
- animation: newAnim,
52721
- direction: direction ?? state.direction,
52722
- frame: 0,
52723
- elapsed: 0,
52724
- queuedAnimation: null,
52725
- finished: false
52726
- };
52727
- }
52728
- function tickAnimationState(state, deltaMs) {
52729
- const newElapsed = state.elapsed + deltaMs;
52730
- const { frame, finished } = getCurrentFrame(state.animation, newElapsed);
52731
- const def = SPRITE_SHEET_LAYOUT[state.animation];
52732
- if (finished && !def.loop && !state.finished) {
52733
- if (state.animation === "death") {
52734
- return { ...state, elapsed: newElapsed, frame, finished: true };
52735
- }
52736
- const nextAnim = state.queuedAnimation ?? "idle";
52737
- return {
52738
- ...state,
52739
- animation: nextAnim,
52740
- elapsed: 0,
52741
- frame: 0,
52742
- queuedAnimation: null,
52743
- finished: false
52744
- };
52745
- }
52746
- return { ...state, elapsed: newElapsed, frame, finished };
52747
- }
52748
-
52749
52984
  // components/game/organisms/hooks/useSpriteAnimations.ts
52985
+ init_spriteAnimation();
52750
52986
  function useSpriteAnimations(getSheetUrls, getFrameDims, options = {}) {
52751
52987
  const speed = options.speed ?? 1;
52752
52988
  const animStatesRef = React76.useRef(/* @__PURE__ */ new Map());
52753
52989
  const prevPositionsRef = React76.useRef(/* @__PURE__ */ new Map());
52754
52990
  const unitDataRef = React76.useRef(/* @__PURE__ */ new Map());
52755
52991
  const walkHoldRef = React76.useRef(/* @__PURE__ */ new Map());
52756
- const WALK_HOLD_MS = 600;
52992
+ const WALK_HOLD_MS2 = 600;
52757
52993
  const syncUnits = React76.useCallback((units, deltaMs) => {
52758
52994
  const scaledDelta = deltaMs * speed;
52759
52995
  const states = animStatesRef.current;
@@ -52779,7 +53015,7 @@ function useSpriteAnimations(getSheetUrls, getFrameDims, options = {}) {
52779
53015
  const dir = inferDirection(dx, dy);
52780
53016
  if (state.animation !== "attack" && state.animation !== "hit" && state.animation !== "death") {
52781
53017
  state = transitionAnimation(state, "walk", dir);
52782
- walkHold.set(unit.id, WALK_HOLD_MS);
53018
+ walkHold.set(unit.id, WALK_HOLD_MS2);
52783
53019
  }
52784
53020
  } else if (state.animation === "walk") {
52785
53021
  const remaining = (walkHold.get(unit.id) ?? 0) - scaledDelta;
@@ -53112,6 +53348,8 @@ function usePhysics2D(options = {}) {
53112
53348
 
53113
53349
  // components/game/organisms/index.ts
53114
53350
  init_isometric();
53351
+ init_spriteAnimation();
53352
+ init_spriteSheetConstants();
53115
53353
  init_BattleBoard();
53116
53354
  init_UncontrolledBattleBoard();
53117
53355
  init_useBattleState();
@@ -53776,8 +54014,6 @@ exports.RoguelikeBoard = RoguelikeBoard;
53776
54014
  exports.RoguelikeTemplate = RoguelikeTemplate;
53777
54015
  exports.RuleEditor = RuleEditor;
53778
54016
  exports.RuntimeDebugger = RuntimeDebugger;
53779
- exports.SHEET_COLUMNS = SHEET_COLUMNS;
53780
- exports.SPRITE_SHEET_LAYOUT = SPRITE_SHEET_LAYOUT;
53781
54017
  exports.ScoreBoard = ScoreBoard;
53782
54018
  exports.ScoreDisplay = ScoreDisplay;
53783
54019
  exports.SequenceBar = SequenceBar;