Merge branch 'issue-387' into main (Fixes #387)
This commit is contained in:
commit
ff347bb946
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue