feat(anim): 接入 LvlPrest.txt 的 Animate 列还原逐关卡动画速度 (Issue #85)

- 从 LvlPrest.txt 读取 Animate 列作为动画速度 animSpeed (D2 1.13c 默认速度 80,即 80/256 帧/tick @ 25Hz => 128ms/帧)
- 在 animated-tiles.ts 中提供 frameDurationMsFromAnimSpeed 公式函数与 128ms 默认帧时长
- 统一在 acts.ts、maze.ts、preset.ts、wilderness.ts、pack-act-assets.ts 中解析并传递 animSpeed 与 frameDurationMs
- 在 act-scene.ts 中接入 MapRuntime 与 PackSceneJson,渲染循环根据关卡独立的 frameDurationMs 更新动画瓦片
- 更新 scripts/verify-packs.ts 校验门禁并通过 1671/1671 项断言
- 新增 tests/animated-tiles-speed.test.ts 专项单测,全量 vitest 1137 测试全通

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-18 10:54:10 +00:00
parent 24eb66d2e6
commit 5d6dc8a15f
9 changed files with 290 additions and 15 deletions

View File

@ -38,6 +38,7 @@ import { decodeDt1 } from '../src/formats/dt1.ts'
import type { Dt1 } from '../src/formats/dt1.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts'
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'
@ -1052,6 +1053,8 @@ function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<Maze
if (classified === null) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
pieces.push({
name,
kind: classified.kind satisfies MazePieceKind,
@ -1059,6 +1062,7 @@ function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<Maze
levels,
// `LvlPrest.KillEdge`; 1.13c trims the outer-edge room's overhang when set.
killEdge: cell(tables.lvlprest, row, 'KillEdge') === '1',
...(animSpeed !== undefined ? { animSpeed } : {}),
})
}
return pieces
@ -1080,7 +1084,9 @@ function getWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]>
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff|ravine/i.test(name)
pieces.push({ name, levels, border: isBorder })
const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
pieces.push({ name, levels, border: isBorder, ...(animSpeed !== undefined ? { animSpeed } : {}) })
}
return pieces
})()
@ -1169,8 +1175,12 @@ async function bakeDs1Variant(
readonly mazeWaypoints?: readonly MazeWaypoint[]
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
readonly notes?: readonly string[] | undefined
readonly animSpeed?: number | undefined
} = { entrances: [], mazeWarps: [] },
animSpeedOverride?: number,
): Promise<void> {
const animSpeed = animSpeedOverride ?? generatorLinks.animSpeed
const frameDurationMs = frameDurationMsFromAnimSpeed(animSpeed)
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
const spawn = findIsoSpawn(scene)
const dir = join(outDir, `act${String(entry.act)}`, label)
@ -1407,6 +1417,8 @@ async function bakeDs1Variant(
levelId: entry.levelId,
levelName,
ds1: ds1Name,
...(animSpeed !== undefined ? { animSpeed } : {}),
frameDurationMs,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
originX: scene.originX,
@ -1546,7 +1558,16 @@ for (const entry of LEVELS) {
for (const ds1Name of info.ds1Names) {
const level = await loadDs1(ds1Name)
const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? ds1Name
pieces.push({ name: pieceName, levels: [level] })
const prestRow = tables.lvlprest.rows.find(r => {
for (let file = 1; file <= 6; file += 1) {
const f = cell(tables.lvlprest, r, `File${String(file)}`)
if (f && ds1Name.toLowerCase().includes(f.toLowerCase().replace(/\.ds1$/i, ''))) return true
}
return cell(tables.lvlprest, r, 'Name').toLowerCase().includes(pieceName.toLowerCase())
})
const animSpeedRaw = prestRow ? (parseInt(cell(tables.lvlprest, prestRow, 'Animate'), 10) || 0) : 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
pieces.push({ name: pieceName, levels: [level], ...(animSpeed !== undefined ? { animSpeed } : {}) })
}
const result = generatePreset({
levelId: entry.levelId,
@ -1556,12 +1577,22 @@ for (const entry of LEVELS) {
pieces,
})
const label = `${entry.slug}-chaos`
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, result.level, 'preset:chaos-sanctuary', label, levelSeed(info.levelName))
await bakeDs1Variant(
entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries,
result.level, 'preset:chaos-sanctuary', label, levelSeed(info.levelName),
{ entrances: [], mazeWarps: [] },
(result.stats.animSpeed as number | undefined) ?? info.animSpeed,
)
} else {
for (const ds1Name of info.ds1Names) {
const level = await loadDs1(ds1Name)
const label = `${entry.slug}-${slugOf(ds1Name)}`
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, level, ds1Name, label, levelSeed(ds1Name))
await bakeDs1Variant(
entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries,
level, ds1Name, label, levelSeed(ds1Name),
{ entrances: [], mazeWarps: [] },
info.animSpeed,
)
}
}
} else if (entry.kind === 'maze') {
@ -1610,6 +1641,7 @@ for (const entry of LEVELS) {
entrances: [],
mazeWarps: (result.stats.warps ?? []) as MazeWarp[],
mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[],
animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined),
},
)
} catch (err) {
@ -1662,6 +1694,7 @@ for (const entry of LEVELS) {
{
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
mazeWarps: [],
animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined),
...(Array.isArray(result.stats.landmarkEntities)
? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] }
: {}),

