Merge branch 'issue-387' into main (Fixes #387)

This commit is contained in:
troytt 2026-09-23 05:37:19 +00:00
commit ff347bb946
6 changed files with 481 additions and 32 deletions

View File

@ -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<string, { frame: SpriteFrame; offsetX: number; offsetY: number }>()
const decodedMemberCache = new Map<string, DecodedMemberArt>()
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<string, { placement: Placement; offsetX: number; offsetY: number }>()
interface PlacedMemberFrame {
placement: Placement
offsetX: number
offsetY: number
}
const placementByMember = new Map<string, { frames: PlacedMemberFrame[] }>()
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`)

View File

@ -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,
}),

View File

@ -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,

View File

@ -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,
}
}

View File

@ -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[]
/**
@ -2095,6 +2184,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. */
@ -2337,6 +2431,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,
@ -2348,6 +2470,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,
} : {}),
})
}
}
@ -2450,6 +2581,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 ?? [],
@ -3887,6 +4019,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
@ -4261,6 +4394,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)
@ -4784,6 +4920,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

View File

@ -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()
}
})
})
})