@almadar/ui 5.49.1 → 5.50.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,14 +41,14 @@ 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
- import { StateMachineManager, createContextFromBindings, interpolateValue, collectDeclaredConfigDefaults, createServerEffectHandlers, EffectExecutor, InMemoryPersistence } from '@almadar/runtime';
51
+ import { StateMachineManager, collectDeclaredConfigDefaults, createServerEffectHandlers, EffectExecutor, interpolateValue, createContextFromBindings, InMemoryPersistence } from '@almadar/runtime';
52
52
  import { OrbitalServerRuntime } from '@almadar/runtime/OrbitalServerRuntime';
53
53
 
54
54
  var __defProp = Object.defineProperty;
@@ -7053,6 +7053,52 @@ var init_ControlButton = __esm({
7053
7053
  ControlButton.displayName = "ControlButton";
7054
7054
  }
7055
7055
  });
7056
+
7057
+ // components/game/organisms/utils/spriteAnimation.ts
7058
+ function inferDirection(dx, dy) {
7059
+ if (dx === 0 && dy === 0) return "se";
7060
+ if (dx >= 0 && dy >= 0) return "se";
7061
+ if (dx <= 0 && dy >= 0) return "sw";
7062
+ if (dx >= 0 && dy <= 0) return "ne";
7063
+ return "nw";
7064
+ }
7065
+ function resolveSheetDirection(facing) {
7066
+ switch (facing) {
7067
+ case "se":
7068
+ return { sheetDir: "se", flipX: false };
7069
+ case "sw":
7070
+ return { sheetDir: "sw", flipX: false };
7071
+ case "ne":
7072
+ return { sheetDir: "sw", flipX: true };
7073
+ case "nw":
7074
+ return { sheetDir: "se", flipX: true };
7075
+ }
7076
+ }
7077
+ function frameRect(frame, row, columns, frameWidth, frameHeight) {
7078
+ return {
7079
+ sx: frame % columns * frameWidth,
7080
+ sy: row * frameHeight,
7081
+ sw: frameWidth,
7082
+ sh: frameHeight
7083
+ };
7084
+ }
7085
+ function getCurrentFrameFromDef(def, elapsed) {
7086
+ const frameDuration = 1e3 / def.frameRate;
7087
+ const totalDuration = def.frames * frameDuration;
7088
+ if (def.loop) {
7089
+ const frame2 = Math.floor(elapsed % totalDuration / frameDuration);
7090
+ return { frame: frame2, finished: false };
7091
+ }
7092
+ if (elapsed >= totalDuration) {
7093
+ return { frame: def.frames - 1, finished: true };
7094
+ }
7095
+ const frame = Math.floor(elapsed / frameDuration);
7096
+ return { frame, finished: false };
7097
+ }
7098
+ var init_spriteAnimation = __esm({
7099
+ "components/game/organisms/utils/spriteAnimation.ts"() {
7100
+ }
7101
+ });
7056
7102
  function Sprite({
7057
7103
  spritesheet = "https://almadar-kflow-assets.web.app/shared/isometric-blocks/Spritesheet/allTiles_sheet.png",
7058
7104
  frameWidth = 64,
@@ -7073,12 +7119,8 @@ function Sprite({
7073
7119
  }) {
7074
7120
  const eventBus = useEventBus();
7075
7121
  const sourcePosition = useMemo(() => {
7076
- const frameX = frame % columns;
7077
- const frameY = Math.floor(frame / columns);
7078
- return {
7079
- x: frameX * frameWidth,
7080
- y: frameY * frameHeight
7081
- };
7122
+ const { sx, sy } = frameRect(frame, Math.floor(frame / columns), columns, frameWidth, frameHeight);
7123
+ return { x: sx, y: sy };
7082
7124
  }, [frame, columns, frameWidth, frameHeight]);
7083
7125
  const transform = useMemo(() => {
7084
7126
  const transforms = [
@@ -7126,6 +7168,7 @@ var init_Sprite = __esm({
7126
7168
  "components/game/atoms/Sprite.tsx"() {
7127
7169
  "use client";
7128
7170
  init_useEventBus();
7171
+ init_spriteAnimation();
7129
7172
  }
7130
7173
  });
7131
7174
  function StateIndicator({
@@ -10517,6 +10560,163 @@ var init_useCamera = __esm({
10517
10560
  "use client";
10518
10561
  }
10519
10562
  });
10563
+ function unitAtlasUrl(unit) {
10564
+ if (unit.spriteSheet) return unit.spriteSheet;
10565
+ const sprite = unit.sprite;
10566
+ if (!sprite) return null;
10567
+ const match = /^(.*-sprite-sheet)(?:-(?:se|sw))?(?:-v\d+)?\.png$/.exec(sprite);
10568
+ if (!match) return null;
10569
+ return `${match[1]}.json`;
10570
+ }
10571
+ function resolveSheetUrl(atlasUrl, relativeSheetPath) {
10572
+ try {
10573
+ return new URL(relativeSheetPath, atlasUrl).toString();
10574
+ } catch {
10575
+ const base = atlasUrl.slice(0, atlasUrl.lastIndexOf("/") + 1);
10576
+ return `${base}${relativeSheetPath.replace(/^\.\//, "")}`;
10577
+ }
10578
+ }
10579
+ function useUnitSpriteAtlas(units) {
10580
+ const atlasCacheRef = useRef(/* @__PURE__ */ new Map());
10581
+ const loadingRef = useRef(/* @__PURE__ */ new Set());
10582
+ const [pendingCount, setPendingCount] = useState(0);
10583
+ const [, forceTick] = useState(0);
10584
+ const animStatesRef = useRef(/* @__PURE__ */ new Map());
10585
+ const lastTickRef = useRef(0);
10586
+ const rafRef = useRef(0);
10587
+ const atlasUrls = useMemo(() => {
10588
+ const set = /* @__PURE__ */ new Set();
10589
+ for (const unit of units) {
10590
+ const url = unitAtlasUrl(unit);
10591
+ if (url) set.add(url);
10592
+ }
10593
+ return [...set];
10594
+ }, [units]);
10595
+ useEffect(() => {
10596
+ const cache = atlasCacheRef.current;
10597
+ const loading = loadingRef.current;
10598
+ const toLoad = atlasUrls.filter((url) => !cache.has(url) && !loading.has(url));
10599
+ if (toLoad.length === 0) return;
10600
+ let cancelled = false;
10601
+ setPendingCount((prev) => prev + toLoad.length);
10602
+ for (const url of toLoad) {
10603
+ loading.add(url);
10604
+ fetch(url).then((res) => res.ok ? res.json() : Promise.reject(new Error(String(res.status)))).then((atlas) => {
10605
+ if (cancelled) return;
10606
+ cache.set(url, atlas);
10607
+ }).catch(() => {
10608
+ }).finally(() => {
10609
+ if (cancelled) return;
10610
+ loading.delete(url);
10611
+ setPendingCount((prev) => Math.max(0, prev - 1));
10612
+ forceTick((n) => n + 1);
10613
+ });
10614
+ }
10615
+ return () => {
10616
+ cancelled = true;
10617
+ };
10618
+ }, [atlasUrls]);
10619
+ const sheetUrls = useMemo(() => {
10620
+ const urls = /* @__PURE__ */ new Set();
10621
+ for (const unit of units) {
10622
+ const atlasUrl = unitAtlasUrl(unit);
10623
+ if (!atlasUrl) continue;
10624
+ const atlas = atlasCacheRef.current.get(atlasUrl);
10625
+ if (!atlas) continue;
10626
+ for (const rel of Object.values(atlas.sheets)) {
10627
+ if (rel) urls.add(resolveSheetUrl(atlasUrl, rel));
10628
+ }
10629
+ }
10630
+ return [...urls];
10631
+ }, [units, pendingCount]);
10632
+ useEffect(() => {
10633
+ const hasAtlasUnits = units.some((u) => unitAtlasUrl(u) !== null);
10634
+ if (!hasAtlasUnits) return;
10635
+ let running = true;
10636
+ const tick = (ts) => {
10637
+ if (!running) return;
10638
+ const last = lastTickRef.current || ts;
10639
+ const delta = ts - last;
10640
+ lastTickRef.current = ts;
10641
+ const states = animStatesRef.current;
10642
+ const currentIds = /* @__PURE__ */ new Set();
10643
+ for (const unit of units) {
10644
+ if (unitAtlasUrl(unit) === null) continue;
10645
+ currentIds.add(unit.id);
10646
+ let state = states.get(unit.id);
10647
+ if (!state) {
10648
+ state = { animation: "idle", direction: "se", elapsed: 0, walkHold: 0, prev: null };
10649
+ states.set(unit.id, state);
10650
+ }
10651
+ const posX = unit.position?.x ?? unit.x ?? 0;
10652
+ const posY = unit.position?.y ?? unit.y ?? 0;
10653
+ if (state.prev) {
10654
+ const dx = posX - state.prev.x;
10655
+ const dy = posY - state.prev.y;
10656
+ if (dx !== 0 || dy !== 0) {
10657
+ state.animation = "walk";
10658
+ state.direction = inferDirection(dx, dy);
10659
+ state.walkHold = WALK_HOLD_MS;
10660
+ } else if (state.animation === "walk") {
10661
+ state.walkHold -= delta;
10662
+ if (state.walkHold <= 0) state.animation = "idle";
10663
+ }
10664
+ }
10665
+ state.prev = { x: posX, y: posY };
10666
+ state.elapsed += delta;
10667
+ }
10668
+ for (const id of states.keys()) {
10669
+ if (!currentIds.has(id)) states.delete(id);
10670
+ }
10671
+ rafRef.current = requestAnimationFrame(tick);
10672
+ };
10673
+ rafRef.current = requestAnimationFrame(tick);
10674
+ return () => {
10675
+ running = false;
10676
+ cancelAnimationFrame(rafRef.current);
10677
+ lastTickRef.current = 0;
10678
+ };
10679
+ }, [units]);
10680
+ const resolveUnitFrame = useCallback((unitId) => {
10681
+ const unit = units.find((u) => u.id === unitId);
10682
+ if (!unit) return null;
10683
+ const atlasUrl = unitAtlasUrl(unit);
10684
+ if (!atlasUrl) return null;
10685
+ const atlas = atlasCacheRef.current.get(atlasUrl);
10686
+ if (!atlas) return null;
10687
+ const state = animStatesRef.current.get(unitId);
10688
+ const animation = state?.animation ?? "idle";
10689
+ const direction = state?.direction ?? "se";
10690
+ const elapsed = state?.elapsed ?? 0;
10691
+ const def = atlas.animations[animation] ?? atlas.animations.idle;
10692
+ if (!def) return null;
10693
+ const { sheetDir, flipX } = resolveSheetDirection(direction);
10694
+ const rel = atlas.sheets[sheetDir] ?? atlas.sheets.se ?? atlas.sheets.sw;
10695
+ if (!rel) return null;
10696
+ const sheetUrl = resolveSheetUrl(atlasUrl, rel);
10697
+ const isIdle = animation === "idle";
10698
+ const frame = isIdle ? 0 : getCurrentFrameFromDef(def, elapsed).frame;
10699
+ const rect = frameRect(frame, def.row, atlas.columns, atlas.frameWidth, atlas.frameHeight);
10700
+ return {
10701
+ sheetUrl,
10702
+ sx: rect.sx,
10703
+ sy: rect.sy,
10704
+ sw: rect.sw,
10705
+ sh: rect.sh,
10706
+ flipX,
10707
+ applyBreathing: isIdle
10708
+ };
10709
+ }, [units]);
10710
+ return { sheetUrls, resolveUnitFrame, pendingCount };
10711
+ }
10712
+ var WALK_HOLD_MS;
10713
+ var init_useUnitSpriteAtlas = __esm({
10714
+ "components/game/molecules/useUnitSpriteAtlas.ts"() {
10715
+ "use client";
10716
+ init_spriteAnimation();
10717
+ WALK_HOLD_MS = 600;
10718
+ }
10719
+ });
10520
10720
 
10521
10721
  // components/game/organisms/utils/isometric.ts
10522
10722
  function isoToScreen(tileX, tileY, scale, baseOffsetX) {
@@ -10631,6 +10831,10 @@ function IsometricCanvas({
10631
10831
  () => unitsProp.map((u) => u.position ? u : { ...u, position: { x: u.x ?? 0, y: u.y ?? 0 } }),
10632
10832
  [unitsProp]
10633
10833
  );
10834
+ const { sheetUrls: atlasSheetUrls, resolveUnitFrame: resolveUnitFrameInternal, pendingCount: atlasPending } = useUnitSpriteAtlas(units);
10835
+ const resolveFrameForUnit = useCallback((unitId) => {
10836
+ return resolveUnitFrame?.(unitId) ?? resolveUnitFrameInternal(unitId);
10837
+ }, [resolveUnitFrame, resolveUnitFrameInternal]);
10634
10838
  const features = useMemo(
10635
10839
  () => featuresProp.map((f3) => {
10636
10840
  if (f3.type) return f3;
@@ -10714,9 +10918,10 @@ function IsometricCanvas({
10714
10918
  }
10715
10919
  }
10716
10920
  if (effectSpriteUrls) urls.push(...effectSpriteUrls);
10921
+ if (atlasSheetUrls.length) urls.push(...atlasSheetUrls);
10717
10922
  if (backgroundImage) urls.push(backgroundImage);
10718
10923
  return [...new Set(urls.filter(Boolean))];
10719
- }, [sortedTiles, features, units, getTerrainSprite, getFeatureSprite, getUnitSprite, effectSpriteUrls, backgroundImage, assetManifest, resolveManifestUrl]);
10924
+ }, [sortedTiles, features, units, getTerrainSprite, getFeatureSprite, getUnitSprite, effectSpriteUrls, atlasSheetUrls, backgroundImage, assetManifest, resolveManifestUrl]);
10720
10925
  const { getImage, pendingCount } = useImageCache(spriteUrls);
10721
10926
  useEffect(() => {
10722
10927
  if (typeof window === "undefined") return;
@@ -11003,7 +11208,7 @@ function IsometricCanvas({
11003
11208
  ctx.lineWidth = 3;
11004
11209
  ctx.stroke();
11005
11210
  }
11006
- const frame = resolveUnitFrame?.(unit.id) ?? null;
11211
+ const frame = resolveFrameForUnit(unit.id);
11007
11212
  const frameImg = frame ? getImage(frame.sheetUrl) : null;
11008
11213
  if (frame && frameImg) {
11009
11214
  const frameAr = frame.sw / frame.sh;
@@ -11113,7 +11318,7 @@ function IsometricCanvas({
11113
11318
  resolveTerrainSpriteUrl,
11114
11319
  resolveFeatureSpriteUrl,
11115
11320
  resolveUnitSpriteUrl,
11116
- resolveUnitFrame,
11321
+ resolveFrameForUnit,
11117
11322
  getImage,
11118
11323
  gridWidth,
11119
11324
  gridHeight,
@@ -11145,7 +11350,7 @@ function IsometricCanvas({
11145
11350
  };
11146
11351
  }, [selectedUnitId, units, scale, baseOffsetX, scaledTileWidth, scaledDiamondTopY, scaledFloorHeight, viewportSize, targetCameraRef]);
11147
11352
  useEffect(() => {
11148
- const hasAnimations = units.length > 0 || validMoves.length > 0 || attackTargets.length > 0 || selectedUnitId != null || targetCameraRef.current != null || hasActiveEffects2 || pendingCount > 0;
11353
+ const hasAnimations = units.length > 0 || validMoves.length > 0 || attackTargets.length > 0 || selectedUnitId != null || targetCameraRef.current != null || hasActiveEffects2 || pendingCount > 0 || atlasPending > 0;
11149
11354
  draw(animTimeRef.current);
11150
11355
  if (!hasAnimations) return;
11151
11356
  let running = true;
@@ -11161,7 +11366,7 @@ function IsometricCanvas({
11161
11366
  running = false;
11162
11367
  cancelAnimationFrame(rafIdRef.current);
11163
11368
  };
11164
- }, [draw, units.length, validMoves.length, attackTargets.length, selectedUnitId, hasActiveEffects2, pendingCount, lerpToTarget, targetCameraRef]);
11369
+ }, [draw, units.length, validMoves.length, attackTargets.length, selectedUnitId, hasActiveEffects2, pendingCount, atlasPending, lerpToTarget, targetCameraRef]);
11165
11370
  const handleMouseMoveWithCamera = useCallback((e) => {
11166
11371
  if (enableCamera) {
11167
11372
  const wasPanning = handleMouseMove(e, () => draw(animTimeRef.current));
@@ -11306,6 +11511,7 @@ var init_IsometricCanvas = __esm({
11306
11511
  init_ErrorState();
11307
11512
  init_useImageCache();
11308
11513
  init_useCamera();
11514
+ init_useUnitSpriteAtlas();
11309
11515
  init_verificationRegistry();
11310
11516
  init_isometric();
11311
11517
  IsometricCanvas.displayName = "IsometricCanvas";
@@ -28888,13 +29094,13 @@ var init_MapView = __esm({
28888
29094
  shadowSize: [41, 41]
28889
29095
  });
28890
29096
  L.Marker.prototype.options.icon = defaultIcon;
28891
- const { useEffect: useEffect80, useRef: useRef74, useCallback: useCallback118, useState: useState112 } = React81__default;
29097
+ const { useEffect: useEffect81, useRef: useRef75, useCallback: useCallback119, useState: useState113 } = React81__default;
28892
29098
  const { Typography: Typography2 } = await Promise.resolve().then(() => (init_Typography(), Typography_exports));
28893
29099
  const { useEventBus: useEventBus4 } = await Promise.resolve().then(() => (init_useEventBus(), useEventBus_exports));
28894
29100
  function MapUpdater({ centerLat, centerLng, zoom }) {
28895
29101
  const map = useMap();
28896
- const prevRef = useRef74({ centerLat, centerLng, zoom });
28897
- useEffect80(() => {
29102
+ const prevRef = useRef75({ centerLat, centerLng, zoom });
29103
+ useEffect81(() => {
28898
29104
  const prev = prevRef.current;
28899
29105
  if (prev.centerLat !== centerLat || prev.centerLng !== centerLng || prev.zoom !== zoom) {
28900
29106
  map.setView([centerLat, centerLng], zoom);
@@ -28905,7 +29111,7 @@ var init_MapView = __esm({
28905
29111
  }
28906
29112
  function MapClickHandler({ onMapClick }) {
28907
29113
  const map = useMap();
28908
- useEffect80(() => {
29114
+ useEffect81(() => {
28909
29115
  if (!onMapClick) return;
28910
29116
  const handler = (e) => {
28911
29117
  onMapClick(e.latlng.lat, e.latlng.lng);
@@ -28933,8 +29139,8 @@ var init_MapView = __esm({
28933
29139
  showAttribution = true
28934
29140
  }) {
28935
29141
  const eventBus = useEventBus4();
28936
- const [clickedPosition, setClickedPosition] = useState112(null);
28937
- const handleMapClick = useCallback118((lat, lng) => {
29142
+ const [clickedPosition, setClickedPosition] = useState113(null);
29143
+ const handleMapClick = useCallback119((lat, lng) => {
28938
29144
  if (showClickedPin) {
28939
29145
  setClickedPosition({ lat, lng });
28940
29146
  }
@@ -28943,7 +29149,7 @@ var init_MapView = __esm({
28943
29149
  eventBus.emit(`UI:${mapClickEvent}`, { latitude: lat, longitude: lng });
28944
29150
  }
28945
29151
  }, [onMapClick, mapClickEvent, eventBus, showClickedPin]);
28946
- const handleMarkerClick = useCallback118((marker) => {
29152
+ const handleMarkerClick = useCallback119((marker) => {
28947
29153
  onMarkerClick?.(marker);
28948
29154
  if (markerClickEvent) {
28949
29155
  eventBus.emit(`UI:${markerClickEvent}`, { ...marker });
@@ -40661,7 +40867,7 @@ var init_AssetLoader = __esm({
40661
40867
  __publicField(this, "textureCache");
40662
40868
  __publicField(this, "loadingPromises");
40663
40869
  this.objLoader = new OBJLoader();
40664
- this.textureLoader = new THREE.TextureLoader();
40870
+ this.textureLoader = new THREE3.TextureLoader();
40665
40871
  this.modelCache = /* @__PURE__ */ new Map();
40666
40872
  this.textureCache = /* @__PURE__ */ new Map();
40667
40873
  this.loadingPromises = /* @__PURE__ */ new Map();
@@ -40735,7 +40941,7 @@ var init_AssetLoader = __esm({
40735
40941
  return this.loadingPromises.get(`texture:${url}`);
40736
40942
  }
40737
40943
  const loadPromise = this.textureLoader.loadAsync(url).then((texture) => {
40738
- texture.colorSpace = THREE.SRGBColorSpace;
40944
+ texture.colorSpace = THREE3.SRGBColorSpace;
40739
40945
  this.textureCache.set(url, texture);
40740
40946
  this.loadingPromises.delete(`texture:${url}`);
40741
40947
  return texture;
@@ -40809,7 +41015,7 @@ var init_AssetLoader = __esm({
40809
41015
  });
40810
41016
  this.modelCache.forEach((model) => {
40811
41017
  model.scene.traverse((child) => {
40812
- if (child instanceof THREE.Mesh) {
41018
+ if (child instanceof THREE3.Mesh) {
40813
41019
  child.geometry.dispose();
40814
41020
  if (Array.isArray(child.material)) {
40815
41021
  child.material.forEach((m) => m.dispose());
@@ -41365,7 +41571,7 @@ function ModelLoader({
41365
41571
  if (!loadedModel) return null;
41366
41572
  const cloned = loadedModel.clone();
41367
41573
  cloned.traverse((child) => {
41368
- if (child instanceof THREE.Mesh) {
41574
+ if (child instanceof THREE3.Mesh) {
41369
41575
  child.castShadow = castShadow;
41370
41576
  child.receiveShadow = receiveShadow;
41371
41577
  }
@@ -41479,6 +41685,47 @@ function CameraController({
41479
41685
  }, [camera.position, onCameraChange]);
41480
41686
  return null;
41481
41687
  }
41688
+ function UnitSpriteBillboard({
41689
+ sheetUrl,
41690
+ resolveFrame,
41691
+ height = 1.2
41692
+ }) {
41693
+ const texture = useLoader(THREE3.TextureLoader, sheetUrl);
41694
+ const meshRef = useRef(null);
41695
+ const matRef = useRef(null);
41696
+ const [aspect, setAspect] = useState(1);
41697
+ useFrame(() => {
41698
+ const frame = resolveFrame();
41699
+ if (!frame || !texture.image) return;
41700
+ const imgW = texture.image.width;
41701
+ const imgH = texture.image.height;
41702
+ if (!imgW || !imgH) return;
41703
+ texture.repeat.set((frame.flipX ? -1 : 1) * (frame.sw / imgW), frame.sh / imgH);
41704
+ texture.offset.set(
41705
+ frame.flipX ? (frame.sx + frame.sw) / imgW : frame.sx / imgW,
41706
+ 1 - (frame.sy + frame.sh) / imgH
41707
+ );
41708
+ texture.magFilter = THREE3.NearestFilter;
41709
+ texture.minFilter = THREE3.NearestFilter;
41710
+ texture.needsUpdate = true;
41711
+ const nextAspect = frame.sw / frame.sh;
41712
+ if (Math.abs(nextAspect - aspect) > 1e-3) setAspect(nextAspect);
41713
+ if (matRef.current) matRef.current.needsUpdate = true;
41714
+ });
41715
+ return /* @__PURE__ */ jsxs("mesh", { ref: meshRef, position: [0, height / 2, 0], children: [
41716
+ /* @__PURE__ */ jsx("planeGeometry", { args: [height * aspect, height] }),
41717
+ /* @__PURE__ */ jsx(
41718
+ "meshBasicMaterial",
41719
+ {
41720
+ ref: matRef,
41721
+ map: texture,
41722
+ transparent: true,
41723
+ alphaTest: 0.1,
41724
+ side: THREE3.DoubleSide
41725
+ }
41726
+ )
41727
+ ] });
41728
+ }
41482
41729
  var DEFAULT_GRID_CONFIG, GameCanvas3D;
41483
41730
  var init_GameCanvas3D2 = __esm({
41484
41731
  "components/game/molecules/GameCanvas3D.tsx"() {
@@ -41489,6 +41736,7 @@ var init_GameCanvas3D2 = __esm({
41489
41736
  init_Canvas3DLoadingState2();
41490
41737
  init_Canvas3DErrorBoundary2();
41491
41738
  init_ModelLoader();
41739
+ init_useUnitSpriteAtlas();
41492
41740
  init_cn();
41493
41741
  init_GameCanvas3D();
41494
41742
  DEFAULT_GRID_CONFIG = {
@@ -41545,8 +41793,10 @@ var init_GameCanvas3D2 = __esm({
41545
41793
  const controlsRef = useRef(null);
41546
41794
  const [hoveredTile, setHoveredTile] = useState(null);
41547
41795
  const [internalError, setInternalError] = useState(null);
41796
+ const { sheetUrls: atlasSheetUrls, resolveUnitFrame } = useUnitSpriteAtlas(units);
41797
+ const preloadUrls = useMemo(() => [...preloadAssets, ...atlasSheetUrls], [preloadAssets, atlasSheetUrls]);
41548
41798
  const { isLoading: assetsLoading, progress, loaded, total } = useAssetLoader({
41549
- preloadUrls: preloadAssets,
41799
+ preloadUrls,
41550
41800
  loader: customAssetLoader
41551
41801
  });
41552
41802
  const eventHandlers = useGameCanvas3DEvents({
@@ -41605,7 +41855,7 @@ var init_GameCanvas3D2 = __esm({
41605
41855
  getCameraPosition: () => {
41606
41856
  if (controlsRef.current) {
41607
41857
  const pos = controlsRef.current.object.position;
41608
- return new THREE.Vector3(pos.x, pos.y, pos.z);
41858
+ return new THREE3.Vector3(pos.x, pos.y, pos.z);
41609
41859
  }
41610
41860
  return null;
41611
41861
  },
@@ -41757,6 +42007,8 @@ var init_GameCanvas3D2 = __esm({
41757
42007
  ({ unit, position }) => {
41758
42008
  const isSelected = selectedUnitId === unit.id;
41759
42009
  const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
42010
+ const hasAtlas = unitAtlasUrl(unit) !== null;
42011
+ const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
41760
42012
  return /* @__PURE__ */ jsxs(
41761
42013
  "group",
41762
42014
  {
@@ -41768,7 +42020,16 @@ var init_GameCanvas3D2 = __esm({
41768
42020
  /* @__PURE__ */ jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
41769
42021
  /* @__PURE__ */ jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
41770
42022
  ] }),
41771
- unit.modelUrl ? (
42023
+ hasAtlas && initialFrame ? (
42024
+ /* Animated sprite-sheet billboard — single cropped frame, by state */
42025
+ /* @__PURE__ */ jsx(Billboard, { children: /* @__PURE__ */ jsx(
42026
+ UnitSpriteBillboard,
42027
+ {
42028
+ sheetUrl: initialFrame.sheetUrl,
42029
+ resolveFrame: () => resolveUnitFrame(unit.id)
42030
+ }
42031
+ ) })
42032
+ ) : unit.modelUrl ? (
41772
42033
  /* GLB unit model (box fallback while loading / on error) */
41773
42034
  /* @__PURE__ */ jsx(
41774
42035
  ModelLoader,
@@ -41822,7 +42083,7 @@ var init_GameCanvas3D2 = __esm({
41822
42083
  }
41823
42084
  );
41824
42085
  },
41825
- [selectedUnitId, handleUnitClick]
42086
+ [selectedUnitId, handleUnitClick, resolveUnitFrame]
41826
42087
  );
41827
42088
  const DefaultFeatureRenderer = useCallback(
41828
42089
  ({
@@ -51151,6 +51412,27 @@ init_verificationRegistry();
51151
51412
  var crossTraitLog = createLogger("almadar:ui:cross-trait");
51152
51413
  var flushLog = createLogger("almadar:ui:slot-flush");
51153
51414
  var stateLog = createLogger("almadar:ui:state-transitions");
51415
+ var tickLog = createLogger("almadar:ui:tick-effects");
51416
+ var SYNC_TICK_OPERATORS = /* @__PURE__ */ new Set([
51417
+ "set",
51418
+ "emit",
51419
+ "render-ui",
51420
+ "render",
51421
+ "navigate",
51422
+ "notify",
51423
+ "log",
51424
+ // Synchronous structural forms that wrap sync effects. A tick authored as
51425
+ // a single top-level `(let ((..)) (do (set ..) (render-ui ..)))` or
51426
+ // `(if cond (set ..) ..)` is one of these at its head — the EffectExecutor
51427
+ // resolves the binding values / condition through the canonical evaluator
51428
+ // and runs the wrapped sync effects. Without these in the allow-list the
51429
+ // whole tick is filtered out (its head op isn't `set`/`emit`/...), so the
51430
+ // physics/gameflow/AI tick never runs.
51431
+ "let",
51432
+ "if",
51433
+ "do",
51434
+ "when"
51435
+ ]);
51154
51436
  function toTraitDefinition(binding) {
51155
51437
  return {
51156
51438
  name: binding.trait.name,
@@ -51347,50 +51629,196 @@ function useTraitStateMachine(traitBindings, uiSlots, options) {
51347
51629
  for (const unreg of snapshotUnregs) unreg();
51348
51630
  };
51349
51631
  }, [traitBindings]);
51350
- const runTickEffects = useCallback((tick, binding) => {
51351
- const currentState = traitStatesRef.current.get(binding.trait.name)?.currentState ?? "";
51352
- if (tick.appliesTo.length > 0 && !tick.appliesTo.includes(currentState)) return;
51353
- const bindingCtx = { entity: {}, payload: {}, state: currentState };
51354
- if (binding.config) {
51355
- bindingCtx.config = binding.config;
51356
- }
51357
- const evalCtx = createContextFromBindings(bindingCtx);
51358
- if (tick.guard !== void 0) {
51359
- const passed = interpolateValue(tick.guard, evalCtx);
51360
- if (!passed) return;
51361
- }
51632
+ const executeTransitionEffects = useCallback(async (params) => {
51633
+ const { binding, previousState, newState, payload, flushEvent, syncOnly, log: log14 } = params;
51634
+ const traitName = binding.trait.name;
51635
+ const linkedEntity = binding.linkedEntity || "";
51636
+ const entityId = payload?.entityId;
51637
+ const effects = syncOnly ? params.effects.filter(
51638
+ (e) => Array.isArray(e) && SYNC_TICK_OPERATORS.has(String(e[0]))
51639
+ ) : params.effects;
51640
+ if (effects.length === 0) return [];
51362
51641
  const pendingSlots = /* @__PURE__ */ new Map();
51363
51642
  ({
51364
- trait: binding.trait.name,
51365
- transition: `${currentState}->tick:${tick.name}`,
51366
- effects: tick.effects,
51367
51643
  traitDefinition: binding.trait
51368
51644
  });
51369
- for (const effect of tick.effects) {
51370
- if (!Array.isArray(effect)) continue;
51371
- const op = effect[0];
51372
- if (op === "render-ui" || op === "render") {
51373
- const slot = effect[1];
51374
- const rawPattern = effect[2];
51375
- if (rawPattern === null || rawPattern === void 0) {
51645
+ const clientHandlers = createClientEffectHandlers({
51646
+ eventBus,
51647
+ slotSetter: {
51648
+ addPattern: (slot, pattern, props) => {
51649
+ const existing = pendingSlots.get(slot) || [];
51650
+ existing.push({ pattern, props: props || {} });
51651
+ pendingSlots.set(slot, existing);
51652
+ },
51653
+ clearSlot: (slot) => {
51376
51654
  pendingSlots.set(slot, []);
51377
- continue;
51378
51655
  }
51379
- const updatedCtx = createContextFromBindings(bindingCtx);
51380
- const resolved = interpolateValue(rawPattern, updatedCtx);
51381
- const existing = pendingSlots.get(slot) ?? [];
51382
- existing.push({ pattern: resolved, props: {} });
51383
- pendingSlots.set(slot, existing);
51656
+ },
51657
+ navigate: optionsRef.current?.navigate,
51658
+ notify: optionsRef.current?.notify,
51659
+ callService: optionsRef.current?.callService
51660
+ });
51661
+ const persistence = syncOnly ? void 0 : optionsRef.current?.persistence;
51662
+ let handlers = clientHandlers;
51663
+ if (persistence) {
51664
+ const sharedBindings = {
51665
+ // Seed `@entity` from the trait's scalar field state (a real
51666
+ // EntityRow), the same source the executor's own bindingCtx
51667
+ // uses below. `@payload.*` resolves from `payload` separately,
51668
+ // so dropping the prior `payload as EntityRow` cast loses
51669
+ // nothing — it just stops mislabelling the payload as an entity.
51670
+ entity: traitFieldStatesRef.current.get(traitName) ?? {},
51671
+ payload: payload || {},
51672
+ state: previousState
51673
+ };
51674
+ const sharedDeclared = collectDeclaredConfigDefaults(binding.trait);
51675
+ const sharedCallSite = binding.config;
51676
+ if (sharedDeclared || sharedCallSite) {
51677
+ sharedBindings.config = {
51678
+ ...sharedDeclared ?? {},
51679
+ ...sharedCallSite ?? {}
51680
+ };
51384
51681
  }
51682
+ const serverHandlers = createServerEffectHandlers({
51683
+ persistence,
51684
+ eventBus,
51685
+ entityType: linkedEntity,
51686
+ entityId,
51687
+ bindings: sharedBindings,
51688
+ context: {
51689
+ traitName,
51690
+ state: previousState,
51691
+ transition: `${previousState}->${newState}`,
51692
+ linkedEntity,
51693
+ entityId
51694
+ },
51695
+ source: { trait: traitName },
51696
+ callService: optionsRef.current?.callService
51697
+ });
51698
+ handlers = {
51699
+ ...serverHandlers,
51700
+ emit: clientHandlers.emit,
51701
+ renderUI: clientHandlers.renderUI,
51702
+ navigate: clientHandlers.navigate,
51703
+ notify: clientHandlers.notify
51704
+ };
51385
51705
  }
51386
- for (const [slot, patterns] of pendingSlots) {
51387
- flushSlot(binding.trait.name, slot, patterns, {
51388
- event: `tick:${tick.name}`,
51389
- state: currentState,
51390
- entity: binding.linkedEntity
51706
+ const baseSet = handlers.set;
51707
+ handlers = {
51708
+ ...handlers,
51709
+ set: async (targetId, field, value) => {
51710
+ let fieldState = traitFieldStatesRef.current.get(traitName);
51711
+ if (!fieldState) {
51712
+ fieldState = {};
51713
+ traitFieldStatesRef.current.set(traitName, fieldState);
51714
+ }
51715
+ fieldState[field] = value;
51716
+ log14.debug("set:write", {
51717
+ traitName,
51718
+ field,
51719
+ value: JSON.stringify(value),
51720
+ transition: `${previousState}->${newState}`
51721
+ });
51722
+ if (baseSet) await baseSet(targetId, field, value);
51723
+ }
51724
+ };
51725
+ const entityForBinding = traitFieldStatesRef.current.get(traitName) ?? {};
51726
+ const bindingCtx = {
51727
+ entity: entityForBinding,
51728
+ payload: payload || {},
51729
+ state: previousState
51730
+ };
51731
+ const declaredDefaults = collectDeclaredConfigDefaults(binding.trait);
51732
+ const callSiteConfig = binding.config;
51733
+ if (declaredDefaults || callSiteConfig) {
51734
+ bindingCtx.config = {
51735
+ ...declaredDefaults ?? {},
51736
+ ...callSiteConfig ?? {}
51737
+ };
51738
+ }
51739
+ const effectContext = {
51740
+ traitName,
51741
+ state: previousState,
51742
+ transition: `${previousState}->${newState}`,
51743
+ linkedEntity,
51744
+ entityId
51745
+ };
51746
+ const emittedDuringExec = [];
51747
+ const baseEmit = handlers.emit;
51748
+ const trackingHandlers = {
51749
+ ...handlers,
51750
+ emit: (event, eventPayload, source) => {
51751
+ emittedDuringExec.push(event);
51752
+ baseEmit(event, eventPayload, source);
51753
+ }
51754
+ };
51755
+ const executor = new EffectExecutor({ handlers: trackingHandlers, bindings: bindingCtx, context: effectContext });
51756
+ try {
51757
+ await executor.executeAll(effects);
51758
+ log14.debug("effects:executed", () => ({
51759
+ traitName,
51760
+ transition: `${previousState}->${newState}`,
51761
+ event: flushEvent,
51762
+ effectCount: effects.length,
51763
+ emitted: emittedDuringExec.join(","),
51764
+ entityAfter: JSON.stringify(traitFieldStatesRef.current.get(traitName) ?? {}),
51765
+ slotsTouched: Array.from(pendingSlots.keys()).join(",")
51766
+ }));
51767
+ for (const [slot, patterns] of pendingSlots) {
51768
+ log14.debug("flush:slot", {
51769
+ traitName,
51770
+ slot,
51771
+ patternCount: patterns.length,
51772
+ event: flushEvent,
51773
+ transition: `${previousState}->${newState}`,
51774
+ cleared: patterns.length === 0
51775
+ });
51776
+ flushSlot(traitName, slot, patterns, {
51777
+ event: flushEvent,
51778
+ state: previousState,
51779
+ entity: binding.linkedEntity
51780
+ });
51781
+ }
51782
+ } catch (error) {
51783
+ log14.error("effects:error", {
51784
+ traitName,
51785
+ transition: `${previousState}->${newState}`,
51786
+ event: flushEvent,
51787
+ error: error instanceof Error ? error.message : String(error),
51788
+ effectCount: effects.length
51391
51789
  });
51392
51790
  }
51393
- }, [flushSlot]);
51791
+ return emittedDuringExec;
51792
+ }, [eventBus, flushSlot]);
51793
+ const runTickEffects = useCallback((tick, binding) => {
51794
+ const traitName = binding.trait.name;
51795
+ const currentState = traitStatesRef.current.get(traitName)?.currentState ?? "";
51796
+ if (tick.appliesTo.length > 0 && !tick.appliesTo.includes(currentState)) return;
51797
+ if (tick.guard !== void 0) {
51798
+ const guardCtx = {
51799
+ entity: traitFieldStatesRef.current.get(traitName) ?? {},
51800
+ payload: {},
51801
+ state: currentState
51802
+ };
51803
+ if (binding.config) {
51804
+ guardCtx.config = binding.config;
51805
+ }
51806
+ const passed = interpolateValue(tick.guard, createContextFromBindings(guardCtx));
51807
+ if (!passed) {
51808
+ tickLog.debug("guard-blocked", { traitName, tick: tick.name, state: currentState });
51809
+ return;
51810
+ }
51811
+ }
51812
+ void executeTransitionEffects({
51813
+ binding,
51814
+ effects: tick.effects,
51815
+ previousState: currentState,
51816
+ newState: currentState,
51817
+ flushEvent: `tick:${tick.name}`,
51818
+ syncOnly: true,
51819
+ log: tickLog
51820
+ });
51821
+ }, [executeTransitionEffects]);
51394
51822
  useEffect(() => {
51395
51823
  const hasFrameTicks = traitBindingsRef.current.some(
51396
51824
  (b) => b.trait.ticks?.some((t) => t.interval === "frame")
@@ -51501,161 +51929,17 @@ function useTraitStateMachine(traitBindings, uiSlots, options) {
51501
51929
  transition: `${result.previousState} -> ${result.newState}`,
51502
51930
  effects: JSON.stringify(result.effects)
51503
51931
  }));
51504
- const linkedEntity = binding.linkedEntity || "";
51505
- const entityId = payload?.entityId;
51506
- const pendingSlots = /* @__PURE__ */ new Map();
51507
- const slotSource = {
51508
- trait: binding.trait.name,
51509
- state: result.previousState,
51510
- transition: `${result.previousState}->${result.newState}`,
51932
+ const emittedDuringExec = await executeTransitionEffects({
51933
+ binding,
51511
51934
  effects: result.effects,
51512
- traitDefinition: binding.trait
51513
- };
51514
- const clientHandlers = createClientEffectHandlers({
51515
- eventBus,
51516
- slotSetter: {
51517
- addPattern: (slot, pattern, props) => {
51518
- const existing = pendingSlots.get(slot) || [];
51519
- existing.push({ pattern, props: props || {} });
51520
- pendingSlots.set(slot, existing);
51521
- },
51522
- clearSlot: (slot) => {
51523
- pendingSlots.set(slot, []);
51524
- }
51525
- },
51526
- navigate: optionsRef.current?.navigate,
51527
- notify: optionsRef.current?.notify,
51528
- callService: optionsRef.current?.callService
51935
+ previousState: result.previousState,
51936
+ newState: result.newState,
51937
+ payload,
51938
+ flushEvent: eventKey,
51939
+ syncOnly: false,
51940
+ log: stateLog
51529
51941
  });
51530
- const persistence = optionsRef.current?.persistence;
51531
- let handlers = clientHandlers;
51532
- if (persistence) {
51533
- const sharedBindings = {
51534
- entity: payload ?? {},
51535
- payload: payload || {},
51536
- state: result.previousState
51537
- };
51538
- const sharedDeclared = collectDeclaredConfigDefaults(
51539
- binding.trait
51540
- );
51541
- const sharedCallSite = binding.config;
51542
- if (sharedDeclared || sharedCallSite) {
51543
- sharedBindings.config = {
51544
- ...sharedDeclared ?? {},
51545
- ...sharedCallSite ?? {}
51546
- };
51547
- }
51548
- const serverHandlers = createServerEffectHandlers({
51549
- persistence,
51550
- eventBus,
51551
- entityType: linkedEntity,
51552
- entityId,
51553
- bindings: sharedBindings,
51554
- context: {
51555
- traitName: binding.trait.name,
51556
- state: result.previousState,
51557
- transition: `${result.previousState}->${result.newState}`,
51558
- linkedEntity,
51559
- entityId
51560
- },
51561
- source: { trait: binding.trait.name },
51562
- callService: optionsRef.current?.callService
51563
- });
51564
- handlers = {
51565
- ...serverHandlers,
51566
- // Client handlers own UI + emit: keep the slot setter
51567
- // and pre-prefixed UI:* emit path intact.
51568
- emit: clientHandlers.emit,
51569
- renderUI: clientHandlers.renderUI,
51570
- navigate: clientHandlers.navigate,
51571
- notify: clientHandlers.notify
51572
- };
51573
- }
51574
- const baseSet = handlers.set;
51575
- handlers = {
51576
- ...handlers,
51577
- set: async (targetId, field, value) => {
51578
- let fieldState = traitFieldStatesRef.current.get(traitName);
51579
- if (!fieldState) {
51580
- fieldState = {};
51581
- traitFieldStatesRef.current.set(traitName, fieldState);
51582
- }
51583
- fieldState[field] = value;
51584
- if (baseSet) await baseSet(targetId, field, value);
51585
- }
51586
- };
51587
- const entityForBinding = traitFieldStatesRef.current.get(traitName) ?? {};
51588
- const bindingCtx = {
51589
- entity: entityForBinding,
51590
- payload: payload || {},
51591
- state: result.previousState
51592
- };
51593
- const declaredDefaults = collectDeclaredConfigDefaults(
51594
- binding.trait
51595
- );
51596
- const callSiteConfig = binding.config;
51597
- if (declaredDefaults || callSiteConfig) {
51598
- bindingCtx.config = {
51599
- ...declaredDefaults ?? {},
51600
- ...callSiteConfig ?? {}
51601
- };
51602
- }
51603
- const effectContext = {
51604
- traitName: binding.trait.name,
51605
- state: result.previousState,
51606
- transition: `${result.previousState}->${result.newState}`,
51607
- linkedEntity,
51608
- entityId
51609
- };
51610
- const emittedDuringExec = [];
51611
51942
  emittedByTrait.set(traitName, emittedDuringExec);
51612
- const baseEmit = handlers.emit;
51613
- const trackingHandlers = {
51614
- ...handlers,
51615
- emit: (event, eventPayload, source) => {
51616
- emittedDuringExec.push(event);
51617
- baseEmit(event, eventPayload, source);
51618
- }
51619
- };
51620
- const executor = new EffectExecutor({ handlers: trackingHandlers, bindings: bindingCtx, context: effectContext });
51621
- try {
51622
- await executor.executeAll(result.effects);
51623
- stateLog.debug("transition:render-ui-dispatched", () => ({
51624
- traitName,
51625
- fromState: result.previousState,
51626
- toState: result.newState,
51627
- event: eventKey,
51628
- slotsTouched: Array.from(pendingSlots.keys()).join(","),
51629
- patternTypes: Array.from(pendingSlots.entries()).map(
51630
- ([slot, patterns]) => `${slot}:[${patterns.map((p2) => p2.pattern?.type ?? "null").join(",")}]`
51631
- ).join(";")
51632
- }));
51633
- void slotSource;
51634
- for (const [slot, patterns] of pendingSlots) {
51635
- stateLog.debug("flush:slot", {
51636
- traitName,
51637
- slot,
51638
- patternCount: patterns.length,
51639
- event: eventKey,
51640
- transition: `${result.previousState}->${result.newState}`,
51641
- cleared: patterns.length === 0
51642
- });
51643
- flushSlot(traitName, slot, patterns, {
51644
- event: eventKey,
51645
- state: result.previousState,
51646
- entity: binding.linkedEntity
51647
- });
51648
- }
51649
- } catch (error) {
51650
- stateLog.error("transition:effect-error", {
51651
- traitName,
51652
- fromState: result.previousState,
51653
- toState: result.newState,
51654
- event: eventKey,
51655
- error: error instanceof Error ? error.message : String(error),
51656
- effectCount: result.effects.length
51657
- });
51658
- }
51659
51943
  } else if (!result.executed) {
51660
51944
  if (result.guardResult === false) {
51661
51945
  stateLog.debug("guard-blocked-transition", {