diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 994ff38..473cbe5 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -42,7 +42,7 @@ import { frameDurationMsFromAnimSpeed } from '../src/game/animated-tiles.ts' import { planLevelMonsters } from '../src/game/monsters.ts' import type { IsoMapScene } from '../src/game/d2map.ts' import { SUB_TILES_PER_TILE } from '../src/game/map.ts' -import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts' +import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT, OBJECT_MODE_TOKENS } from '../src/game/objects.ts' import { decodeDcc } from '../src/formats/dcc.ts' import { decodeDc6 } from '../src/formats/dc6.ts' import type { SpriteFrame } from '../src/formats/sprite.ts' @@ -961,8 +961,18 @@ function pickObjectMember(token: string, modeToken: string, baseIsMonsters = fal return fallback === undefined ? null : { member: fallback, candidates } } +interface DecodedMemberFrame { + frame: SpriteFrame + offsetX: number + offsetY: number +} + +interface DecodedMemberArt { + frames: DecodedMemberFrame[] +} + /** Cache decoded member frames across levels so each unique member is decoded once. */ -const decodedMemberCache = new Map() +const decodedMemberCache = new Map() const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt')) const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) @@ -1301,7 +1311,12 @@ async function bakeDs1Variant( const npcs: unknown[] = [] const missingObjects: string[] = [] let objectsWithArt = 0 - const placementByMember = new Map() + interface PlacedMemberFrame { + placement: Placement + offsetX: number + offsetY: number + } + const placementByMember = new Map() for (const object of level.objects) { let resolved @@ -1334,6 +1349,15 @@ async function bakeDs1Variant( offsetX: number offsetY: number } | null = null + let animatedFrames: { + page: number + x: number + y: number + width: number + height: number + offsetX: number + offsetY: number + }[] | undefined = undefined if (pick !== null) { let placed = placementByMember.get(pick.member) @@ -1345,24 +1369,24 @@ async function bakeDs1Variant( if (pick.member.toLowerCase().endsWith('.dc6')) { const sheet = decodeDc6(bytes) const group = sheet.groups[0] - const f = group?.frames[0] - if (f) { - art = { + if (group && group.frames.length > 0) { + const frames: DecodedMemberFrame[] = group.frames.map(f => ({ frame: f, offsetX: -16 + f.offsetX, offsetY: 16 + (f.offsetY - f.height + 1), - } + })) + art = { frames } } } else { const dcc = decodeDcc(bytes) const dir = dcc.directions[0] - const f = dir?.frames[0] - if (f && dir) { - art = { + if (dir && dir.frames.length > 0) { + const frames: DecodedMemberFrame[] = dir.frames.map(f => ({ frame: f.frame, offsetX: -16 + dir.box.left, offsetY: 16 + dir.box.top, - } + })) + art = { frames } } } if (art) decodedMemberCache.set(pick.member, art) @@ -1370,21 +1394,37 @@ async function bakeDs1Variant( console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`) } } - if (art) { - const placement = objectPages.add(art.frame) - placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY } + if (art && art.frames.length > 0) { + const placedFrames: PlacedMemberFrame[] = art.frames.map(item => ({ + placement: objectPages.add(item.frame), + offsetX: item.offsetX, + offsetY: item.offsetY, + })) + placed = { frames: placedFrames } placementByMember.set(pick.member, placed) } } - if (placed) { + if (placed && placed.frames.length > 0) { + const f0 = placed.frames[0]! objectFrame = { - page: placed.placement.page, - x: placed.placement.x, - y: placed.placement.y, - width: placed.placement.width, - height: placed.placement.height, - offsetX: placed.offsetX, - offsetY: placed.offsetY, + page: f0.placement.page, + x: f0.placement.x, + y: f0.placement.y, + width: f0.placement.width, + height: f0.placement.height, + offsetX: f0.offsetX, + offsetY: f0.offsetY, + } + if (placed.frames.length > 1) { + animatedFrames = placed.frames.map(f => ({ + page: f.placement.page, + x: f.placement.x, + y: f.placement.y, + width: f.placement.width, + height: f.placement.height, + offsetX: f.offsetX, + offsetY: f.offsetY, + })) } } } @@ -1396,12 +1436,26 @@ async function bakeDs1Variant( if (resolved.token.toLowerCase() === 'wp' || /waypoint/i.test(resolvedName)) { waypointCells.push({ x: object.x, y: object.y }) } + + const modeToken = resolved.mode === '' ? 'NU' : resolved.mode + const modeIndex = OBJECT_MODE_TOKENS.indexOf(modeToken as any) + let frameDelta = 256 + let cycleAnim = true + let sync = false + if (row !== null && modeIndex >= 0) { + const delta = row.frameDelta[modeIndex] ?? 0 + if (delta > 0) frameDelta = delta + cycleAnim = (row.cycleAnim[modeIndex] ?? 1) !== 0 + sync = (row.sync ?? 0) === 1 + } + const frameDurationMs = Math.round((256 / frameDelta) * 40 * 100) / 100 + list.push({ id: object.id, type: object.type, name: resolvedName, token: resolved.token, - mode: resolved.mode === '' ? 'NU' : resolved.mode, + mode: modeToken, // `objectsTxtId` is the Objects.txt row the table points at; -1 means the // engine picks the art straight from the table's token, with no row. objectsTxtId: resolved.entry?.objectsTxtId ?? -1, @@ -1414,6 +1468,13 @@ async function bakeDs1Variant( y: Math.round(orthoY), depth: (object.x + object.y) / 5, frame: objectFrame, + ...(animatedFrames ? { + animatedFrames, + frameDelta, + frameDurationMs, + cycleAnim, + sync, + } : {}), }) if (pick !== null) objectsWithArt += 1 else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`) diff --git a/scripts/pack-entity-assets.ts b/scripts/pack-entity-assets.ts index 0193576..fede7ec 100644 --- a/scripts/pack-entity-assets.ts +++ b/scripts/pack-entity-assets.ts @@ -398,8 +398,10 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ height: packedCharR8.height, directions: charClips.wl.directions, clips: charClips, - walk: charClips.wl.group, - stand: charClips.nu.group, + walk: charClips.wl.frames, + stand: charClips.nu.frames, + walkOffset: charClips.wl.group, + standOffset: charClips.nu.group, members: totalCharMembers, groups: packedCharR8.groups, }), diff --git a/scripts/verify-objects.ts b/scripts/verify-objects.ts index d47b251..bf10cc1 100644 --- a/scripts/verify-objects.ts +++ b/scripts/verify-objects.ts @@ -393,7 +393,7 @@ async function walkPresetObjects( id: object.id, name: resolved.token, token: resolved.token, subClass: 0, act: info.act, sizeX: 0, sizeY: 0, xOffset: 0, yOffset: 0, isDoor: false, trans: 0, draw: 0, totalPieces: 0, autoMap: 0, mode: [], selectable: [], frameCnt: [], frameDelta: [], - start: [], cycleAnim: [], lit: [], components: [], + start: [], cycleAnim: [], lit: [], sync: 0, components: [], }, member: art.member, mode: art.mode, diff --git a/src/game/objects.ts b/src/game/objects.ts index 1ecb757..c8bf427 100644 --- a/src/game/objects.ts +++ b/src/game/objects.ts @@ -360,6 +360,8 @@ export interface ObjectsRow { readonly cycleAnim: readonly number[] /** `Lit0..7`: whether the mode is drawn lit. */ readonly lit: readonly number[] + /** `Sync`: whether all instances of this object are animation-synchronized. */ + readonly sync: number /** `HD`, `TR`, `LG`, `RA`, `LA`, `RH`, `LH`, `SH`, `S1..S8`: composition components present. */ readonly components: readonly string[] } @@ -411,6 +413,7 @@ function toObjectsRow(table: D2Table, row: readonly string[]): ObjectsRow { start: numbers('Start', OBJECT_MODE_COUNT), cycleAnim: numbers('CycleAnim', OBJECT_MODE_COUNT), lit: numbers('Lit', OBJECT_MODE_COUNT), + sync: Number(cell(table, row, 'Sync') || '0'), components, } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index dd172f7..cde5464 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -296,6 +296,8 @@ export interface ActSceneState { npcsNear: NpcEntity[] /** Count of animated tiles in the active map. */ animatedTiles: number + /** Count of animated map objects in the active map. */ + animatedObjects: number /** Animation speed from LvlPrest.txt `Animate` column (or undefined if not specified). */ animSpeed?: number | undefined /** Per-frame duration in ms computed from animSpeed. */ @@ -439,7 +441,7 @@ const state: ActSceneState = { missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0, drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0, zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null, - dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, + dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, animatedObjects: 0, animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS, missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0, } @@ -458,14 +460,99 @@ interface Drawable extends AnimatableTile { readonly roofHeight?: number } -/** One object sprite to draw. */ -interface ObjectDrawable { +/** One placed frame of an animated object. */ +export interface AnimatedObjectFrame { readonly frame: AtlasFrame - readonly x: number - readonly y: number + readonly page: number + readonly offsetX: number + readonly offsetY: number +} + +/** One object sprite to draw. */ +export interface ObjectDrawable { + frame: AtlasFrame + x: number + y: number readonly depth: number /** Object atlas page index this frame lives on. */ - readonly page: number + page: number + readonly baseX?: number + readonly baseY?: number + readonly animatedFrames?: readonly AnimatedObjectFrame[] + readonly frameDurationMs?: number + readonly cycleAnim?: boolean + readonly phaseOffsetMs?: number + currentFrameIndex?: number +} + +/** + * SplitMix64 based spatial phase offset for unsynchronized animated map objects. + * Prevents mechanical synchronization across multiple torches or campfires in a scene. + * + * @param x - object sub-tile or world x + * @param y - object sub-tile or world y + * @param totalDurationMs - total duration of one animation cycle in milliseconds + * @returns phase offset in milliseconds within [0, totalDurationMs) + */ +export function computeObjectPhaseOffsetMs(x: number, y: number, totalDurationMs: number): number { + if (totalDurationMs <= 0) return 0 + let h = (Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) >>> 0 + h = (h ^ (h >>> 16)) >>> 0 + h = Math.imul(h, 0x45d9f3b) >>> 0 + h = (h ^ (h >>> 16)) >>> 0 + return ((h % 1000000) / 1000000) * totalDurationMs +} + +/** + * Update one animated object's current frame based on time and spatial phase. + * + * @param obj - object drawable + * @param clockMs - current clock time in milliseconds + * @returns true if frame changed + */ +export function updateAnimatedObject(obj: ObjectDrawable, clockMs: number): boolean { + const frames = obj.animatedFrames + if (!frames || frames.length <= 1) return false + const duration = (obj.frameDurationMs && obj.frameDurationMs > 0) ? obj.frameDurationMs : 40 + const phase = obj.phaseOffsetMs ?? 0 + const elapsed = Math.max(0, clockMs + phase) + const targetIndex = obj.cycleAnim !== false + ? Math.floor(elapsed / duration) % frames.length + : Math.min(frames.length - 1, Math.floor(elapsed / duration)) + + if (targetIndex === obj.currentFrameIndex) return false + obj.currentFrameIndex = targetIndex + const targetFrame = frames[targetIndex] + if (targetFrame !== undefined) { + obj.frame = targetFrame.frame + obj.page = targetFrame.page + if (obj.baseX !== undefined && obj.baseY !== undefined) { + obj.x = obj.baseX + targetFrame.offsetX + obj.y = obj.baseY + targetFrame.offsetY + } + return true + } + return false +} + +/** + * Update an array of animated objects according to millisecond clock. + * + * @param objects - array of object drawables + * @param clockMs - current clock time in milliseconds + * @returns number of objects whose active frame changed + */ +export function updateAnimatedObjects( + objects: readonly ObjectDrawable[], + clockMs: number, +): number { + let updated = 0 + for (let i = 0; i < objects.length; i += 1) { + if (updateAnimatedObject(objects[i]!, clockMs)) { + updated += 1 + } + } + return updated } /** Town Stash entity (authentic 1.13c Objects.txt row 267 B6). */ @@ -612,6 +699,8 @@ export interface MapRuntime { readonly frameDurationMs?: number | undefined /** Object draws: interleaved with walls in painter's order. */ readonly objectDrawables: readonly ObjectDrawable[] + /** Animated object drawables whose frames cycle during rendering. */ + readonly animatedObjects?: readonly ObjectDrawable[] | undefined readonly spawn: { x: number; y: number } readonly npcs: readonly PackObject[] /** @@ -1939,6 +2028,11 @@ interface PackObject { readonly y: number readonly depth?: number readonly frame?: PackObjectFrame | null + readonly animatedFrames?: readonly PackObjectFrame[] | null + readonly frameDelta?: number + readonly frameDurationMs?: number + readonly cycleAnim?: boolean + readonly sync?: boolean } /** A pack's per-map scene file. */ @@ -2181,6 +2275,34 @@ async function buildPackRuntime( const packObjects = [...scene.objects, ...(scene.npcs ?? [])] for (const obj of packObjects) { if (hasPackedSprite(obj)) { + const hasAnim = obj.animatedFrames !== undefined && obj.animatedFrames !== null && obj.animatedFrames.length > 1 + const animatedFrames: AnimatedObjectFrame[] | undefined = hasAnim + ? obj.animatedFrames!.map(f => ({ + frame: { + x: f.x, + y: f.y, + width: f.width, + height: f.height, + }, + page: f.page, + offsetX: f.offsetX, + offsetY: f.offsetY, + })) + : undefined + + const frameDurationMs = obj.frameDurationMs ?? ( + obj.frameDelta && obj.frameDelta > 0 + ? Math.round((256 / obj.frameDelta) * 40 * 100) / 100 + : 40 + ) + const cycleAnim = obj.cycleAnim ?? true + + let phaseOffsetMs = 0 + if (hasAnim && !obj.sync) { + const totalDurationMs = obj.animatedFrames!.length * frameDurationMs + phaseOffsetMs = computeObjectPhaseOffsetMs(obj.x, obj.y, totalDurationMs) + } + objectDrawables.push({ frame: { x: obj.frame.x, @@ -2192,6 +2314,15 @@ async function buildPackRuntime( y: obj.y + obj.frame.offsetY, depth: obj.depth ?? ((obj.x + obj.y) / 5), page: obj.frame.page, + baseX: obj.x, + baseY: obj.y, + ...(animatedFrames ? { + animatedFrames, + frameDurationMs, + cycleAnim, + phaseOffsetMs, + currentFrameIndex: 0, + } : {}), }) } } @@ -2294,6 +2425,7 @@ async function buildPackRuntime( animSpeed: scene.animSpeed, frameDurationMs: scene.frameDurationMs ?? (scene.animSpeed !== undefined ? frameDurationMsFromAnimSpeed(scene.animSpeed) : DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS), objectDrawables, + animatedObjects: objectDrawables.filter(d => d.animatedFrames && d.animatedFrames.length > 1), spawn, npcs: scene.npcs ?? [], monsterPacks: scene.monsters?.packs ?? [], @@ -3715,6 +3847,7 @@ if (typeof window !== 'undefined') { state.pagesTotal = next.pages.length state.pagesLoaded = 0 state.animatedTiles = next.animatedDrawables?.length ?? 0 + state.animatedObjects = next.animatedObjects?.length ?? 0 state.animSpeed = next.animSpeed state.frameDurationMs = next.frameDurationMs @@ -4089,6 +4222,9 @@ if (typeof window !== 'undefined') { if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) { updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time', runtime.frameDurationMs) } + if (runtime.animatedObjects && runtime.animatedObjects.length > 0) { + updateAnimatedObjects(runtime.animatedObjects, renderStarted) + } if (state.pagesAtFirstFrame < 0) { state.pagesAtFirstFrame = countPages() state.firstFrameMs = Math.round(performance.now() - started) @@ -4612,6 +4748,7 @@ function publish(runtime: MapRuntime, base: string): void { state.objects = runtime.objects state.roofs = runtime.roofs.length state.animatedTiles = runtime.animatedDrawables?.length ?? 0 + state.animatedObjects = runtime.animatedObjects?.length ?? 0 state.animSpeed = runtime.animSpeed state.frameDurationMs = runtime.frameDurationMs state.roofAlpha = 1 diff --git a/tests/object-animation-113c.test.ts b/tests/object-animation-113c.test.ts new file mode 100644 index 0000000..9b88c48 --- /dev/null +++ b/tests/object-animation-113c.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { + computeObjectPhaseOffsetMs, + updateAnimatedObject, + updateAnimatedObjects, + type ObjectDrawable, + type AnimatedObjectFrame, +} from '../src/scene/act-scene.ts' + +describe('Diablo II 1.13c Map Object Animations (Issue #387)', () => { + describe('Spatial phase offset (SplitMix64 anti-resonance)', () => { + it('returns 0 when totalDurationMs is 0 or negative', () => { + expect(computeObjectPhaseOffsetMs(100, 200, 0)).toBe(0) + expect(computeObjectPhaseOffsetMs(100, 200, -10)).toBe(0) + }) + + it('is strictly deterministic for identical coordinates', () => { + const p1 = computeObjectPhaseOffsetMs(152, 364, 1024) + const p2 = computeObjectPhaseOffsetMs(152, 364, 1024) + expect(p1).toBe(p2) + }) + + it('produces diverse phase offsets for different torch locations', () => { + const torchCoords = [ + { x: 100, y: 200 }, + { x: 105, y: 200 }, + { x: 120, y: 250 }, + { x: 300, y: 400 }, + { x: 310, y: 400 }, + { x: 500, y: 600 }, + ] + const totalDuration = 20 * 51.2 // 1024ms for 20-frame torch + const phases = torchCoords.map(c => computeObjectPhaseOffsetMs(c.x, c.y, totalDuration)) + + // All phases must be within [0, totalDuration) + for (const phase of phases) { + expect(phase).toBeGreaterThanOrEqual(0) + expect(phase).toBeLessThan(totalDuration) + } + + // Check entropy: no two distinct torch coordinates should have the exact same phase + const uniquePhases = new Set(phases) + expect(uniquePhases.size).toBe(torchCoords.length) + }) + }) + + describe('Runtime frame ticking (updateAnimatedObject)', () => { + const dummyAtlasFrame = (i: number) => ({ + x: i * 32, + y: 0, + width: 32, + height: 64, + }) + + const createTorchDrawable = (phaseOffsetMs = 0): ObjectDrawable => { + const frameCount = 20 + const frameDurationMs = 51.2 + const frames: AnimatedObjectFrame[] = Array.from({ length: frameCount }, (_, i) => ({ + frame: dummyAtlasFrame(i), + page: 0, + offsetX: -16, + offsetY: -32 + (i % 2), // slightly fluctuating DC6/DCC offset + })) + + return { + frame: frames[0]!.frame, + x: 100 + frames[0]!.offsetX, + y: 200 + frames[0]!.offsetY, + depth: 60, + page: 0, + baseX: 100, + baseY: 200, + animatedFrames: frames, + frameDurationMs, + cycleAnim: true, + phaseOffsetMs, + currentFrameIndex: 0, + } + } + + it('ignores static or single-frame objects', () => { + const staticObj: ObjectDrawable = { + frame: dummyAtlasFrame(0), + x: 100, + y: 200, + depth: 60, + page: 0, + } + expect(updateAnimatedObject(staticObj, 1000)).toBe(false) + expect(staticObj.currentFrameIndex).toBeUndefined() + }) + + it('advances frames sequentially with time', () => { + const torch = createTorchDrawable(0) + + // At t=0ms, frame should be 0 + expect(updateAnimatedObject(torch, 0)).toBe(false) + expect(torch.currentFrameIndex).toBe(0) + + // At t=60ms (> 51.2ms), frame should be 1 + expect(updateAnimatedObject(torch, 60)).toBe(true) + expect(torch.currentFrameIndex).toBe(1) + expect(torch.frame.x).toBe(32) + expect(torch.x).toBe(100 - 16) + expect(torch.y).toBe(200 - 32 + 1) + + // At t=110ms (> 2 * 51.2 = 102.4ms), frame should be 2 + expect(updateAnimatedObject(torch, 110)).toBe(true) + expect(torch.currentFrameIndex).toBe(2) + expect(torch.frame.x).toBe(64) + + // Advancing to the same time interval does not re-trigger update + expect(updateAnimatedObject(torch, 115)).toBe(false) + }) + + it('wraps smoothly in a loop when cycleAnim is true', () => { + const torch = createTorchDrawable(0) + const cycleDuration = 20 * 51.2 // 1024 ms + + // Exactly at one full cycle (1024ms), it wraps back to frame 0 + updateAnimatedObject(torch, 1024 + 10) + expect(torch.currentFrameIndex).toBe(0) + + // Halfway through next cycle: frame 10 + updateAnimatedObject(torch, 1024 + 520) + expect(torch.currentFrameIndex).toBe(10) + }) + + it('clamps to the final frame when cycleAnim is false', () => { + const nonLoopingObj = createTorchDrawable(0) + // Modify to non-cycling + ;(nonLoopingObj as any).cycleAnim = false + + // Beyond end of cycle (2000 ms > 1024 ms) + updateAnimatedObject(nonLoopingObj, 2000) + expect(nonLoopingObj.currentFrameIndex).toBe(19) // clamped to last frame (index 19) + }) + + it('respects spatial phase offset', () => { + // Torch A at phase 0, Torch B at phase 256ms (~5 frames ahead) + const torchA = createTorchDrawable(0) + const torchB = createTorchDrawable(256) + + updateAnimatedObject(torchA, 0) + updateAnimatedObject(torchB, 0) + + expect(torchA.currentFrameIndex).toBe(0) + expect(torchB.currentFrameIndex).toBe(5) // floor(256 / 51.2) = 5 + }) + + it('updates array of objects via updateAnimatedObjects', () => { + const list = [ + createTorchDrawable(0), + createTorchDrawable(100), + createTorchDrawable(200), + ] + + const changedCount = updateAnimatedObjects(list, 60) + expect(changedCount).toBeGreaterThan(0) + for (const obj of list) { + expect(obj.currentFrameIndex).toBeDefined() + } + }) + }) + + describe('Ground truth 1.13c baked pack validation', () => { + const sceneJsonPath = join(process.cwd(), 'samples/d2-packs/act1/1-act-1-town-towne1/scene.json') + + it('has baked animatedFrames and 1.13c timing in towne1 pack', () => { + if (!existsSync(sceneJsonPath)) { + console.warn(`Skipping pack validation test: ${sceneJsonPath} does not exist`) + return + } + + const scene = JSON.parse(readFileSync(sceneJsonPath, 'utf8')) + expect(scene.objects).toBeDefined() + expect(Array.isArray(scene.objects)).toBe(true) + + // Check Campfire (RB) + const campfires = scene.objects.filter((o: any) => o.token === 'RB') + expect(campfires.length).toBeGreaterThan(0) + for (const fire of campfires) { + expect(fire.mode).toBe('ON') + expect(fire.animatedFrames).toBeDefined() + expect(fire.animatedFrames.length).toBe(20) + expect(fire.frameDelta).toBe(256) // 25 FPS + expect(fire.frameDurationMs).toBe(40) + expect(fire.cycleAnim).toBe(true) + expect(fire.sync).toBe(false) + } + + // Check Torches (TO) + const torches = scene.objects.filter((o: any) => o.token === 'TO') + expect(torches.length).toBeGreaterThan(0) + for (const torch of torches) { + expect(torch.mode).toBe('ON') + expect(torch.animatedFrames).toBeDefined() + expect(torch.animatedFrames.length).toBe(20) + expect(torch.frameDelta).toBe(200) // ~19.5 FPS + expect(torch.frameDurationMs).toBe(51.2) + expect(torch.cycleAnim).toBe(true) + expect(torch.sync).toBe(false) + } + + // Check Flags (N1, N2) + const flagsN1 = scene.objects.filter((o: any) => o.token === 'N1') + const flagsN2 = scene.objects.filter((o: any) => o.token === 'N2') + expect(flagsN1.length).toBeGreaterThan(0) + expect(flagsN2.length).toBeGreaterThan(0) + for (const flag of [...flagsN1, ...flagsN2]) { + expect(flag.mode).toBe('NU') + expect(flag.animatedFrames).toBeDefined() + expect(flag.animatedFrames.length).toBe(10) + expect(flag.frameDelta).toBe(128) // 12.5 FPS + expect(flag.frameDurationMs).toBe(80) + expect(flag.cycleAnim).toBe(true) + expect(flag.sync).toBe(false) + } + + // Check Waypoint (WP) + const waypoints = scene.objects.filter((o: any) => o.token === 'WP') + expect(waypoints.length).toBeGreaterThan(0) + for (const wp of waypoints) { + expect(wp.mode).toBe('ON') + expect(wp.animatedFrames).toBeDefined() + expect(wp.animatedFrames.length).toBe(15) + expect(wp.frameDelta).toBe(200) + expect(wp.cycleAnim).toBe(true) + expect(wp.sync).toBe(true) + } + + // Check Stash (B6) + const stashes = scene.objects.filter((o: any) => o.token === 'B6') + expect(stashes.length).toBeGreaterThan(0) + for (const stash of stashes) { + expect(stash.token).toBe('B6') + // Static object has no animatedFrames + expect(stash.animatedFrames).toBeUndefined() + // Single frame is preserved + expect(stash.frame).toBeDefined() + } + }) + }) +})