@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.
@@ -42,12 +42,12 @@ import { DndContext, useSensors, useSensor, PointerSensor, KeyboardSensor, useDr
42
42
  import { useSortable, arrayMove, sortableKeyboardCoordinates, SortableContext, rectSortingStrategy, verticalListSortingStrategy } from '@dnd-kit/sortable';
43
43
  import { CSS } from '@dnd-kit/utilities';
44
44
  import { useNodeId, ReactFlowProvider, Handle, Position } from '@xyflow/react';
45
- import * as THREE from 'three';
45
+ import * as THREE3 from 'three';
46
46
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
47
47
  import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
48
48
  import { GLTFLoader as GLTFLoader$1 } from 'three/examples/jsm/loaders/GLTFLoader';
49
- import { Canvas, useThree } from '@react-three/fiber';
50
- import { Grid as Grid$1, OrbitControls } from '@react-three/drei';
49
+ import { Canvas, useLoader, useFrame, useThree } from '@react-three/fiber';
50
+ import { Billboard, Grid as Grid$1, OrbitControls } from '@react-three/drei';
51
51
  import { getPatternDefinition as getPatternDefinition$1, getComponentForPattern as getComponentForPattern$1 } from '@almadar/patterns';
52
52
 
53
53
  var __defProp = Object.defineProperty;
@@ -9373,6 +9373,291 @@ var init_useCamera = __esm({
9373
9373
  }
9374
9374
  });
9375
9375
 