View File

@ -252,6 +252,8 @@ export interface LevelInfo {
readonly position: number
/** `Portal`. */
readonly portal: number
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column (0 or undefined defaults to 80). */
readonly animSpeed?: number
}
/**
@ -355,6 +357,8 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number):
if (preset.length === 0) throw new Error(`LvlPrest.txt has no row with LevelId ${String(levelId)}`)
const dt1Mask = Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask'))
const animSpeedRaw = parseInt(cell(tables.lvlprest, preset[0]!, 'Animate'), 10) || 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
const dt1Names: string[] = []
for (let slot = 1; slot <= 32; slot += 1) {
const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`)
@ -398,6 +402,7 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number):
waypoint: Number(cell(tables.levels, row, 'Waypoint') || '255'),
position: Number(cell(tables.levels, row, 'Position') || '0'),
portal: Number(cell(tables.levels, row, 'Portal') || '0'),
...(animSpeed !== undefined ? { animSpeed } : {}),
}
}

View File

@ -8,12 +8,37 @@
* byte +7 (animated flag) set, and `rarityFrameIndex` indicating the 0-based
* animation frame index.
*
* Default playback speed is 10 frames per second (100ms per frame).
* In Diablo II 1.13c / 1.10f, animated tile speed is driven by the `Animate` column
* in `LvlPrest.txt` (column index 6, name 'Animate').
* `DRLGANIM_AllocAnimationTileGrids` receives `pLvlPrestTxtRecord->nAnimSpeed`.
* In the original code: `if (nAnimationSpeed == 0) nAnimationSpeed = 80;`
* Fixed-point step: `nCurrentFrame = (nCurrentFrame + nAnimationSpeed) % (nFrames << 8);`
* (low 8 bits are fractional).
* Speed 80 means 80/256 frames per tick.
* At standard 25Hz simulation rate: 25 * 80 / 256 = 7.8125 FPS => 128 ms per frame.
*/
import type { AtlasFrame } from '../render/atlas.ts'
/** Default playback duration per animated tile frame in milliseconds (10 FPS in Diablo II). */
export const DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS = 100
/**
* Calculate frame duration in milliseconds from LvlPrest.txt Animate column value (animSpeed).
*
* In Diablo II 1.13c / 1.10f:
* - `DRLGANIM_AllocAnimationTileGrids` receives `pLvlPrestTxtRecord->nAnimSpeed`.
* - If `nAnimationSpeed == 0`, it defaults to 80 (`if (nAnimationSpeed == 0) nAnimationSpeed = 80;`).
* - Fixed-point step: `nCurrentFrame = (nCurrentFrame + nAnimationSpeed) % (nFrames << 8);` (low 8 bits are fractional).
* - Speed 80 means 80/256 frames per tick.
* - At 25Hz simulation rate: 25 * 80 / 256 = 7.8125 FPS => (256 / speed) * (1000 / 25) ms per frame.
*
* @param animSpeed - speed from LvlPrest.txt Animate column
* @returns duration of each frame in milliseconds
*/
export function frameDurationMsFromAnimSpeed(animSpeed?: number): number {
const speed = (!animSpeed || animSpeed <= 0) ? 80 : animSpeed
return (256 / speed) * (1000 / 25)
}
/** Default playback duration per animated tile frame in milliseconds (128ms / 7.8125 FPS in Diablo II 1.13c, speed 80). */
export const DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS = 128
/** Game ticks per animation frame at standard 25Hz simulation rate (2.5 ticks/frame = 100ms/frame). */
export const DEFAULT_TICKS_PER_ANIMATED_FRAME = 2.5

