@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.
- package/dist/avl/index.cjs +519 -215
- package/dist/avl/index.js +521 -217
- package/dist/components/game/molecules/three/index.cjs +251 -3
- package/dist/components/game/molecules/three/index.js +253 -5
- package/dist/components/game/molecules/useUnitSpriteAtlas.d.ts +30 -0
- package/dist/components/game/organisms/types/isometric.d.ts +4 -0
- package/dist/components/game/organisms/types/spriteAnimation.d.ts +24 -0
- package/dist/components/game/organisms/utils/spriteAnimation.d.ts +29 -1
- package/dist/components/index.cjs +377 -141
- package/dist/components/index.js +378 -140
- package/dist/providers/index.cjs +289 -28
- package/dist/providers/index.js +290 -29
- package/dist/runtime/index.cjs +519 -215
- package/dist/runtime/index.js +521 -217
- package/package.json +1 -1
|
@@ -2282,6 +2282,200 @@ function preloadFeatures(urls) {
|
|
|
2282
2282
|
}
|
|
2283
2283
|
});
|
|
2284
2284
|
}
|
|
2285
|
+
|
|
2286
|
+
// components/game/organisms/utils/spriteAnimation.ts
|
|
2287
|
+
function inferDirection(dx, dy) {
|
|
2288
|
+
if (dx === 0 && dy === 0) return "se";
|
|
2289
|
+
if (dx >= 0 && dy >= 0) return "se";
|
|
2290
|
+
if (dx <= 0 && dy >= 0) return "sw";
|
|
2291
|
+
if (dx >= 0 && dy <= 0) return "ne";
|
|
2292
|
+
return "nw";
|
|
2293
|
+
}
|
|
2294
|
+
function resolveSheetDirection(facing) {
|
|
2295
|
+
switch (facing) {
|
|
2296
|
+
case "se":
|
|
2297
|
+
return { sheetDir: "se", flipX: false };
|
|
2298
|
+
case "sw":
|
|
2299
|
+
return { sheetDir: "sw", flipX: false };
|
|
2300
|
+
case "ne":
|
|
2301
|
+
return { sheetDir: "sw", flipX: true };
|
|
2302
|
+
case "nw":
|
|
2303
|
+
return { sheetDir: "se", flipX: true };
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
function frameRect(frame, row, columns, frameWidth, frameHeight) {
|
|
2307
|
+
return {
|
|
2308
|
+
sx: frame % columns * frameWidth,
|
|
2309
|
+
sy: row * frameHeight,
|
|
2310
|
+
sw: frameWidth,
|
|
2311
|
+
sh: frameHeight
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
function getCurrentFrameFromDef(def, elapsed) {
|
|
2315
|
+
const frameDuration = 1e3 / def.frameRate;
|
|
2316
|
+
const totalDuration = def.frames * frameDuration;
|
|
2317
|
+
if (def.loop) {
|
|
2318
|
+
const frame2 = Math.floor(elapsed % totalDuration / frameDuration);
|
|
2319
|
+
return { frame: frame2, finished: false };
|
|
2320
|
+
}
|
|
2321
|
+
if (elapsed >= totalDuration) {
|
|
2322
|
+
return { frame: def.frames - 1, finished: true };
|
|
2323
|
+
}
|
|
2324
|
+
const frame = Math.floor(elapsed / frameDuration);
|
|
2325
|
+
return { frame, finished: false };
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// components/game/molecules/useUnitSpriteAtlas.ts
|
|
2329
|
+
var WALK_HOLD_MS = 600;
|
|
2330
|
+
function unitAtlasUrl(unit) {
|
|
2331
|
+
if (unit.spriteSheet) return unit.spriteSheet;
|
|
2332
|
+
const sprite = unit.sprite;
|
|
2333
|
+
if (!sprite) return null;
|
|
2334
|
+
const match = /^(.*-sprite-sheet)(?:-(?:se|sw))?(?:-v\d+)?\.png$/.exec(sprite);
|
|
2335
|
+
if (!match) return null;
|
|
2336
|
+
return `${match[1]}.json`;
|
|
2337
|
+
}
|
|
2338
|
+
function resolveSheetUrl(atlasUrl, relativeSheetPath) {
|
|
2339
|
+
try {
|
|
2340
|
+
return new URL(relativeSheetPath, atlasUrl).toString();
|
|
2341
|
+
} catch {
|
|
2342
|
+
const base = atlasUrl.slice(0, atlasUrl.lastIndexOf("/") + 1);
|
|
2343
|
+
return `${base}${relativeSheetPath.replace(/^\.\//, "")}`;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
function useUnitSpriteAtlas(units) {
|
|
2347
|
+
const atlasCacheRef = React11.useRef(/* @__PURE__ */ new Map());
|
|
2348
|
+
const loadingRef = React11.useRef(/* @__PURE__ */ new Set());
|
|
2349
|
+
const [pendingCount, setPendingCount] = React11.useState(0);
|
|
2350
|
+
const [, forceTick] = React11.useState(0);
|
|
2351
|
+
const animStatesRef = React11.useRef(/* @__PURE__ */ new Map());
|
|
2352
|
+
const lastTickRef = React11.useRef(0);
|
|
2353
|
+
const rafRef = React11.useRef(0);
|
|
2354
|
+
const atlasUrls = React11.useMemo(() => {
|
|
2355
|
+
const set = /* @__PURE__ */ new Set();
|
|
2356
|
+
for (const unit of units) {
|
|
2357
|
+
const url = unitAtlasUrl(unit);
|
|
2358
|
+
if (url) set.add(url);
|
|
2359
|
+
}
|
|
2360
|
+
return [...set];
|
|
2361
|
+
}, [units]);
|
|
2362
|
+
React11.useEffect(() => {
|
|
2363
|
+
const cache = atlasCacheRef.current;
|
|
2364
|
+
const loading = loadingRef.current;
|
|
2365
|
+
const toLoad = atlasUrls.filter((url) => !cache.has(url) && !loading.has(url));
|
|
2366
|
+
if (toLoad.length === 0) return;
|
|
2367
|
+
let cancelled = false;
|
|
2368
|
+
setPendingCount((prev) => prev + toLoad.length);
|
|
2369
|
+
for (const url of toLoad) {
|
|
2370
|
+
loading.add(url);
|
|
2371
|
+
fetch(url).then((res) => res.ok ? res.json() : Promise.reject(new Error(String(res.status)))).then((atlas) => {
|
|
2372
|
+
if (cancelled) return;
|
|
2373
|
+
cache.set(url, atlas);
|
|
2374
|
+
}).catch(() => {
|
|
2375
|
+
}).finally(() => {
|
|
2376
|
+
if (cancelled) return;
|
|
2377
|
+
loading.delete(url);
|
|
2378
|
+
setPendingCount((prev) => Math.max(0, prev - 1));
|
|
2379
|
+
forceTick((n) => n + 1);
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
return () => {
|
|
2383
|
+
cancelled = true;
|
|
2384
|
+
};
|
|
2385
|
+
}, [atlasUrls]);
|
|
2386
|
+
const sheetUrls = React11.useMemo(() => {
|
|
2387
|
+
const urls = /* @__PURE__ */ new Set();
|
|
2388
|
+
for (const unit of units) {
|
|
2389
|
+
const atlasUrl = unitAtlasUrl(unit);
|
|
2390
|
+
if (!atlasUrl) continue;
|
|
2391
|
+
const atlas = atlasCacheRef.current.get(atlasUrl);
|
|
2392
|
+
if (!atlas) continue;
|
|
2393
|
+
for (const rel of Object.values(atlas.sheets)) {
|
|
2394
|
+
if (rel) urls.add(resolveSheetUrl(atlasUrl, rel));
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
return [...urls];
|
|
2398
|
+
}, [units, pendingCount]);
|
|
2399
|
+
React11.useEffect(() => {
|
|
2400
|
+
const hasAtlasUnits = units.some((u) => unitAtlasUrl(u) !== null);
|
|
2401
|
+
if (!hasAtlasUnits) return;
|
|
2402
|
+
let running = true;
|
|
2403
|
+
const tick = (ts) => {
|
|
2404
|
+
if (!running) return;
|
|
2405
|
+
const last = lastTickRef.current || ts;
|
|
2406
|
+
const delta = ts - last;
|
|
2407
|
+
lastTickRef.current = ts;
|
|
2408
|
+
const states = animStatesRef.current;
|
|
2409
|
+
const currentIds = /* @__PURE__ */ new Set();
|
|
2410
|
+
for (const unit of units) {
|
|
2411
|
+
if (unitAtlasUrl(unit) === null) continue;
|
|
2412
|
+
currentIds.add(unit.id);
|
|
2413
|
+
let state = states.get(unit.id);
|
|
2414
|
+
if (!state) {
|
|
2415
|
+
state = { animation: "idle", direction: "se", elapsed: 0, walkHold: 0, prev: null };
|
|
2416
|
+
states.set(unit.id, state);
|
|
2417
|
+
}
|
|
2418
|
+
const posX = unit.position?.x ?? unit.x ?? 0;
|
|
2419
|
+
const posY = unit.position?.y ?? unit.y ?? 0;
|
|
2420
|
+
if (state.prev) {
|
|
2421
|
+
const dx = posX - state.prev.x;
|
|
2422
|
+
const dy = posY - state.prev.y;
|
|
2423
|
+
if (dx !== 0 || dy !== 0) {
|
|
2424
|
+
state.animation = "walk";
|
|
2425
|
+
state.direction = inferDirection(dx, dy);
|
|
2426
|
+
state.walkHold = WALK_HOLD_MS;
|
|
2427
|
+
} else if (state.animation === "walk") {
|
|
2428
|
+
state.walkHold -= delta;
|
|
2429
|
+
if (state.walkHold <= 0) state.animation = "idle";
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
state.prev = { x: posX, y: posY };
|
|
2433
|
+
state.elapsed += delta;
|
|
2434
|
+
}
|
|
2435
|
+
for (const id of states.keys()) {
|
|
2436
|
+
if (!currentIds.has(id)) states.delete(id);
|
|
2437
|
+
}
|
|
2438
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
2439
|
+
};
|
|
2440
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
2441
|
+
return () => {
|
|
2442
|
+
running = false;
|
|
2443
|
+
cancelAnimationFrame(rafRef.current);
|
|
2444
|
+
lastTickRef.current = 0;
|
|
2445
|
+
};
|
|
2446
|
+
}, [units]);
|
|
2447
|
+
const resolveUnitFrame = React11.useCallback((unitId) => {
|
|
2448
|
+
const unit = units.find((u) => u.id === unitId);
|
|
2449
|
+
if (!unit) return null;
|
|
2450
|
+
const atlasUrl = unitAtlasUrl(unit);
|
|
2451
|
+
if (!atlasUrl) return null;
|
|
2452
|
+
const atlas = atlasCacheRef.current.get(atlasUrl);
|
|
2453
|
+
if (!atlas) return null;
|
|
2454
|
+
const state = animStatesRef.current.get(unitId);
|
|
2455
|
+
const animation = state?.animation ?? "idle";
|
|
2456
|
+
const direction = state?.direction ?? "se";
|
|
2457
|
+
const elapsed = state?.elapsed ?? 0;
|
|
2458
|
+
const def = atlas.animations[animation] ?? atlas.animations.idle;
|
|
2459
|
+
if (!def) return null;
|
|
2460
|
+
const { sheetDir, flipX } = resolveSheetDirection(direction);
|
|
2461
|
+
const rel = atlas.sheets[sheetDir] ?? atlas.sheets.se ?? atlas.sheets.sw;
|
|
2462
|
+
if (!rel) return null;
|
|
2463
|
+
const sheetUrl = resolveSheetUrl(atlasUrl, rel);
|
|
2464
|
+
const isIdle = animation === "idle";
|
|
2465
|
+
const frame = isIdle ? 0 : getCurrentFrameFromDef(def, elapsed).frame;
|
|
2466
|
+
const rect = frameRect(frame, def.row, atlas.columns, atlas.frameWidth, atlas.frameHeight);
|
|
2467
|
+
return {
|
|
2468
|
+
sheetUrl,
|
|
2469
|
+
sx: rect.sx,
|
|
2470
|
+
sy: rect.sy,
|
|
2471
|
+
sw: rect.sw,
|
|
2472
|
+
sh: rect.sh,
|
|
2473
|
+
flipX,
|
|
2474
|
+
applyBreathing: isIdle
|
|
2475
|
+
};
|
|
2476
|
+
}, [units]);
|
|
2477
|
+
return { sheetUrls, resolveUnitFrame, pendingCount };
|
|
2478
|
+
}
|
|
2285
2479
|
function cn(...inputs) {
|
|
2286
2480
|
return tailwindMerge.twMerge(clsx.clsx(inputs));
|
|
2287
2481
|
}
|
|
@@ -2305,6 +2499,47 @@ function CameraController({
|
|
|
2305
2499
|
}, [camera.position, onCameraChange]);
|
|
2306
2500
|
return null;
|
|
2307
2501
|
}
|
|
2502
|
+
function UnitSpriteBillboard({
|
|
2503
|
+
sheetUrl,
|
|
2504
|
+
resolveFrame,
|
|
2505
|
+
height = 1.2
|
|
2506
|
+
}) {
|
|
2507
|
+
const texture = fiber.useLoader(THREE6__namespace.TextureLoader, sheetUrl);
|
|
2508
|
+
const meshRef = React11.useRef(null);
|
|
2509
|
+
const matRef = React11.useRef(null);
|
|
2510
|
+
const [aspect, setAspect] = React11.useState(1);
|
|
2511
|
+
fiber.useFrame(() => {
|
|
2512
|
+
const frame = resolveFrame();
|
|
2513
|
+
if (!frame || !texture.image) return;
|
|
2514
|
+
const imgW = texture.image.width;
|
|
2515
|
+
const imgH = texture.image.height;
|
|
2516
|
+
if (!imgW || !imgH) return;
|
|
2517
|
+
texture.repeat.set((frame.flipX ? -1 : 1) * (frame.sw / imgW), frame.sh / imgH);
|
|
2518
|
+
texture.offset.set(
|
|
2519
|
+
frame.flipX ? (frame.sx + frame.sw) / imgW : frame.sx / imgW,
|
|
2520
|
+
1 - (frame.sy + frame.sh) / imgH
|
|
2521
|
+
);
|
|
2522
|
+
texture.magFilter = THREE6__namespace.NearestFilter;
|
|
2523
|
+
texture.minFilter = THREE6__namespace.NearestFilter;
|
|
2524
|
+
texture.needsUpdate = true;
|
|
2525
|
+
const nextAspect = frame.sw / frame.sh;
|
|
2526
|
+
if (Math.abs(nextAspect - aspect) > 1e-3) setAspect(nextAspect);
|
|
2527
|
+
if (matRef.current) matRef.current.needsUpdate = true;
|
|
2528
|
+
});
|
|
2529
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("mesh", { ref: meshRef, position: [0, height / 2, 0], children: [
|
|
2530
|
+
/* @__PURE__ */ jsxRuntime.jsx("planeGeometry", { args: [height * aspect, height] }),
|
|
2531
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
2532
|
+
"meshBasicMaterial",
|
|
2533
|
+
{
|
|
2534
|
+
ref: matRef,
|
|
2535
|
+
map: texture,
|
|
2536
|
+
transparent: true,
|
|
2537
|
+
alphaTest: 0.1,
|
|
2538
|
+
side: THREE6__namespace.DoubleSide
|
|
2539
|
+
}
|
|
2540
|
+
)
|
|
2541
|
+
] });
|
|
2542
|
+
}
|
|
2308
2543
|
var GameCanvas3D = React11.forwardRef(
|
|
2309
2544
|
({
|
|
2310
2545
|
tiles = [],
|
|
@@ -2354,8 +2589,10 @@ var GameCanvas3D = React11.forwardRef(
|
|
|
2354
2589
|
const controlsRef = React11.useRef(null);
|
|
2355
2590
|
const [hoveredTile, setHoveredTile] = React11.useState(null);
|
|
2356
2591
|
const [internalError, setInternalError] = React11.useState(null);
|
|
2592
|
+
const { sheetUrls: atlasSheetUrls, resolveUnitFrame } = useUnitSpriteAtlas(units);
|
|
2593
|
+
const preloadUrls = React11.useMemo(() => [...preloadAssets, ...atlasSheetUrls], [preloadAssets, atlasSheetUrls]);
|
|
2357
2594
|
const { isLoading: assetsLoading, progress, loaded, total } = useAssetLoader({
|
|
2358
|
-
preloadUrls
|
|
2595
|
+
preloadUrls,
|
|
2359
2596
|
loader: customAssetLoader
|
|
2360
2597
|
});
|
|
2361
2598
|
const eventHandlers = useGameCanvas3DEvents({
|
|
@@ -2566,6 +2803,8 @@ var GameCanvas3D = React11.forwardRef(
|
|
|
2566
2803
|
({ unit, position }) => {
|
|
2567
2804
|
const isSelected = selectedUnitId === unit.id;
|
|
2568
2805
|
const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
|
|
2806
|
+
const hasAtlas = unitAtlasUrl(unit) !== null;
|
|
2807
|
+
const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
|
|
2569
2808
|
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
2570
2809
|
"group",
|
|
2571
2810
|
{
|
|
@@ -2577,7 +2816,16 @@ var GameCanvas3D = React11.forwardRef(
|
|
|
2577
2816
|
/* @__PURE__ */ jsxRuntime.jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
|
|
2578
2817
|
/* @__PURE__ */ jsxRuntime.jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
|
|
2579
2818
|
] }),
|
|
2580
|
-
|
|
2819
|
+
hasAtlas && initialFrame ? (
|
|
2820
|
+
/* Animated sprite-sheet billboard — single cropped frame, by state */
|
|
2821
|
+
/* @__PURE__ */ jsxRuntime.jsx(drei.Billboard, { children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
2822
|
+
UnitSpriteBillboard,
|
|
2823
|
+
{
|
|
2824
|
+
sheetUrl: initialFrame.sheetUrl,
|
|
2825
|
+
resolveFrame: () => resolveUnitFrame(unit.id)
|
|
2826
|
+
}
|
|
2827
|
+
) })
|
|
2828
|
+
) : unit.modelUrl ? (
|
|
2581
2829
|
/* GLB unit model (box fallback while loading / on error) */
|
|
2582
2830
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
2583
2831
|
ModelLoader,
|
|
@@ -2631,7 +2879,7 @@ var GameCanvas3D = React11.forwardRef(
|
|
|
2631
2879
|
}
|
|
2632
2880
|
);
|
|
2633
2881
|
},
|
|
2634
|
-
[selectedUnitId, handleUnitClick]
|
|
2882
|
+
[selectedUnitId, handleUnitClick, resolveUnitFrame]
|
|
2635
2883
|
);
|
|
2636
2884
|
const DefaultFeatureRenderer = React11.useCallback(
|
|
2637
2885
|
({
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import React11, { forwardRef, useRef, useEffect, useImperativeHandle, useState, useMemo, useCallback, createContext, Component, useContext, useSyncExternalStore, useReducer } from 'react';
|
|
2
|
-
import { useThree, useFrame, Canvas, context } from '@react-three/fiber';
|
|
2
|
+
import { useThree, useFrame, Canvas, useLoader, context } from '@react-three/fiber';
|
|
3
3
|
import * as THREE6 from 'three';
|
|
4
4
|
import { Vector3, QuadraticBezierCurve3, MathUtils, Quaternion } from 'three';
|
|
5
5
|
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
6
|
-
import { OrbitControls, Grid, Stars, Sparkles, Html, RoundedBox } from '@react-three/drei';
|
|
6
|
+
import { OrbitControls, Billboard, Grid, Stars, Sparkles, Html, RoundedBox } from '@react-three/drei';
|
|
7
7
|
import { createLogger } from '@almadar/logger';
|
|
8
8
|
import { GLTFLoader as GLTFLoader$1 } from 'three/examples/jsm/loaders/GLTFLoader';
|
|
9
9
|
import { OrbitControls as OrbitControls$1 } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
@@ -2258,6 +2258,200 @@ function preloadFeatures(urls) {
|
|
|
2258
2258
|
}
|
|
2259
2259
|
});
|
|
2260
2260
|
}
|
|
2261
|
+
|
|
2262
|
+
// components/game/organisms/utils/spriteAnimation.ts
|
|
2263
|
+
function inferDirection(dx, dy) {
|
|
2264
|
+
if (dx === 0 && dy === 0) return "se";
|
|
2265
|
+
if (dx >= 0 && dy >= 0) return "se";
|
|
2266
|
+
if (dx <= 0 && dy >= 0) return "sw";
|
|
2267
|
+
if (dx >= 0 && dy <= 0) return "ne";
|
|
2268
|
+
return "nw";
|
|
2269
|
+
}
|
|
2270
|
+
function resolveSheetDirection(facing) {
|
|
2271
|
+
switch (facing) {
|
|
2272
|
+
case "se":
|
|
2273
|
+
return { sheetDir: "se", flipX: false };
|
|
2274
|
+
case "sw":
|
|
2275
|
+
return { sheetDir: "sw", flipX: false };
|
|
2276
|
+
case "ne":
|
|
2277
|
+
return { sheetDir: "sw", flipX: true };
|
|
2278
|
+
case "nw":
|
|
2279
|
+
return { sheetDir: "se", flipX: true };
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
function frameRect(frame, row, columns, frameWidth, frameHeight) {
|
|
2283
|
+
return {
|
|
2284
|
+
sx: frame % columns * frameWidth,
|
|
2285
|
+
sy: row * frameHeight,
|
|
2286
|
+
sw: frameWidth,
|
|
2287
|
+
sh: frameHeight
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
function getCurrentFrameFromDef(def, elapsed) {
|
|
2291
|
+
const frameDuration = 1e3 / def.frameRate;
|
|
2292
|
+
const totalDuration = def.frames * frameDuration;
|
|
2293
|
+
if (def.loop) {
|
|
2294
|
+
const frame2 = Math.floor(elapsed % totalDuration / frameDuration);
|
|
2295
|
+
return { frame: frame2, finished: false };
|
|
2296
|
+
}
|
|
2297
|
+
if (elapsed >= totalDuration) {
|
|
2298
|
+
return { frame: def.frames - 1, finished: true };
|
|
2299
|
+
}
|
|
2300
|
+
const frame = Math.floor(elapsed / frameDuration);
|
|
2301
|
+
return { frame, finished: false };
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// components/game/molecules/useUnitSpriteAtlas.ts
|
|
2305
|
+
var WALK_HOLD_MS = 600;
|
|
2306
|
+
function unitAtlasUrl(unit) {
|
|
2307
|
+
if (unit.spriteSheet) return unit.spriteSheet;
|
|
2308
|
+
const sprite = unit.sprite;
|
|
2309
|
+
if (!sprite) return null;
|
|
2310
|
+
const match = /^(.*-sprite-sheet)(?:-(?:se|sw))?(?:-v\d+)?\.png$/.exec(sprite);
|
|
2311
|
+
if (!match) return null;
|
|
2312
|
+
return `${match[1]}.json`;
|
|
2313
|
+
}
|
|
2314
|
+
function resolveSheetUrl(atlasUrl, relativeSheetPath) {
|
|
2315
|
+
try {
|
|
2316
|
+
return new URL(relativeSheetPath, atlasUrl).toString();
|
|
2317
|
+
} catch {
|
|
2318
|
+
const base = atlasUrl.slice(0, atlasUrl.lastIndexOf("/") + 1);
|
|
2319
|
+
return `${base}${relativeSheetPath.replace(/^\.\//, "")}`;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
function useUnitSpriteAtlas(units) {
|
|
2323
|
+
const atlasCacheRef = useRef(/* @__PURE__ */ new Map());
|
|
2324
|
+
const loadingRef = useRef(/* @__PURE__ */ new Set());
|
|
2325
|
+
const [pendingCount, setPendingCount] = useState(0);
|
|
2326
|
+
const [, forceTick] = useState(0);
|
|
2327
|
+
const animStatesRef = useRef(/* @__PURE__ */ new Map());
|
|
2328
|
+
const lastTickRef = useRef(0);
|
|
2329
|
+
const rafRef = useRef(0);
|
|
2330
|
+
const atlasUrls = useMemo(() => {
|
|
2331
|
+
const set = /* @__PURE__ */ new Set();
|
|
2332
|
+
for (const unit of units) {
|
|
2333
|
+
const url = unitAtlasUrl(unit);
|
|
2334
|
+
if (url) set.add(url);
|
|
2335
|
+
}
|
|
2336
|
+
return [...set];
|
|
2337
|
+
}, [units]);
|
|
2338
|
+
useEffect(() => {
|
|
2339
|
+
const cache = atlasCacheRef.current;
|
|
2340
|
+
const loading = loadingRef.current;
|
|
2341
|
+
const toLoad = atlasUrls.filter((url) => !cache.has(url) && !loading.has(url));
|
|
2342
|
+
if (toLoad.length === 0) return;
|
|
2343
|
+
let cancelled = false;
|
|
2344
|
+
setPendingCount((prev) => prev + toLoad.length);
|
|
2345
|
+
for (const url of toLoad) {
|
|
2346
|
+
loading.add(url);
|
|
2347
|
+
fetch(url).then((res) => res.ok ? res.json() : Promise.reject(new Error(String(res.status)))).then((atlas) => {
|
|
2348
|
+
if (cancelled) return;
|
|
2349
|
+
cache.set(url, atlas);
|
|
2350
|
+
}).catch(() => {
|
|
2351
|
+
}).finally(() => {
|
|
2352
|
+
if (cancelled) return;
|
|
2353
|
+
loading.delete(url);
|
|
2354
|
+
setPendingCount((prev) => Math.max(0, prev - 1));
|
|
2355
|
+
forceTick((n) => n + 1);
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
return () => {
|
|
2359
|
+
cancelled = true;
|
|
2360
|
+
};
|
|
2361
|
+
}, [atlasUrls]);
|
|
2362
|
+
const sheetUrls = useMemo(() => {
|
|
2363
|
+
const urls = /* @__PURE__ */ new Set();
|
|
2364
|
+
for (const unit of units) {
|
|
2365
|
+
const atlasUrl = unitAtlasUrl(unit);
|
|
2366
|
+
if (!atlasUrl) continue;
|
|
2367
|
+
const atlas = atlasCacheRef.current.get(atlasUrl);
|
|
2368
|
+
if (!atlas) continue;
|
|
2369
|
+
for (const rel of Object.values(atlas.sheets)) {
|
|
2370
|
+
if (rel) urls.add(resolveSheetUrl(atlasUrl, rel));
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
return [...urls];
|
|
2374
|
+
}, [units, pendingCount]);
|
|
2375
|
+
useEffect(() => {
|
|
2376
|
+
const hasAtlasUnits = units.some((u) => unitAtlasUrl(u) !== null);
|
|
2377
|
+
if (!hasAtlasUnits) return;
|
|
2378
|
+
let running = true;
|
|
2379
|
+
const tick = (ts) => {
|
|
2380
|
+
if (!running) return;
|
|
2381
|
+
const last = lastTickRef.current || ts;
|
|
2382
|
+
const delta = ts - last;
|
|
2383
|
+
lastTickRef.current = ts;
|
|
2384
|
+
const states = animStatesRef.current;
|
|
2385
|
+
const currentIds = /* @__PURE__ */ new Set();
|
|
2386
|
+
for (const unit of units) {
|
|
2387
|
+
if (unitAtlasUrl(unit) === null) continue;
|
|
2388
|
+
currentIds.add(unit.id);
|
|
2389
|
+
let state = states.get(unit.id);
|
|
2390
|
+
if (!state) {
|
|
2391
|
+
state = { animation: "idle", direction: "se", elapsed: 0, walkHold: 0, prev: null };
|
|
2392
|
+
states.set(unit.id, state);
|
|
2393
|
+
}
|
|
2394
|
+
const posX = unit.position?.x ?? unit.x ?? 0;
|
|
2395
|
+
const posY = unit.position?.y ?? unit.y ?? 0;
|
|
2396
|
+
if (state.prev) {
|
|
2397
|
+
const dx = posX - state.prev.x;
|
|
2398
|
+
const dy = posY - state.prev.y;
|
|
2399
|
+
if (dx !== 0 || dy !== 0) {
|
|
2400
|
+
state.animation = "walk";
|
|
2401
|
+
state.direction = inferDirection(dx, dy);
|
|
2402
|
+
state.walkHold = WALK_HOLD_MS;
|
|
2403
|
+
} else if (state.animation === "walk") {
|
|
2404
|
+
state.walkHold -= delta;
|
|
2405
|
+
if (state.walkHold <= 0) state.animation = "idle";
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
state.prev = { x: posX, y: posY };
|
|
2409
|
+
state.elapsed += delta;
|
|
2410
|
+
}
|
|
2411
|
+
for (const id of states.keys()) {
|
|
2412
|
+
if (!currentIds.has(id)) states.delete(id);
|
|
2413
|
+
}
|
|
2414
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
2415
|
+
};
|
|
2416
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
2417
|
+
return () => {
|
|
2418
|
+
running = false;
|
|
2419
|
+
cancelAnimationFrame(rafRef.current);
|
|
2420
|
+
lastTickRef.current = 0;
|
|
2421
|
+
};
|
|
2422
|
+
}, [units]);
|
|
2423
|
+
const resolveUnitFrame = useCallback((unitId) => {
|
|
2424
|
+
const unit = units.find((u) => u.id === unitId);
|
|
2425
|
+
if (!unit) return null;
|
|
2426
|
+
const atlasUrl = unitAtlasUrl(unit);
|
|
2427
|
+
if (!atlasUrl) return null;
|
|
2428
|
+
const atlas = atlasCacheRef.current.get(atlasUrl);
|
|
2429
|
+
if (!atlas) return null;
|
|
2430
|
+
const state = animStatesRef.current.get(unitId);
|
|
2431
|
+
const animation = state?.animation ?? "idle";
|
|
2432
|
+
const direction = state?.direction ?? "se";
|
|
2433
|
+
const elapsed = state?.elapsed ?? 0;
|
|
2434
|
+
const def = atlas.animations[animation] ?? atlas.animations.idle;
|
|
2435
|
+
if (!def) return null;
|
|
2436
|
+
const { sheetDir, flipX } = resolveSheetDirection(direction);
|
|
2437
|
+
const rel = atlas.sheets[sheetDir] ?? atlas.sheets.se ?? atlas.sheets.sw;
|
|
2438
|
+
if (!rel) return null;
|
|
2439
|
+
const sheetUrl = resolveSheetUrl(atlasUrl, rel);
|
|
2440
|
+
const isIdle = animation === "idle";
|
|
2441
|
+
const frame = isIdle ? 0 : getCurrentFrameFromDef(def, elapsed).frame;
|
|
2442
|
+
const rect = frameRect(frame, def.row, atlas.columns, atlas.frameWidth, atlas.frameHeight);
|
|
2443
|
+
return {
|
|
2444
|
+
sheetUrl,
|
|
2445
|
+
sx: rect.sx,
|
|
2446
|
+
sy: rect.sy,
|
|
2447
|
+
sw: rect.sw,
|
|
2448
|
+
sh: rect.sh,
|
|
2449
|
+
flipX,
|
|
2450
|
+
applyBreathing: isIdle
|
|
2451
|
+
};
|
|
2452
|
+
}, [units]);
|
|
2453
|
+
return { sheetUrls, resolveUnitFrame, pendingCount };
|
|
2454
|
+
}
|
|
2261
2455
|
function cn(...inputs) {
|
|
2262
2456
|
return twMerge(clsx(inputs));
|
|
2263
2457
|
}
|
|
@@ -2281,6 +2475,47 @@ function CameraController({
|
|
|
2281
2475
|
}, [camera.position, onCameraChange]);
|
|
2282
2476
|
return null;
|
|
2283
2477
|
}
|
|
2478
|
+
function UnitSpriteBillboard({
|
|
2479
|
+
sheetUrl,
|
|
2480
|
+
resolveFrame,
|
|
2481
|
+
height = 1.2
|
|
2482
|
+
}) {
|
|
2483
|
+
const texture = useLoader(THREE6.TextureLoader, sheetUrl);
|
|
2484
|
+
const meshRef = useRef(null);
|
|
2485
|
+
const matRef = useRef(null);
|
|
2486
|
+
const [aspect, setAspect] = useState(1);
|
|
2487
|
+
useFrame(() => {
|
|
2488
|
+
const frame = resolveFrame();
|
|
2489
|
+
if (!frame || !texture.image) return;
|
|
2490
|
+
const imgW = texture.image.width;
|
|
2491
|
+
const imgH = texture.image.height;
|
|
2492
|
+
if (!imgW || !imgH) return;
|
|
2493
|
+
texture.repeat.set((frame.flipX ? -1 : 1) * (frame.sw / imgW), frame.sh / imgH);
|
|
2494
|
+
texture.offset.set(
|
|
2495
|
+
frame.flipX ? (frame.sx + frame.sw) / imgW : frame.sx / imgW,
|
|
2496
|
+
1 - (frame.sy + frame.sh) / imgH
|
|
2497
|
+
);
|
|
2498
|
+
texture.magFilter = THREE6.NearestFilter;
|
|
2499
|
+
texture.minFilter = THREE6.NearestFilter;
|
|
2500
|
+
texture.needsUpdate = true;
|
|
2501
|
+
const nextAspect = frame.sw / frame.sh;
|
|
2502
|
+
if (Math.abs(nextAspect - aspect) > 1e-3) setAspect(nextAspect);
|
|
2503
|
+
if (matRef.current) matRef.current.needsUpdate = true;
|
|
2504
|
+
});
|
|
2505
|
+
return /* @__PURE__ */ jsxs("mesh", { ref: meshRef, position: [0, height / 2, 0], children: [
|
|
2506
|
+
/* @__PURE__ */ jsx("planeGeometry", { args: [height * aspect, height] }),
|
|
2507
|
+
/* @__PURE__ */ jsx(
|
|
2508
|
+
"meshBasicMaterial",
|
|
2509
|
+
{
|
|
2510
|
+
ref: matRef,
|
|
2511
|
+
map: texture,
|
|
2512
|
+
transparent: true,
|
|
2513
|
+
alphaTest: 0.1,
|
|
2514
|
+
side: THREE6.DoubleSide
|
|
2515
|
+
}
|
|
2516
|
+
)
|
|
2517
|
+
] });
|
|
2518
|
+
}
|
|
2284
2519
|
var GameCanvas3D = forwardRef(
|
|
2285
2520
|
({
|
|
2286
2521
|
tiles = [],
|
|
@@ -2330,8 +2565,10 @@ var GameCanvas3D = forwardRef(
|
|
|
2330
2565
|
const controlsRef = useRef(null);
|
|
2331
2566
|
const [hoveredTile, setHoveredTile] = useState(null);
|
|
2332
2567
|
const [internalError, setInternalError] = useState(null);
|
|
2568
|
+
const { sheetUrls: atlasSheetUrls, resolveUnitFrame } = useUnitSpriteAtlas(units);
|
|
2569
|
+
const preloadUrls = useMemo(() => [...preloadAssets, ...atlasSheetUrls], [preloadAssets, atlasSheetUrls]);
|
|
2333
2570
|
const { isLoading: assetsLoading, progress, loaded, total } = useAssetLoader({
|
|
2334
|
-
preloadUrls
|
|
2571
|
+
preloadUrls,
|
|
2335
2572
|
loader: customAssetLoader
|
|
2336
2573
|
});
|
|
2337
2574
|
const eventHandlers = useGameCanvas3DEvents({
|
|
@@ -2542,6 +2779,8 @@ var GameCanvas3D = forwardRef(
|
|
|
2542
2779
|
({ unit, position }) => {
|
|
2543
2780
|
const isSelected = selectedUnitId === unit.id;
|
|
2544
2781
|
const color = unit.faction === "player" ? 4491519 : unit.faction === "enemy" ? 16729156 : 16777028;
|
|
2782
|
+
const hasAtlas = unitAtlasUrl(unit) !== null;
|
|
2783
|
+
const initialFrame = hasAtlas ? resolveUnitFrame(unit.id) : null;
|
|
2545
2784
|
return /* @__PURE__ */ jsxs(
|
|
2546
2785
|
"group",
|
|
2547
2786
|
{
|
|
@@ -2553,7 +2792,16 @@ var GameCanvas3D = forwardRef(
|
|
|
2553
2792
|
/* @__PURE__ */ jsx("ringGeometry", { args: [0.4, 0.5, 32] }),
|
|
2554
2793
|
/* @__PURE__ */ jsx("meshBasicMaterial", { color: "#ffff00", transparent: true, opacity: 0.8 })
|
|
2555
2794
|
] }),
|
|
2556
|
-
|
|
2795
|
+
hasAtlas && initialFrame ? (
|
|
2796
|
+
/* Animated sprite-sheet billboard — single cropped frame, by state */
|
|
2797
|
+
/* @__PURE__ */ jsx(Billboard, { children: /* @__PURE__ */ jsx(
|
|
2798
|
+
UnitSpriteBillboard,
|
|
2799
|
+
{
|
|
2800
|
+
sheetUrl: initialFrame.sheetUrl,
|
|
2801
|
+
resolveFrame: () => resolveUnitFrame(unit.id)
|
|
2802
|
+
}
|
|
2803
|
+
) })
|
|
2804
|
+
) : unit.modelUrl ? (
|
|
2557
2805
|
/* GLB unit model (box fallback while loading / on error) */
|
|
2558
2806
|
/* @__PURE__ */ jsx(
|
|
2559
2807
|
ModelLoader,
|
|
@@ -2607,7 +2855,7 @@ var GameCanvas3D = forwardRef(
|
|
|
2607
2855
|
}
|
|
2608
2856
|
);
|
|
2609
2857
|
},
|
|
2610
|
-
[selectedUnitId, handleUnitClick]
|
|
2858
|
+
[selectedUnitId, handleUnitClick, resolveUnitFrame]
|
|
2611
2859
|
);
|
|
2612
2860
|
const DefaultFeatureRenderer = useCallback(
|
|
2613
2861
|
({
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AssetUrl } from '@almadar/core';
|
|
2
|
+
import type { IsometricUnit } from '../organisms/types/isometric';
|
|
3
|
+
import type { ResolvedFrame } from '../organisms/types/spriteAnimation';
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the sprite-sheet atlas-JSON URL for a unit.
|
|
6
|
+
*
|
|
7
|
+
* An explicit `unit.spriteSheet` wins. Otherwise, when the unit's static
|
|
8
|
+
* `sprite` points at a directional sheet PNG, derive its sibling atlas JSON by
|
|
9
|
+
* the deterministic naming convention used in `shared/sprite-sheets/`:
|
|
10
|
+
* the shared atlas drops the `-se` / `-sw` direction suffix and any `-vN`
|
|
11
|
+
* variant suffix, then swaps `.png` → `.json`
|
|
12
|
+
* (`amir-sprite-sheet-se.png` → `amir-sprite-sheet.json`).
|
|
13
|
+
*
|
|
14
|
+
* Returns null when no atlas can be resolved (the unit keeps its static draw).
|
|
15
|
+
*/
|
|
16
|
+
export declare function unitAtlasUrl(unit: Pick<IsometricUnit, 'spriteSheet' | 'sprite'>): AssetUrl | null;
|
|
17
|
+
export interface UseUnitSpriteAtlasResult {
|
|
18
|
+
/** Atlas-JSON URLs successfully loaded — feed these to no one; sheet PNGs are returned below. */
|
|
19
|
+
sheetUrls: string[];
|
|
20
|
+
/** Resolve the current single-frame rect for a unit, or null if its atlas isn't loaded. */
|
|
21
|
+
resolveUnitFrame: (unitId: string) => ResolvedFrame | null;
|
|
22
|
+
/** Whether any atlas is still loading (drives isLoading / keeps RAF alive). */
|
|
23
|
+
pendingCount: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Load per-unit sprite atlases and drive single-frame animation.
|
|
27
|
+
*
|
|
28
|
+
* @param units - Units to animate. Only units with a `spriteSheet` URL participate.
|
|
29
|
+
*/
|
|
30
|
+
export declare function useUnitSpriteAtlas(units: IsometricUnit[]): UseUnitSpriteAtlasResult;
|
|
@@ -51,6 +51,10 @@ export type IsometricUnit = {
|
|
|
51
51
|
z?: number;
|
|
52
52
|
/** Static sprite URL (used when no sprite sheet animation) */
|
|
53
53
|
sprite?: AssetUrl;
|
|
54
|
+
/** Sprite-sheet atlas JSON URL (e.g. `.../guardian-sprite-sheet.json`).
|
|
55
|
+
* When set, the canvas loads the atlas and crops/animates a single frame
|
|
56
|
+
* by animation state instead of drawing the whole sheet. */
|
|
57
|
+
spriteSheet?: AssetUrl;
|
|
54
58
|
/** 3D model URL (GLB format) for GameCanvas3D — rendered via ModelLoader with box fallback */
|
|
55
59
|
modelUrl?: AssetUrl;
|
|
56
60
|
/** Unit archetype key for sprite resolution */
|
|
@@ -72,3 +72,27 @@ export interface SpriteSheetUrls {
|
|
|
72
72
|
/** Southwest-facing sheet URL */
|
|
73
73
|
sw: AssetUrl;
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Parsed sprite-sheet atlas JSON (e.g. `guardian-sprite-sheet.json`).
|
|
77
|
+
* Sits next to the `.png` sheets and drives the frame rect deterministically.
|
|
78
|
+
*/
|
|
79
|
+
export interface SpriteAtlas {
|
|
80
|
+
/** Unit archetype key */
|
|
81
|
+
unit?: string;
|
|
82
|
+
/** Visual type key */
|
|
83
|
+
type?: string;
|
|
84
|
+
/** Width of a single frame in pixels */
|
|
85
|
+
frameWidth: number;
|
|
86
|
+
/** Height of a single frame in pixels */
|
|
87
|
+
frameHeight: number;
|
|
88
|
+
/** Number of columns (frames per row) */
|
|
89
|
+
columns: number;
|
|
90
|
+
/** Number of rows (animations) */
|
|
91
|
+
rows: number;
|
|
92
|
+
/** Directions present as physical PNG files */
|
|
93
|
+
directions: SpriteDirection[];
|
|
94
|
+
/** Relative PNG sheet paths per direction */
|
|
95
|
+
sheets: Partial<Record<SpriteDirection, string>>;
|
|
96
|
+
/** Animation row layout keyed by animation name */
|
|
97
|
+
animations: Partial<Record<AnimationName, AnimationDef>>;
|
|
98
|
+
}
|