9376
+ // components/game/organisms/utils/spriteSheetConstants.ts
9377
+ var SHEET_COLUMNS, SPRITE_SHEET_LAYOUT;
9378
+ var init_spriteSheetConstants = __esm({
9379
+ "components/game/organisms/utils/spriteSheetConstants.ts"() {
9380
+ SHEET_COLUMNS = 8;
9381
+ SPRITE_SHEET_LAYOUT = {
9382
+ idle: { row: 0, frames: 4, frameRate: 6, loop: true },
9383
+ walk: { row: 1, frames: 8, frameRate: 10, loop: true },
9384
+ attack: { row: 2, frames: 6, frameRate: 12, loop: false },
9385
+ hit: { row: 3, frames: 3, frameRate: 8, loop: false },
9386
+ death: { row: 4, frames: 6, frameRate: 8, loop: false }
9387
+ };
9388
+ }
9389
+ });
9390
+
9391
+ // components/game/organisms/utils/spriteAnimation.ts
9392
+ function inferDirection(dx, dy) {
9393
+ if (dx === 0 && dy === 0) return "se";
9394
+ if (dx >= 0 && dy >= 0) return "se";
9395
+ if (dx <= 0 && dy >= 0) return "sw";
9396
+ if (dx >= 0 && dy <= 0) return "ne";
9397
+ return "nw";
9398
+ }
9399
+ function resolveSheetDirection(facing) {
9400
+ switch (facing) {
9401
+ case "se":
9402
+ return { sheetDir: "se", flipX: false };
9403
+ case "sw":
9404
+ return { sheetDir: "sw", flipX: false };
9405
+ case "ne":
9406
+ return { sheetDir: "sw", flipX: true };
9407
+ case "nw":
9408
+ return { sheetDir: "se", flipX: true };
9409
+ }
9410
+ }
9411
+ function frameRect(frame, row, columns, frameWidth, frameHeight) {
9412
+ return {
9413
+ sx: frame % columns * frameWidth,
9414
+ sy: row * frameHeight,
9415
+ sw: frameWidth,
9416
+ sh: frameHeight
9417
+ };
9418
+ }
9419
+ function getCurrentFrameFromDef(def, elapsed) {
9420
+ const frameDuration = 1e3 / def.frameRate;
9421
+ const totalDuration = def.frames * frameDuration;
9422
+ if (def.loop) {
9423
+ const frame2 = Math.floor(elapsed % totalDuration / frameDuration);
9424
+ return { frame: frame2, finished: false };
9425
+ }
9426
+ if (elapsed >= totalDuration) {
9427
+ return { frame: def.frames - 1, finished: true };
9428
+ }
9429
+ const frame = Math.floor(elapsed / frameDuration);
9430
+ return { frame, finished: false };
9431
+ }
9432
+ function getCurrentFrame(animName, elapsed) {
9433
+ return getCurrentFrameFromDef(SPRITE_SHEET_LAYOUT[animName], elapsed);
9434
+ }
9435
+ function resolveFrame(sheetUrls, frameDims, animState) {
9436
+ if (!sheetUrls) return null;
9437
+ const { sheetDir, flipX } = resolveSheetDirection(animState.direction);
9438
+ const sheetUrl = sheetUrls[sheetDir];
9439
+ if (!sheetUrl) return null;
9440
+ const def = SPRITE_SHEET_LAYOUT[animState.animation];
9441
+ const { frame } = getCurrentFrame(animState.animation, animState.elapsed);
9442
+ const rect = frameRect(frame, def.row, def.frames, frameDims.width, frameDims.height);
9443
+ return {
9444
+ sheetUrl,
9445
+ sx: rect.sx,
9446
+ sy: rect.sy,
9447
+ sw: rect.sw,
9448
+ sh: rect.sh,
9449
+ flipX
9450
+ };
9451
+ }
9452
+ function createUnitAnimationState(unitId) {
9453
+ return {
9454
+ unitId,
9455
+ animation: "idle",
9456
+ direction: "se",
9457
+ frame: 0,
9458
+ elapsed: 0,
9459
+ queuedAnimation: null,
9460
+ finished: false
9461
+ };
9462
+ }
9463
+ function transitionAnimation(state, newAnim, direction) {
9464
+ if (state.animation === "death" && state.finished) return state;
9465
+ if (state.animation === newAnim && SPRITE_SHEET_LAYOUT[newAnim].loop) {
9466
+ return direction ? { ...state, direction } : state;
9467
+ }
9468
+ return {
9469
+ ...state,
9470
+ animation: newAnim,
9471
+ direction: direction ?? state.direction,
9472
+ frame: 0,
9473
+ elapsed: 0,
9474
+ queuedAnimation: null,
9475
+ finished: false
9476
+ };
9477
+ }
9478
+ function tickAnimationState(state, deltaMs) {
9479
+ const newElapsed = state.elapsed + deltaMs;
9480
+ const { frame, finished } = getCurrentFrame(state.animation, newElapsed);
9481
+ const def = SPRITE_SHEET_LAYOUT[state.animation];
9482
+ if (finished && !def.loop && !state.finished) {
9483
+ if (state.animation === "death") {
9484
+ return { ...state, elapsed: newElapsed, frame, finished: true };
9485
+ }
9486
+ const nextAnim = state.queuedAnimation ?? "idle";
9487
+ return {
9488
+ ...state,
9489
+ animation: nextAnim,
9490
+ elapsed: 0,
9491
+ frame: 0,
9492
+ queuedAnimation: null,
9493
+ finished: false
9494
+ };
9495
+ }
9496
+ return { ...state, elapsed: newElapsed, frame, finished };
9497
+ }
9498
+ var init_spriteAnimation = __esm({
9499
+ "components/game/organisms/utils/spriteAnimation.ts"() {
9500
+ init_spriteSheetConstants();
9501
+ }
9502
+ });
9503
+ function unitAtlasUrl(unit) {
9504
+ if (unit.spriteSheet) return unit.spriteSheet;
9505
+ const sprite = unit.sprite;
9506
+ if (!sprite) return null;
9507
+ const match = /^(.*-sprite-sheet)(?:-(?:se|sw))?(?:-v\d+)?\.png$/.exec(sprite);
9508
+ if (!match) return null;
9509
+ return `${match[1]}.json`;
9510
+ }
9511
+ function resolveSheetUrl(atlasUrl, relativeSheetPath) {
9512
+ try {
9513
+ return new URL(relativeSheetPath, atlasUrl).toString();
9514
+ } catch {
9515
+ const base = atlasUrl.slice(0, atlasUrl.lastIndexOf("/") + 1);
9516
+ return `${base}${relativeSheetPath.replace(/^\.\//, "")}`;
9517
+ }
9518
+ }
9519
+ function useUnitSpriteAtlas(units) {
9520
+ const atlasCacheRef = useRef(/* @__PURE__ */ new Map());
9521
+ const loadingRef = useRef(/* @__PURE__ */ new Set());
9522
+ const [pendingCount, setPendingCount] = useState(0);
9523
+ const [, forceTick] = useState(0);
9524
+ const animStatesRef = useRef(/* @__PURE__ */ new Map());
9525
+ const lastTickRef = useRef(0);
9526
+ const rafRef = useRef(0);
9527
+ const atlasUrls = useMemo(() => {
9528
+ const set = /* @__PURE__ */ new Set();
9529
+ for (const unit of units) {
9530
+ const url = unitAtlasUrl(unit);
9531
+ if (url) set.add(url);
9532
+ }
9533
+ return [...set];
9534
+ }, [units]);
9535
+ useEffect(() => {
9536
+ const cache = atlasCacheRef.current;
9537
+ const loading = loadingRef.current;
9538
+ const toLoad = atlasUrls.filter((url) => !cache.has(url) && !loading.has(url));
9539
+ if (toLoad.length === 0) return;
9540
+ let cancelled = false;
9541
+ setPendingCount((prev) => prev + toLoad.length);
9542
+ for (const url of toLoad) {
9543
+ loading.add(url);
9544
+ fetch(url).then((res) => res.ok ? res.json() : Promise.reject(new Error(String(res.status)))).then((atlas) => {
9545
+ if (cancelled) return;
9546
+ cache.set(url, atlas);
9547
+ }).catch(() => {
9548
+ }).finally(() => {
9549
+ if (cancelled) return;
9550
+ loading.delete(url);
9551
+ setPendingCount((prev) => Math.max(0, prev - 1));
9552
+ forceTick((n) => n + 1);
9553
+ });
9554
+ }
9555
+ return () => {
9556
+ cancelled = true;
9557
+ };
9558
+ }, [atlasUrls]);
9559
+ const sheetUrls = useMemo(() => {
9560
+ const urls = /* @__PURE__ */ new Set();
9561
+ for (const unit of units) {
9562
+ const atlasUrl = unitAtlasUrl(unit);
9563
+ if (!atlasUrl) continue;
9564
+ const atlas = atlasCacheRef.current.get(atlasUrl);
9565
+ if (!atlas) continue;
9566
+ for (const rel of Object.values(atlas.sheets)) {
9567
+ if (rel) urls.add(resolveSheetUrl(atlasUrl, rel));
9568
+ }
9569
+ }
9570
+ return [...urls];
9571
+ }, [units, pendingCount]);
9572
+ useEffect(() => {
9573
+ const hasAtlasUnits = units.some((u) => unitAtlasUrl(u) !== null);
9574
+ if (!hasAtlasUnits) return;
9575
+ let running = true;
9576
+ const tick = (ts) => {
9577
+ if (!running) return;
9578
+ const last = lastTickRef.current || ts;
9579
+ const delta = ts - last;
9580
+ lastTickRef.current = ts;
9581
+ const states = animStatesRef.current;
9582
+ const currentIds = /* @__PURE__ */ new Set();
9583
+ for (const unit of units) {
9584
+ if (unitAtlasUrl(unit) === null) continue;
9585
+ currentIds.add(unit.id);
9586
+ let state = states.get(unit.id);
9587
+ if (!state) {
9588
+ state = { animation: "idle", direction: "se", elapsed: 0, walkHold: 0, prev: null };
9589
+ states.set(unit.id, state);
9590
+ }
9591
+ const posX = unit.position?.x ?? unit.x ?? 0;
9592
+ const posY = unit.position?.y ?? unit.y ?? 0;
9593
+ if (state.prev) {
9594
+ const dx = posX - state.prev.x;
9595
+ const dy = posY - state.prev.y;
9596
+ if (dx !== 0 || dy !== 0) {
9597
+ state.animation = "walk";
9598
+ state.direction = inferDirection(dx, dy);
9599
+ state.walkHold = WALK_HOLD_MS;
9600
+ } else if (state.animation === "walk") {
9601
+ state.walkHold -= delta;
9602
+ if (state.walkHold <= 0) state.animation = "idle";
9603
+ }
9604
+ }
9605
+ state.prev = { x: posX, y: posY };
9606
+ state.elapsed += delta;
9607
+ }
9608
+ for (const id of states.keys()) {
9609
+ if (!currentIds.has(id)) states.delete(id);
9610
+ }
9611
+ rafRef.current = requestAnimationFrame(tick);
9612
+ };
9613
+ rafRef.current = requestAnimationFrame(tick);
9614
+ return () => {
9615
+ running = false;
9616
+ cancelAnimationFrame(rafRef.current);
9617
+ lastTickRef.current = 0;
9618
+ };
9619
+ }, [units]);
9620
+ const resolveUnitFrame = useCallback((unitId) => {
9621
+ const unit = units.find((u) => u.id === unitId);
9622
+ if (!unit) return null;
9623
+ const atlasUrl = unitAtlasUrl(unit);
9624
+ if (!atlasUrl) return null;
9625
+ const atlas = atlasCacheRef.current.get(atlasUrl);
9626
+ if (!atlas) return null;
9627
+ const state = animStatesRef.current.get(unitId);
9628
+ const animation = state?.animation ?? "idle";
9629
+ const direction = state?.direction ?? "se";
9630
+ const elapsed = state?.elapsed ?? 0;
9631
+ const def = atlas.animations[animation] ?? atlas.animations.idle;
9632
+ if (!def) return null;
9633
+ const { sheetDir, flipX } = resolveSheetDirection(direction);
9634
+ const rel = atlas.sheets[sheetDir] ?? atlas.sheets.se ?? atlas.sheets.sw;
9635
+ if (!rel) return null;
9636
+ const sheetUrl = resolveSheetUrl(atlasUrl, rel);
9637
+ const isIdle = animation === "idle";
9638
+ const frame = isIdle ? 0 : getCurrentFrameFromDef(def, elapsed).frame;
9639
+ const rect = frameRect(frame, def.row, atlas.columns, atlas.frameWidth, atlas.frameHeight);
9640
+ return {
9641
+ sheetUrl,
9642
+ sx: rect.sx,
9643
+ sy: rect.sy,
9644
+ sw: rect.sw,
9645
+ sh: rect.sh,
9646
+ flipX,
9647
+ applyBreathing: isIdle
9648
+ };
9649
+ }, [units]);
9650
+ return { sheetUrls, resolveUnitFrame, pendingCount };
9651
+ }
9652
+ var WALK_HOLD_MS;
9653
+ var init_useUnitSpriteAtlas = __esm({
9654
+ "components/game/molecules/useUnitSpriteAtlas.ts"() {
9655
+ "use client";
9656
+ init_spriteAnimation();
9657
+ WALK_HOLD_MS = 600;
9658
+ }
9659
+ });
9660
+
9376
9661
  // components/game/organisms/utils/isometric.ts
9377
9662
  function isoToScreen(tileX, tileY, scale, baseOffsetX) {
9378
9663
  const scaledTileWidth = TILE_WIDTH * scale;
@@ -9486,6 +9771,10 @@ function IsometricCanvas({
9486
9771
  () => unitsProp.map((u) => u.position ? u : { ...u, position: { x: u.x ?? 0, y: u.y ?? 0 } }),
9487
9772
  [unitsProp]
9488
9773
  );
9774
+ const { sheetUrls: atlasSheetUrls, resolveUnitFrame: resolveUnitFrameInternal, pendingCount: atlasPending } = useUnitSpriteAtlas(units);
9775
+ const resolveFrameForUnit = useCallback((unitId) => {
9776
+ return resolveUnitFrame?.(unitId) ?? resolveUnitFrameInternal(unitId);
9777
+ }, [resolveUnitFrame, resolveUnitFrameInternal]);
9489
9778
  const features = useMemo(
9490
9779
  () => featuresProp.map((f3) => {
9491
9780
  if (f3.type) return f3;
@@ -9569,9 +9858,10 @@ function IsometricCanvas({
9569
9858
  }
9570
9859
  }
9571
9860
  if (effectSpriteUrls) urls.push(...effectSpriteUrls);
9861
+ if (atlasSheetUrls.length) urls.push(...atlasSheetUrls);
9572
9862
  if (backgroundImage) urls.push(backgroundImage);
9573
9863
  return [...new Set(urls.filter(Boolean))];
9574
- }, [sortedTiles, features, units, getTerrainSprite, getFeatureSprite, getUnitSprite, effectSpriteUrls, backgroundImage, assetManifest, resolveManifestUrl]);
9864
+ }, [sortedTiles, features, units, getTerrainSprite, getFeatureSprite, getUnitSprite, effectSpriteUrls, atlasSheetUrls, backgroundImage, assetManifest, resolveManifestUrl]);
9575
9865
  const { getImage, pendingCount } = useImageCache(spriteUrls);
9576
9866
  useEffect(() => {
9577
9867
  if (typeof window === "undefined") return;
@@ -9859,7 +10149,7 @@ function IsometricCanvas({
9859
10149
  ctx.lineWidth = 3;
9860
10150
  ctx.stroke();
9861
10151
  }
9862
- const frame = resolveUnitFrame?.(unit.id) ?? null;
10152
+ const frame = resolveFrameForUnit(unit.id);
9863
10153
  const frameImg = frame ? getImage(frame.sheetUrl) : null;
9864
10154
  if (frame && frameImg) {
9865
10155
  const frameAr = frame.sw / frame.sh;
@@ -9969,7 +10259,7 @@ function IsometricCanvas({
9969
10259
  resolveTerrainSpriteUrl,
9970
10260
  resolveFeatureSpriteUrl,
9971
10261
  resolveUnitSpriteUrl,
9972
- resolveUnitFrame,
10262
+ resolveFrameForUnit,
9973
10263
  getImage,
9974
10264
  gridWidth,
9975
10265
  gridHeight,
@@ -10001,7 +10291,7 @@ function IsometricCanvas({
10001
10291
  };
10002
10292
  }, [selectedUnitId, units, scale, baseOffsetX, scaledTileWidth, scaledDiamondTopY, scaledFloorHeight, viewportSize, targetCameraRef]);
10003
10293
  useEffect(() => {
10004
- const hasAnimations = units.length > 0 || validMoves.length > 0 || attackTargets.length > 0 || selectedUnitId != null || targetCameraRef.current != null || hasActiveEffects2 || pendingCount > 0;
10294
+ const hasAnimations = units.length > 0 || validMoves.length > 0 || attackTargets.length > 0 || selectedUnitId != null || targetCameraRef.current != null || hasActiveEffects2 || pendingCount > 0 || atlasPending > 0;
10005
10295
  draw(animTimeRef.current);
10006
10296
  if (!hasAnimations) return;
10007
10297
  let running = true;
@@ -10017,7 +10307,7 @@ function IsometricCanvas({
10017
10307
  running = false;
10018
10308
  cancelAnimationFrame(rafIdRef.current);
10019
10309
  };
10020
- }, [draw, units.length, validMoves.length, attackTargets.length, selectedUnitId, hasActiveEffects2, pendingCount, lerpToTarget, targetCameraRef]);
10310
+ }, [draw, units.length, validMoves.length, attackTargets.length, selectedUnitId, hasActiveEffects2, pendingCount, atlasPending, lerpToTarget, targetCameraRef]);
10021
10311
  const handleMouseMoveWithCamera = useCallback((e) => {
10022
10312
  if (enableCamera) {
10023
10313
  const wasPanning = handleMouseMove(e, () => draw(animTimeRef.current));
@@ -10162,6 +10452,7 @@ var init_IsometricCanvas = __esm({
10162
10452
  init_ErrorState();
10163
10453
  init_useImageCache();
10164
10454
  init_useCamera();
10455
+ init_useUnitSpriteAtlas();
10165
10456
  init_verificationRegistry();
10166
10457
  init_isometric();
10167
10458
  IsometricCanvas.displayName = "IsometricCanvas";
@@ -29532,13 +29823,13 @@ var init_MapView = __esm({
29532
29823
  shadowSize: [41, 41]
29533
29824
  });
29534
29825
  L.Marker.prototype.options.icon = defaultIcon;
29535
- const { useEffect: useEffect79, useRef: useRef77, useCallback: useCallback119, useState: useState109 } = React76__default;
29826
+ const { useEffect: useEffect80, useRef: useRef78, useCallback: useCallback120, useState: useState110 } = React76__default;
29536
29827
  const { Typography: Typography2 } = await Promise.resolve().then(() => (init_Typography(), Typography_exports));
29537
29828
  const { useEventBus: useEventBus3 } = await Promise.resolve().then(() => (init_useEventBus(), useEventBus_exports));
29538
29829
  function MapUpdater({ centerLat, centerLng, zoom }) {
29539
29830
  const map = useMap();
29540
- const prevRef = useRef77({ centerLat, centerLng, zoom });
29541
- useEffect79(() => {
29831
+ const prevRef = useRef78({ centerLat, centerLng, zoom });
29832
+ useEffect80(() => {
29542
29833
  const prev = prevRef.current;
29543
29834
  if (prev.centerLat !== centerLat || prev.centerLng !== centerLng || prev.zoom !== zoom) {
29544
29835
  map.setView([centerLat, centerLng], zoom);
@@ -29549,7 +29840,7 @@ var init_MapView = __esm({
29549
29840
  }
29550
29841
  function MapClickHandler({ onMapClick }) {
29551
29842
  const map = useMap();
29552
- useEffect79(() => {
29843
+ useEffect80(() => {
29553
29844
  if (!onMapClick) return;
29554
29845
  const handler = (e) => {
29555
29846
  onMapClick(e.latlng.lat, e.latlng.lng);
@@ -29577,8 +29868,8 @@ var init_MapView = __esm({
29577
29868
  showAttribution = true
29578
29869
  }) {
29579
29870
  const eventBus = useEventBus3();
29580
- const [clickedPosition, setClickedPosition] = useState109(null);
29581
- const handleMapClick = useCallback119((lat, lng) => {
29871
+ const [clickedPosition, setClickedPosition] = useState110(null);
29872
+ const handleMapClick = useCallback120((lat, lng) => {
29582
29873
  if (showClickedPin) {
29583
29874
  setClickedPosition({ lat, lng });
29584
29875
  }
@@ -29587,7 +29878,7 @@ var init_MapView = __esm({
29587
29878
  eventBus.emit(`UI:${mapClickEvent}`, { latitude: lat, longitude: lng });
29588
29879
  }
29589
29880
  }, [onMapClick, mapClickEvent, eventBus, showClickedPin]);
29590
- const handleMarkerClick = useCallback119((marker) => {
29881
+ const handleMarkerClick = useCallback120((marker) => {
29591
29882
  onMarkerClick?.(marker);
29592
29883
  if (markerClickEvent) {
29593
29884
  eventBus.emit(`UI:${markerClickEvent}`, { ...marker });
@@ -41632,7 +41923,7 @@ var init_AssetLoader = __esm({
41632
41923
  __publicField(this, "textureCache");
41633
41924
  __publicField(this, "loadingPromises");
41634
41925
  this.objLoader = new OBJLoader();
41635
- this.textureLoader = new THREE.TextureLoader();
41926
+ this.textureLoader = new THREE3.TextureLoader();
41636
41927
  this.modelCache = /* @__PURE__ */ new Map();
41637
41928
  this.textureCache = /* @__PURE__ */ new Map();
41638
41929
  this.loadingPromises = /* @__PURE__ */ new Map();
@@ -41706,7 +41997,7 @@ var init_AssetLoader = __esm({
41706
41997
  return this.loadingPromises.get(`texture:${url}`);
41707
41998
  }
41708
41999
  const loadPromise = this.textureLoader.loadAsync(url).then((texture) => {
41709
- texture.colorSpace = THREE.SRGBColorSpace;
42000
+ texture.colorSpace = THREE3.SRGBColorSpace;
41710
42001
  this.textureCache.set(url, texture);
41711
42002
  this.loadingPromises.delete(`texture:${url}`);
41712
42003
  return texture;
@@ -41780,7 +42071,7 @@ var init_AssetLoader = __esm({
41780
42071
  });
41781
42072
  this.modelCache.forEach((model) => {
41782
42073
  model.scene.traverse((child) => {
41783
- if (child instanceof THREE.Mesh) {
42074
+ if (child instanceof THREE3.Mesh) {
41784
42075
  child.geometry.dispose();
41785
42076
  if (Array.isArray(child.material)) {
41786
42077
  child.material.forEach((m) => m.dispose());
@@ -42336,7 +42627,7 @@ function ModelLoader({
42336
42627
  if (!loadedModel) return null;
42337
42628
  const cloned = loadedModel.clone();
42338
42629
  cloned.traverse((child) => {
42339
- if (child instanceof THREE.Mesh) {
42630
+ if (child instanceof THREE3.Mesh) {
42340
42631
  child.castShadow = castShadow;
42341
42632
  child.receiveShadow = receiveShadow;
42342
42633
  }
@@ -42450,6 +42741,47 @@ function CameraController({
42450
42741
  }, [camera.position, onCameraChange]);
42451
42742
  return null;
42452
42743
  }
42744
+ function UnitSpriteBillboard({
42745
+ sheetUrl,
42746
+ resolveFrame: resolveFrame2,
42747
+ height = 1.2
42748
+ }) {
42749
+ const texture = useLoader(THREE3.TextureLoader, sheetUrl);
42750
+ const meshRef = useRef(null);
42751
+ const matRef = useRef(null);
42752
+ const [aspect, setAspect] = useState(1);
42753
+ useFrame(() => {
42754
+ const frame = resolveFrame2();
42755
+ if (!frame || !texture.image) return;
42756
+ const imgW = texture.image.width;
42757
+ const imgH = texture.image.height;
42758
+ if (!imgW || !imgH) return;
42759
+ texture.repeat.set((frame.flipX ? -1 : 1) * (frame.sw / imgW), frame.sh / imgH);
42760
+ texture.offset.set(
42761
+ frame.flipX ? (frame.sx + frame.sw) / imgW : frame.sx / imgW,
42762
+ 1 - (frame.sy + frame.sh) / imgH
42763
+ );
42764
+ texture.magFilter = THREE3.NearestFilter;
42765
+ texture.minFilter = THREE3.NearestFilter;
42766
+ texture.needsUpdate = true;
42767
+ const nextAspect = frame.sw / frame.sh;
42768
+ if (Math.abs(nextAspect - aspect) > 1e-3) setAspect(nextAspect);
42769
+ if (matRef.current) matRef.current.needsUpdate = true;
42770
+ });
42771
+ return /* @__PURE__ */ jsxs("mesh", { ref: meshRef, position: [0, height / 2, 0], children: [
42772
+ /* @__PURE__ */ jsx("planeGeometry", { args: [height * aspect, height] }),
42773
+ /* @__PURE__ */ jsx(
42774
+ "meshBasicMaterial",
42775
+ {
42776
+ ref: matRef,
42777
+ map: texture,
42778
+ transparent: true,
42779
+ alphaTest: 0.1,
42780
+ side: THREE3.DoubleSide
42781
+ }
42782
+ )
42783
+ ] });
42784
+ }
42453
42785
  var DEFAULT_GRID_CONFIG, GameCanvas3D;
42454
42786
  var init_GameCanvas3D2 = __esm({
42455
42787
  "components/game/molecules/GameCanvas3D.tsx"() {
@@ -42460,6 +42792,7 @@ var init_GameCanvas3D2 = __esm({
42460
42792
  init_Canvas3DLoadingState2();
42461
42793
  init_Canvas3DErrorBoundary2();
42462
42794
  init_ModelLoader();
42795
+ init_useUnitSpriteAtlas();
42463
42796
  init_cn();
42464
42797
  init_GameCanvas3D();
42465
42798
  DEFAULT_GRID_CONFIG = {
@@ -42516,8 +42849,10 @@ var init_GameCanvas3D2 = __esm({
42516
42849
  const controlsRef = useRef(null);
42517
42850
  const [hoveredTile, setHoveredTile] = useState(null);
42518
42851
  const [internalError, setInternalError] = useState(null);
42852
+ const { sheetUrls: atlasSheetUrls, resolveUnitFrame } = useUnitSpriteAtlas(units);
42853
+ const preloadUrls = useMemo(() => [...preloadAssets, ...atlasSheetUrls], [preloadAssets, atlasSheetUrls]);
42519
42854
  const { isLoading: assetsLoading, progress, loaded, total } = useAssetLoader({
42520
- preloadUrls: preloadAssets,
42855
+ preloadUrls,
42521
42856
  loader: customAssetLoader
42522
42857
  });
42523
42858
  const eventHandlers = useGameCanvas3DEvents({
@@ -42576,7 +42911,7 @@ var init_GameCanvas3D2 = __esm({
42576
42911
  getCameraPosition: () => {
42577
42912
  if (controlsRef.current) {
42578
42913
  const pos = controlsRef.current.object.position;
42579
- return new THREE.Vector3(pos.x, pos.y, pos.z);
42914
+ return new THREE3.Vector3(pos.x, pos.y, pos.z);
42580
42915
  }
42581
42916
  return null;
42582
42917
  },
@@ -42728,6 +43063,8 @@ var init_GameCanvas3D2 = __esm({
42728
43063
  ({ unit, position }) => {
42729
43064
  const isSelected = selectedUnitId === unit.id;
42730
43065
  const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
43066
+ const hasAtlas = unitAtlasUrl(unit) !== null;
43067
+ const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
42731
43068
  return /* @__PURE__ */ jsxs(
42732
43069
  "group",
42733
43070
  {
@@ -42739,7 +43076,16 @@ var init_GameCanvas3D2 = __esm({
42739
43076
  /* @__PURE__ */ jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
42740
43077
  /* @__PURE__ */ jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
42741
43078
  ] }),
42742
- unit.modelUrl ? (
43079
+ hasAtlas && initialFrame ? (
43080
+ /* Animated sprite-sheet billboard — single cropped frame, by state */
43081
+ /* @__PURE__ */ jsx(Billboard, { children: /* @__PURE__ */ jsx(
43082
+ UnitSpriteBillboard,
43083
+ {
43084
+ sheetUrl: initialFrame.sheetUrl,
43085
+ resolveFrame: () => resolveUnitFrame(unit.id)
43086
+ }
43087
+ ) })
43088
+ ) : unit.modelUrl ? (
42743
43089
  /* GLB unit model (box fallback while loading / on error) */
42744
43090
  /* @__PURE__ */ jsx(
42745
43091
  ModelLoader,
@@ -42793,7 +43139,7 @@ var init_GameCanvas3D2 = __esm({
42793
43139
  }
42794
43140
  );
42795
43141
  },
42796
- [selectedUnitId, handleUnitClick]
43142
+ [selectedUnitId, handleUnitClick, resolveUnitFrame]
42797
43143
  );
42798
43144
  const DefaultFeatureRenderer = useCallback(
42799
43145
  ({
@@ -48497,12 +48843,8 @@ function Sprite({
48497
48843
  }) {
48498
48844
  const eventBus = useEventBus();
48499
48845
  const sourcePosition = useMemo(() => {
48500
- const frameX = frame % columns;
48501
- const frameY = Math.floor(frame / columns);
48502
- return {
48503
- x: frameX * frameWidth,
48504
- y: frameY * frameHeight
48505
- };
48846
+ const { sx, sy } = frameRect(frame, Math.floor(frame / columns), columns, frameWidth, frameHeight);
48847
+ return { x: sx, y: sy };
48506
48848
  }, [frame, columns, frameWidth, frameHeight]);
48507
48849
  const transform = useMemo(() => {
48508
48850
  const transforms = [
@@ -48560,8 +48902,7 @@ function drawSprite(ctx, image, props) {
48560
48902
  opacity = 1,
48561
48903
  columns = 16
48562
48904
  } = props;
48563
- const sourceX = frame % columns * frameWidth;
48564
- const sourceY = Math.floor(frame / columns) * frameHeight;
48905
+ const { sx: sourceX, sy: sourceY } = frameRect(frame, Math.floor(frame / columns), columns, frameWidth, frameHeight);
48565
48906
  ctx.save();
48566
48907
  ctx.globalAlpha = opacity;
48567
48908
  ctx.translate(x + frameWidth / 2, y + frameHeight / 2);
@@ -48586,6 +48927,7 @@ var init_Sprite = __esm({
48586
48927
  "components/game/atoms/Sprite.tsx"() {
48587
48928
  "use client";
48588
48929
  init_useEventBus();
48930
+ init_spriteAnimation();
48589
48931
  }
48590
48932
  });
48591
48933
  var StatCard;
@@ -52593,121 +52935,15 @@ init_useGameAudio();
52593
52935
  init_useImageCache();
52594
52936
  init_useCamera();
52595
52937
 
52596
- // components/game/organisms/utils/spriteSheetConstants.ts
52597
- var SHEET_COLUMNS = 8;
52598
- var SPRITE_SHEET_LAYOUT = {
52599
- idle: { row: 0, frames: 4, frameRate: 6, loop: true },
52600
- walk: { row: 1, frames: 8, frameRate: 10, loop: true },
52601
- attack: { row: 2, frames: 6, frameRate: 12, loop: false },
52602
- hit: { row: 3, frames: 3, frameRate: 8, loop: false },
52603
- death: { row: 4, frames: 6, frameRate: 8, loop: false }
52604
- };
52605
-
52606
- // components/game/organisms/utils/spriteAnimation.ts
52607
- function inferDirection(dx, dy) {
52608
- if (dx === 0 && dy === 0) return "se";
52609
- if (dx >= 0 && dy >= 0) return "se";
52610
- if (dx <= 0 && dy >= 0) return "sw";
52611
- if (dx >= 0 && dy <= 0) return "ne";
52612
- return "nw";
52613
- }
52614
- function resolveSheetDirection(facing) {
52615
- switch (facing) {
52616
- case "se":
52617
- return { sheetDir: "se", flipX: false };
52618
- case "sw":
52619
- return { sheetDir: "sw", flipX: false };
52620
- case "ne":
52621
- return { sheetDir: "sw", flipX: true };
52622
- case "nw":
52623
- return { sheetDir: "se", flipX: true };
52624
- }
52625
- }
52626
- function getCurrentFrame(animName, elapsed) {
52627
- const def = SPRITE_SHEET_LAYOUT[animName];
52628
- const frameDuration = 1e3 / def.frameRate;
52629
- const totalDuration = def.frames * frameDuration;
52630
- if (def.loop) {
52631
- const frame2 = Math.floor(elapsed % totalDuration / frameDuration);
52632
- return { frame: frame2, finished: false };
52633
- }
52634
- if (elapsed >= totalDuration) {
52635
- return { frame: def.frames - 1, finished: true };
52636
- }
52637
- const frame = Math.floor(elapsed / frameDuration);
52638
- return { frame, finished: false };
52639
- }
52640
- function resolveFrame(sheetUrls, frameDims, animState) {
52641
- if (!sheetUrls) return null;
52642
- const { sheetDir, flipX } = resolveSheetDirection(animState.direction);
52643
- const sheetUrl = sheetUrls[sheetDir];
52644
- if (!sheetUrl) return null;
52645
- const def = SPRITE_SHEET_LAYOUT[animState.animation];
52646
- const { frame } = getCurrentFrame(animState.animation, animState.elapsed);
52647
- return {
52648
- sheetUrl,
52649
- sx: frame * frameDims.width,
52650
- sy: def.row * frameDims.height,
52651
- sw: frameDims.width,
52652
- sh: frameDims.height,
52653
- flipX
52654
- };
52655
- }
52656
- function createUnitAnimationState(unitId) {
52657
- return {
52658
- unitId,
52659
- animation: "idle",
52660
- direction: "se",
52661
- frame: 0,
52662
- elapsed: 0,
52663
- queuedAnimation: null,
52664
- finished: false
52665
- };
52666
- }
52667
- function transitionAnimation(state, newAnim, direction) {
52668
- if (state.animation === "death" && state.finished) return state;
52669
- if (state.animation === newAnim && SPRITE_SHEET_LAYOUT[newAnim].loop) {
52670
- return direction ? { ...state, direction } : state;
52671
- }
52672
- return {
52673
- ...state,
52674
- animation: newAnim,
52675
- direction: direction ?? state.direction,
52676
- frame: 0,
52677
- elapsed: 0,
52678
- queuedAnimation: null,
52679
- finished: false
52680
- };
52681
- }
52682
- function tickAnimationState(state, deltaMs) {
52683
- const newElapsed = state.elapsed + deltaMs;
52684
- const { frame, finished } = getCurrentFrame(state.animation, newElapsed);
52685
- const def = SPRITE_SHEET_LAYOUT[state.animation];
52686
- if (finished && !def.loop && !state.finished) {
52687
- if (state.animation === "death") {
52688
- return { ...state, elapsed: newElapsed, frame, finished: true };
52689
- }
52690
- const nextAnim = state.queuedAnimation ?? "idle";
52691
- return {
52692
- ...state,
52693
- animation: nextAnim,
52694
- elapsed: 0,
52695
- frame: 0,
52696
- queuedAnimation: null,
52697
- finished: false
52698
- };
52699
- }
52700
- return { ...state, elapsed: newElapsed, frame, finished };
52701
- }
52702
-
52703
52938
  // components/game/organisms/hooks/useSpriteAnimations.ts
52939
+ init_spriteAnimation();
52704
52940
  function useSpriteAnimations(getSheetUrls, getFrameDims, options = {}) {
52705
52941
  const speed = options.speed ?? 1;
52706
52942
  const animStatesRef = useRef(/* @__PURE__ */ new Map());
52707
52943
  const prevPositionsRef = useRef(/* @__PURE__ */ new Map());
52708
52944
  const unitDataRef = useRef(/* @__PURE__ */ new Map());
52709
52945
  const walkHoldRef = useRef(/* @__PURE__ */ new Map());
52710
- const WALK_HOLD_MS = 600;
52946
+ const WALK_HOLD_MS2 = 600;
52711
52947
  const syncUnits = useCallback((units, deltaMs) => {
52712
52948
  const scaledDelta = deltaMs * speed;
52713
52949
  const states = animStatesRef.current;
@@ -52733,7 +52969,7 @@ function useSpriteAnimations(getSheetUrls, getFrameDims, options = {}) {
52733
52969
  const dir = inferDirection(dx, dy);
52734
52970
  if (state.animation !== "attack" && state.animation !== "hit" && state.animation !== "death") {
52735
52971
  state = transitionAnimation(state, "walk", dir);
52736
- walkHold.set(unit.id, WALK_HOLD_MS);
52972
+ walkHold.set(unit.id, WALK_HOLD_MS2);
52737
52973
  }
52738
52974
  } else if (state.animation === "walk") {
52739
52975
  const remaining = (walkHold.get(unit.id) ?? 0) - scaledDelta;
@@ -53066,6 +53302,8 @@ function usePhysics2D(options = {}) {
53066
53302
 
53067
53303
  // components/game/organisms/index.ts
53068
53304
  init_isometric();
53305
+ init_spriteAnimation();
53306
+ init_spriteSheetConstants();
53069
53307
  init_BattleBoard();
53070
53308
  init_UncontrolledBattleBoard();
53071
53309
  init_useBattleState();