View File

@ -276,6 +276,11 @@ export interface MazePiece {
* behaviour of never trimming.
*/
readonly killEdge?: boolean
/**
* Animated tile playback speed from `LvlPrest.txt` `Animate` column.
* In 1.13c, 0 defaults to 80 (128ms per frame at 25Hz).
*/
readonly animSpeed?: number
}
/** Everything {@link generateMaze} needs. */
@ -375,6 +380,8 @@ export interface MazeResult {
readonly objects: readonly PlacedMazeObject[]
/** What the generator did, for reporting and tests. */
readonly stats: Record<string, unknown>
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
readonly animSpeed?: number
}
/* ------------------------------------------------------------------------- *
@ -708,6 +715,8 @@ export interface MazeLevelProfile {
readonly specials: readonly SpecialGroup[]
/** Optional official dungeon special pass from D2MOO. */
readonly specialPass?: SpecialPassName
/** Animated tile speed override from LvlPrest.txt (default 80 if 0 or omitted). */
readonly animSpeed?: number
}
/** The four single-side layouts, in the order the D2MOO tables list them. */
@ -905,6 +914,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
levelTypeName: 'Act 4 - Lava',
grow: true,
theme: false,
animSpeed: 1,
specials: [
{
kind: 'prev',
@ -2533,6 +2543,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
const levelTypeName = request.levelTypeName ?? inferLevelTypeName(request.pieces)
if (levelTypeName === null) throw new Error(`${where}: cannot infer the level type from the piece names`)
const profile = MAZE_LEVEL_TYPE_PROFILES.find(candidate => candidate.levelTypeName === levelTypeName)
const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed ?? profile?.animSpeed
if (profile === undefined) throw new Error(`${where}: no maze profile for level type "${levelTypeName}"`)
const stats: MazeStats = {
@ -2737,6 +2748,8 @@ export function generateMaze(request: MazeRequest): MazeResult {
maxCellsY,
boundsRejected: stats.boundsRejected ?? 0,
notes: stats.notes,
...(animSpeed !== undefined ? { animSpeed } : {}),
},
...(animSpeed !== undefined ? { animSpeed } : {}),
}
}

View File

@ -81,6 +81,8 @@ export interface PresetPiece {
readonly levels: readonly Ds1[]
/** Optional border flag for compatibility with wilderness piece records. */
readonly border?: boolean | undefined
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
readonly animSpeed?: number | undefined
}
/** Target placement coordinate for a stamped preset piece. */
@ -328,6 +330,7 @@ export interface PresetStats {
readonly notes?: readonly string[] | undefined
readonly nPickedFile?: number | undefined
readonly rooms?: number | undefined
readonly animSpeed?: number | undefined
readonly [key: string]: unknown
}
@ -339,6 +342,8 @@ export interface PresetResult {
readonly stats: PresetStats
/** Discrete 8x8 rooms sliced from the preset canvas. */
readonly rooms?: readonly PresetRoom[] | undefined
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
readonly animSpeed?: number | undefined
}
/**
@ -538,6 +543,7 @@ export function findMatchingPresetPiece(
export function generateChaosSanctuary(request: PresetRequest): PresetResult {
const width = request.sizeX && request.sizeX > 0 ? request.sizeX : CHAOS_SANCTUARY_SIZE_X
const height = request.sizeY && request.sizeY > 0 ? request.sizeY : CHAOS_SANCTUARY_SIZE_Y
const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed
let wallLayers = 1
let floorLayers = 1
@ -609,8 +615,10 @@ export function generateChaosSanctuary(request: PresetRequest): PresetResult {
cells: canvas.width * canvas.height,
groundCells,
rooms: rooms.length,
...(animSpeed !== undefined ? { animSpeed } : {}),
},
rooms,
...(animSpeed !== undefined ? { animSpeed } : {}),
}
}
@ -696,6 +704,7 @@ export function generatePreset(request: PresetRequest): PresetResult {
}
const rooms = slicePresetRooms(resultLevel.width, resultLevel.height, resultLevel.objects)
const animSpeed = pickedPiece.animSpeed ?? request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed
return {
level: resultLevel,
@ -716,8 +725,10 @@ export function generatePreset(request: PresetRequest): PresetResult {
groundCells,
nPickedFile: nPickedFile ?? 0,
rooms: rooms.length,
...(animSpeed !== undefined ? { animSpeed } : {}),
},
rooms,
...(animSpeed !== undefined ? { animSpeed } : {}),
}
}
}

View File

@ -183,6 +183,8 @@ export interface WildernessPiece {
* fill pieces are scattered inside.
*/
readonly border: boolean
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
readonly animSpeed?: number
}
/**
@ -361,7 +363,10 @@ export interface WildernessResult {
readonly unresolved?: string[]
readonly gateOpenings?: readonly GateOpening[]
readonly groundTransitions?: GroundTransitionResult
readonly animSpeed?: number
}
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
readonly animSpeed?: number
}
/** Canonical default open ground floor tiles per wilderness level type. */
@ -6336,6 +6341,7 @@ export function spawnLandmarkEntities(
export function generateWilderness(request: WildernessRequest): WildernessResult {
const where = `level ${String(request.levelId)} (${request.levelName})`
if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`)
const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed
let sizeX = request.sizeX
let sizeY = request.sizeY
@ -6945,6 +6951,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
...(animSpeed !== undefined ? { animSpeed } : {}),
},
...(animSpeed !== undefined ? { animSpeed } : {}),
}
}

View File

@ -32,7 +32,11 @@ import {
buildIsoMapScene, cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
} from '../game/d2map.ts'
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
import { updateAnimatableTiles } from '../game/animated-tiles.ts'
import {
updateAnimatableTiles,
frameDurationMsFromAnimSpeed,
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
} from '../game/animated-tiles.ts'
import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
import { createIsoTerrain } from '../game/iso-terrain.ts'
import { spawnMonsterPacks } from '../game/combat.ts'
@ -229,6 +233,10 @@ export interface ActSceneState {
npcsNear: NpcEntity[]
/** Count of animated tiles in the active map. */
animatedTiles: number
/** Animation speed from LvlPrest.txt `Animate` column (or undefined if not specified). */
animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
frameDurationMs?: number | undefined
/**
* Monster ids the art loader could not dress, lower case.
*
@ -268,6 +276,7 @@ const state: ActSceneState = {
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
}
if (typeof window !== 'undefined') window.__d2webAct = state
@ -336,6 +345,10 @@ interface MapRuntime {
readonly roofs: readonly Drawable[]
/** Animated tile drawables whose frames cycle during rendering. */
readonly animatedDrawables?: readonly Drawable[] | undefined
/** LvlPrest.txt `Animate` column speed. */
readonly animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
readonly frameDurationMs?: number | undefined
/** Object draws: interleaved with walls in painter's order. */
readonly objectDrawables: readonly ObjectDrawable[]
readonly spawn: { x: number; y: number }
@ -475,6 +488,10 @@ interface PackSceneJson {
readonly roofs?: readonly (readonly number[])[]
readonly objects: readonly PackObject[]
readonly npcs?: readonly PackObject[]
/** LvlPrest.txt `Animate` column speed. */
readonly animSpeed?: number | undefined
/** Per-frame duration in ms computed from animSpeed. */
readonly frameDurationMs?: number | undefined
/**
* The level's monsters, planned at bake time (absent in packs baked before
* the monster columns were read).
@ -763,6 +780,8 @@ async function buildPackRuntime(
animatedDrawables: [...floors, ...walls, ...roofs].filter(
d => d.animatedFrames && d.animatedFrames.length > 1
),
animSpeed: scene.animSpeed,
frameDurationMs: scene.frameDurationMs ?? (scene.animSpeed !== undefined ? frameDurationMsFromAnimSpeed(scene.animSpeed) : DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS),
objectDrawables,
spawn,
npcs: scene.npcs ?? [],
@ -984,6 +1003,8 @@ async function loadLiveRuntime(
walls,
roofs,
animatedDrawables,
animSpeed: town.animSpeed,
frameDurationMs: frameDurationMsFromAnimSpeed(town.animSpeed),
objectDrawables: [],
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
npcs: [],
@ -1947,6 +1968,8 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
state.pagesTotal = next.pages.length
state.pagesLoaded = 0
state.animatedTiles = next.animatedDrawables?.length ?? 0
state.animSpeed = next.animSpeed
state.frameDurationMs = next.frameDurationMs
}
/**
@ -2158,7 +2181,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
onRender: (_alpha: number, frameMs: number) => {
const renderStarted = performance.now()
if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) {
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time')
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time', runtime.frameDurationMs)
}
if (state.pagesAtFirstFrame < 0) {
state.pagesAtFirstFrame = countPages()
@ -2571,6 +2594,8 @@ function publish(runtime: MapRuntime, base: string): void {
state.objects = runtime.objects
state.roofs = runtime.roofs.length
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
state.animSpeed = runtime.animSpeed
state.frameDurationMs = runtime.frameDurationMs
state.roofAlpha = 1
state.missing = 0
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length

View File

@ -0,0 +1,155 @@
import { describe, expect, test } from 'vitest'
import {
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
frameDurationMsFromAnimSpeed,
updateAnimatableTile,
updateAnimatableTiles,
} from '../src/game/animated-tiles.ts'
import type { AnimatableTile } from '../src/game/animated-tiles.ts'
import { MAZE_LEVEL_TYPE_PROFILES } from '../src/game/maze.ts'
describe('Animated Tile Speed from LvlPrest.txt Animate Column (Issue #85)', () => {
describe('frameDurationMsFromAnimSpeed formula', () => {
test('defaults to 128ms for 0, negative, undefined, or missing speed', () => {
// In 1.13c: if (nAnimationSpeed == 0) nAnimationSpeed = 80;
// At 25Hz tick rate and 80/256 frames per tick:
// (256 / 80) * (1000 / 25) = 3.2 * 40 = 128 ms
expect(frameDurationMsFromAnimSpeed(undefined)).toBe(128)
expect(frameDurationMsFromAnimSpeed(0)).toBe(128)
expect(frameDurationMsFromAnimSpeed(-1)).toBe(128)
expect(frameDurationMsFromAnimSpeed(-80)).toBe(128)
expect(frameDurationMsFromAnimSpeed(Number.NaN)).toBe(128)
})
test('calculates 128ms for standard speed 80', () => {
expect(frameDurationMsFromAnimSpeed(80)).toBe(128)
expect(frameDurationMsFromAnimSpeed(80)).toBe(DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS)
})
test('calculates 10240ms for Act 4 lava speed 1', () => {
// Speed 1: (256 / 1) * (1000 / 25) = 256 * 40 = 10240 ms per frame (~0.09765 FPS)
expect(frameDurationMsFromAnimSpeed(1)).toBe(10240)
})
test('calculates correct durations for various speeds', () => {
// Speed 160: twice as fast as default -> 64ms per frame
expect(frameDurationMsFromAnimSpeed(160)).toBe(64)
// Speed 256: 1 frame per tick (25 FPS) -> 40ms per frame
expect(frameDurationMsFromAnimSpeed(256)).toBe(40)
// Speed 40: half speed of default -> 256ms per frame
expect(frameDurationMsFromAnimSpeed(40)).toBe(256)
// Speed 20: quarter speed -> 512ms per frame
expect(frameDurationMsFromAnimSpeed(20)).toBe(512)
})
})
describe('updateAnimatableTiles with per-level frame duration', () => {
const createTile = (frameCount: number): AnimatableTile => {
const frames = Array.from({ length: frameCount }, (_, i) => ({
frame: { x: i * 160, y: 0, width: 160, height: 80 },
page: 0,
}))
return {
frame: frames[0]!.frame,
page: frames[0]!.page,
animatedFrames: frames,
currentFrameIndex: 0,
}
}
test('advances at 10240ms per frame when frameDurationMs is 10240 (Act 4 Lava)', () => {
const tile = createTile(4)
const durationMs = frameDurationMsFromAnimSpeed(1) // 10240ms
expect(durationMs).toBe(10240)
// At 0ms -> frame 0
updateAnimatableTile(tile, 0, 'time', durationMs)
expect(tile.currentFrameIndex).toBe(0)
// At 5000ms -> still frame 0 (not reached 10240ms yet)
updateAnimatableTile(tile, 5000, 'time', durationMs)
expect(tile.currentFrameIndex).toBe(0)
// At 10239ms -> still frame 0
updateAnimatableTile(tile, 10239, 'time', durationMs)
expect(tile.currentFrameIndex).toBe(0)
// At 10240ms -> advances to frame 1
updateAnimatableTile(tile, 10240, 'time', durationMs)
expect(tile.currentFrameIndex).toBe(1)
// At 20480ms -> advances to frame 2
updateAnimatableTile(tile, 20480, 'time', durationMs)
expect(tile.currentFrameIndex).toBe(2)
// At 40960ms -> wraps back to frame 0
updateAnimatableTile(tile, 40960, 'time', durationMs)
expect(tile.currentFrameIndex).toBe(0)
})
test('advances at 64ms per frame when frameDurationMs is 64 (speed 160)', () => {
const tile = createTile(4)
const durationMs = frameDurationMsFromAnimSpeed(160) // 64ms
expect(durationMs).toBe(64)
updateAnimatableTile(tile, 0, 'time', durationMs)
expect(tile.currentFrameIndex).toBe(0)
updateAnimatableTile(tile, 63, 'time', durationMs)
expect(tile.currentFrameIndex).toBe(0)
updateAnimatableTile(tile, 64, 'time', durationMs)
expect(tile.currentFrameIndex).toBe(1)
updateAnimatableTile(tile, 128, 'time', durationMs)
expect(tile.currentFrameIndex).toBe(2)
})
test('updateAnimatableTiles propagates custom frameDurationMs to all tiles', () => {
const tiles = [createTile(3), createTile(4), createTile(5)]
const lavaDuration = frameDurationMsFromAnimSpeed(1) // 10240ms
// At 10240ms, all should be on frame 1
updateAnimatableTiles(tiles, 10240, 'time', lavaDuration)
expect(tiles[0]!.currentFrameIndex).toBe(1)
expect(tiles[1]!.currentFrameIndex).toBe(1)
expect(tiles[2]!.currentFrameIndex).toBe(1)
// At 30720ms:
// tile 0 (3 frames): 30720 / 10240 = 3 -> 3 % 3 = frame 0
// tile 1 (4 frames): 3 % 4 = frame 3
// tile 2 (5 frames): 3 % 5 = frame 3
updateAnimatableTiles(tiles, 30720, 'time', lavaDuration)
expect(tiles[0]!.currentFrameIndex).toBe(0)
expect(tiles[1]!.currentFrameIndex).toBe(3)
expect(tiles[2]!.currentFrameIndex).toBe(3)
})
test('updateAnimatableTiles falls back to DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS (128ms) when omitted', () => {
const tile = createTile(4)
updateAnimatableTiles([tile], 0, 'time')
expect(tile.currentFrameIndex).toBe(0)
updateAnimatableTiles([tile], 127, 'time')
expect(tile.currentFrameIndex).toBe(0)
updateAnimatableTiles([tile], 128, 'time')
expect(tile.currentFrameIndex).toBe(1)
updateAnimatableTiles([tile], 256, 'time')
expect(tile.currentFrameIndex).toBe(2)
})
})
describe('Act 4 Lava Profile configuration', () => {
test('Act 4 - Lava profile specifies animSpeed: 1', () => {
const lavaProfile = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 4 - Lava')
expect(lavaProfile).toBeDefined()
expect(lavaProfile?.animSpeed).toBe(1)
})
})
})

View File

@ -81,14 +81,14 @@ describe('Animated Tiles (Issue #43)', () => {
describe('Frame Advancement & Timing', () => {
test('computeFrameIndexFromTime handles millisecond time advancement', () => {
const frameCount = 4
const duration = DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS // 100ms
const duration = DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS // 128ms (Diablo II 1.13c speed 80)
expect(computeFrameIndexFromTime(0, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(50, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(100, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(199, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(200, frameCount, duration)).toBe(2)
expect(computeFrameIndexFromTime(350, frameCount, duration)).toBe(3)
expect(computeFrameIndexFromTime(64, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(128, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(255, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(256, frameCount, duration)).toBe(2)
expect(computeFrameIndexFromTime(400, frameCount, duration)).toBe(3)
})
test('computeFrameIndexFromTime supports custom duration', () => {