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:
parent
24eb66d2e6
commit
5d6dc8a15f
|
|
@ -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 }[] }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue