From 5d6dc8a15f16098237b8b778839156d07640540b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 10:54:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(anim):=20=E6=8E=A5=E5=85=A5=20LvlPrest.txt?= =?UTF-8?q?=20=E7=9A=84=20Animate=20=E5=88=97=E8=BF=98=E5=8E=9F=E9=80=90?= =?UTF-8?q?=E5=85=B3=E5=8D=A1=E5=8A=A8=E7=94=BB=E9=80=9F=E5=BA=A6=20(Issue?= =?UTF-8?q?=20#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从 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 --- scripts/pack-act-assets.ts | 41 +++++++- src/game/acts.ts | 5 + src/game/animated-tiles.ts | 31 +++++- src/game/maze.ts | 13 +++ src/game/preset.ts | 11 ++ src/game/wilderness.ts | 8 ++ src/scene/act-scene.ts | 29 +++++- tests/animated-tiles-speed.test.ts | 155 +++++++++++++++++++++++++++++ tests/animated-tiles.test.ts | 12 +-- 9 files changed, 290 insertions(+), 15 deletions(-) create mode 100644 tests/animated-tiles-speed.test.ts diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 87c3b3e..ed686fb 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -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 0 ? animSpeedRaw : undefined pieces.push({ name, kind: classified.kind satisfies MazePieceKind, @@ -1059,6 +1062,7 @@ function getMazePieces(levelTypeName: string, levelTypeId: string): Promise 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 { + 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 }[] } : {}), diff --git a/src/game/acts.ts b/src/game/acts.ts index 44dd3a9..d3f5b02 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -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 } : {}), } } diff --git a/src/game/animated-tiles.ts b/src/game/animated-tiles.ts index c7dd582..63eb87b 100644 --- a/src/game/animated-tiles.ts +++ b/src/game/animated-tiles.ts @@ -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 diff --git a/src/game/maze.ts b/src/game/maze.ts index cdc8c8f..3d05dae 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -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 + /** 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 } : {}), } } diff --git a/src/game/preset.ts b/src/game/preset.ts index 23d8d09..aca7cad 100644 --- a/src/game/preset.ts +++ b/src/game/preset.ts @@ -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 } : {}), } } } diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 2736e1f..0458c9a 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -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 } : {}), } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index b4956a7..a3004f2 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 diff --git a/tests/animated-tiles-speed.test.ts b/tests/animated-tiles-speed.test.ts new file mode 100644 index 0000000..cf7a0cd --- /dev/null +++ b/tests/animated-tiles-speed.test.ts @@ -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) + }) + }) +}) diff --git a/tests/animated-tiles.test.ts b/tests/animated-tiles.test.ts index 1745f89..1a7c561 100644 --- a/tests/animated-tiles.test.ts +++ b/tests/animated-tiles.test.ts @@ -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', () => {