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 type { Dt1 } from '../src/formats/dt1.ts'
|
||||||
import { decodePl2 } from '../src/formats/pl2.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 { 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 { planLevelMonsters } from '../src/game/monsters.ts'
|
||||||
import type { IsoMapScene } from '../src/game/d2map.ts'
|
import type { IsoMapScene } from '../src/game/d2map.ts'
|
||||||
import { SUB_TILES_PER_TILE } from '../src/game/map.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
|
if (classified === null) continue
|
||||||
const levels = await rowDs1s(tables.lvlprest, row)
|
const levels = await rowDs1s(tables.lvlprest, row)
|
||||||
if (levels.length === 0) continue
|
if (levels.length === 0) continue
|
||||||
|
const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0
|
||||||
|
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
|
||||||
pieces.push({
|
pieces.push({
|
||||||
name,
|
name,
|
||||||
kind: classified.kind satisfies MazePieceKind,
|
kind: classified.kind satisfies MazePieceKind,
|
||||||
|
|
@ -1059,6 +1062,7 @@ function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<Maze
|
||||||
levels,
|
levels,
|
||||||
// `LvlPrest.KillEdge`; 1.13c trims the outer-edge room's overhang when set.
|
// `LvlPrest.KillEdge`; 1.13c trims the outer-edge room's overhang when set.
|
||||||
killEdge: cell(tables.lvlprest, row, 'KillEdge') === '1',
|
killEdge: cell(tables.lvlprest, row, 'KillEdge') === '1',
|
||||||
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return pieces
|
return pieces
|
||||||
|
|
@ -1080,7 +1084,9 @@ function getWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]>
|
||||||
const levels = await rowDs1s(tables.lvlprest, row)
|
const levels = await rowDs1s(tables.lvlprest, row)
|
||||||
if (levels.length === 0) continue
|
if (levels.length === 0) continue
|
||||||
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff|ravine/i.test(name)
|
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
|
return pieces
|
||||||
})()
|
})()
|
||||||
|
|
@ -1169,8 +1175,12 @@ async function bakeDs1Variant(
|
||||||
readonly mazeWaypoints?: readonly MazeWaypoint[]
|
readonly mazeWaypoints?: readonly MazeWaypoint[]
|
||||||
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
|
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
|
||||||
readonly notes?: readonly string[] | undefined
|
readonly notes?: readonly string[] | undefined
|
||||||
|
readonly animSpeed?: number | undefined
|
||||||
} = { entrances: [], mazeWarps: [] },
|
} = { entrances: [], mazeWarps: [] },
|
||||||
|
animSpeedOverride?: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const animSpeed = animSpeedOverride ?? generatorLinks.animSpeed
|
||||||
|
const frameDurationMs = frameDurationMsFromAnimSpeed(animSpeed)
|
||||||
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
|
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
|
||||||
const spawn = findIsoSpawn(scene)
|
const spawn = findIsoSpawn(scene)
|
||||||
const dir = join(outDir, `act${String(entry.act)}`, label)
|
const dir = join(outDir, `act${String(entry.act)}`, label)
|
||||||
|
|
@ -1407,6 +1417,8 @@ async function bakeDs1Variant(
|
||||||
levelId: entry.levelId,
|
levelId: entry.levelId,
|
||||||
levelName,
|
levelName,
|
||||||
ds1: ds1Name,
|
ds1: ds1Name,
|
||||||
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||||||
|
frameDurationMs,
|
||||||
cellsX: scene.cellsX,
|
cellsX: scene.cellsX,
|
||||||
cellsY: scene.cellsY,
|
cellsY: scene.cellsY,
|
||||||
originX: scene.originX,
|
originX: scene.originX,
|
||||||
|
|
@ -1546,7 +1558,16 @@ for (const entry of LEVELS) {
|
||||||
for (const ds1Name of info.ds1Names) {
|
for (const ds1Name of info.ds1Names) {
|
||||||
const level = await loadDs1(ds1Name)
|
const level = await loadDs1(ds1Name)
|
||||||
const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? 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({
|
const result = generatePreset({
|
||||||
levelId: entry.levelId,
|
levelId: entry.levelId,
|
||||||
|
|
@ -1556,12 +1577,22 @@ for (const entry of LEVELS) {
|
||||||
pieces,
|
pieces,
|
||||||
})
|
})
|
||||||
const label = `${entry.slug}-chaos`
|
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 {
|
} else {
|
||||||
for (const ds1Name of info.ds1Names) {
|
for (const ds1Name of info.ds1Names) {
|
||||||
const level = await loadDs1(ds1Name)
|
const level = await loadDs1(ds1Name)
|
||||||
const label = `${entry.slug}-${slugOf(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') {
|
} else if (entry.kind === 'maze') {
|
||||||
|
|
@ -1610,6 +1641,7 @@ for (const entry of LEVELS) {
|
||||||
entrances: [],
|
entrances: [],
|
||||||
mazeWarps: (result.stats.warps ?? []) as MazeWarp[],
|
mazeWarps: (result.stats.warps ?? []) as MazeWarp[],
|
||||||
mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[],
|
mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[],
|
||||||
|
animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -1662,6 +1694,7 @@ for (const entry of LEVELS) {
|
||||||
{
|
{
|
||||||
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
|
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
|
||||||
mazeWarps: [],
|
mazeWarps: [],
|
||||||
|
animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined),
|
||||||
...(Array.isArray(result.stats.landmarkEntities)
|
...(Array.isArray(result.stats.landmarkEntities)
|
||||||
? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] }
|
? { 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
|
readonly position: number
|
||||||
/** `Portal`. */
|
/** `Portal`. */
|
||||||
readonly portal: number
|
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)}`)
|
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 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[] = []
|
const dt1Names: string[] = []
|
||||||
for (let slot = 1; slot <= 32; slot += 1) {
|
for (let slot = 1; slot <= 32; slot += 1) {
|
||||||
const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`)
|
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'),
|
waypoint: Number(cell(tables.levels, row, 'Waypoint') || '255'),
|
||||||
position: Number(cell(tables.levels, row, 'Position') || '0'),
|
position: Number(cell(tables.levels, row, 'Position') || '0'),
|
||||||
portal: Number(cell(tables.levels, row, 'Portal') || '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
|
* byte +7 (animated flag) set, and `rarityFrameIndex` indicating the 0-based
|
||||||
* animation frame index.
|
* 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'
|
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). */
|
/** 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
|
export const DEFAULT_TICKS_PER_ANIMATED_FRAME = 2.5
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,11 @@ export interface MazePiece {
|
||||||
* behaviour of never trimming.
|
* behaviour of never trimming.
|
||||||
*/
|
*/
|
||||||
readonly killEdge?: boolean
|
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. */
|
/** Everything {@link generateMaze} needs. */
|
||||||
|
|
@ -375,6 +380,8 @@ export interface MazeResult {
|
||||||
readonly objects: readonly PlacedMazeObject[]
|
readonly objects: readonly PlacedMazeObject[]
|
||||||
/** What the generator did, for reporting and tests. */
|
/** What the generator did, for reporting and tests. */
|
||||||
readonly stats: Record<string, unknown>
|
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[]
|
readonly specials: readonly SpecialGroup[]
|
||||||
/** Optional official dungeon special pass from D2MOO. */
|
/** Optional official dungeon special pass from D2MOO. */
|
||||||
readonly specialPass?: SpecialPassName
|
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. */
|
/** 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',
|
levelTypeName: 'Act 4 - Lava',
|
||||||
grow: true,
|
grow: true,
|
||||||
theme: false,
|
theme: false,
|
||||||
|
animSpeed: 1,
|
||||||
specials: [
|
specials: [
|
||||||
{
|
{
|
||||||
kind: 'prev',
|
kind: 'prev',
|
||||||
|
|
@ -2533,6 +2543,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
||||||
const levelTypeName = request.levelTypeName ?? inferLevelTypeName(request.pieces)
|
const levelTypeName = request.levelTypeName ?? inferLevelTypeName(request.pieces)
|
||||||
if (levelTypeName === null) throw new Error(`${where}: cannot infer the level type from the piece names`)
|
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 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}"`)
|
if (profile === undefined) throw new Error(`${where}: no maze profile for level type "${levelTypeName}"`)
|
||||||
|
|
||||||
const stats: MazeStats = {
|
const stats: MazeStats = {
|
||||||
|
|
@ -2737,6 +2748,8 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
||||||
maxCellsY,
|
maxCellsY,
|
||||||
boundsRejected: stats.boundsRejected ?? 0,
|
boundsRejected: stats.boundsRejected ?? 0,
|
||||||
notes: stats.notes,
|
notes: stats.notes,
|
||||||
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||||||
},
|
},
|
||||||
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,8 @@ export interface PresetPiece {
|
||||||
readonly levels: readonly Ds1[]
|
readonly levels: readonly Ds1[]
|
||||||
/** Optional border flag for compatibility with wilderness piece records. */
|
/** Optional border flag for compatibility with wilderness piece records. */
|
||||||
readonly border?: boolean | undefined
|
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. */
|
/** Target placement coordinate for a stamped preset piece. */
|
||||||
|
|
@ -328,6 +330,7 @@ export interface PresetStats {
|
||||||
readonly notes?: readonly string[] | undefined
|
readonly notes?: readonly string[] | undefined
|
||||||
readonly nPickedFile?: number | undefined
|
readonly nPickedFile?: number | undefined
|
||||||
readonly rooms?: number | undefined
|
readonly rooms?: number | undefined
|
||||||
|
readonly animSpeed?: number | undefined
|
||||||
readonly [key: string]: unknown
|
readonly [key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,6 +342,8 @@ export interface PresetResult {
|
||||||
readonly stats: PresetStats
|
readonly stats: PresetStats
|
||||||
/** Discrete 8x8 rooms sliced from the preset canvas. */
|
/** Discrete 8x8 rooms sliced from the preset canvas. */
|
||||||
readonly rooms?: readonly PresetRoom[] | undefined
|
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 {
|
export function generateChaosSanctuary(request: PresetRequest): PresetResult {
|
||||||
const width = request.sizeX && request.sizeX > 0 ? request.sizeX : CHAOS_SANCTUARY_SIZE_X
|
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 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 wallLayers = 1
|
||||||
let floorLayers = 1
|
let floorLayers = 1
|
||||||
|
|
@ -609,8 +615,10 @@ export function generateChaosSanctuary(request: PresetRequest): PresetResult {
|
||||||
cells: canvas.width * canvas.height,
|
cells: canvas.width * canvas.height,
|
||||||
groundCells,
|
groundCells,
|
||||||
rooms: rooms.length,
|
rooms: rooms.length,
|
||||||
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||||||
},
|
},
|
||||||
rooms,
|
rooms,
|
||||||
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -696,6 +704,7 @@ export function generatePreset(request: PresetRequest): PresetResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rooms = slicePresetRooms(resultLevel.width, resultLevel.height, resultLevel.objects)
|
const rooms = slicePresetRooms(resultLevel.width, resultLevel.height, resultLevel.objects)
|
||||||
|
const animSpeed = pickedPiece.animSpeed ?? request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed
|
||||||
|
|
||||||
return {
|
return {
|
||||||
level: resultLevel,
|
level: resultLevel,
|
||||||
|
|
@ -716,8 +725,10 @@ export function generatePreset(request: PresetRequest): PresetResult {
|
||||||
groundCells,
|
groundCells,
|
||||||
nPickedFile: nPickedFile ?? 0,
|
nPickedFile: nPickedFile ?? 0,
|
||||||
rooms: rooms.length,
|
rooms: rooms.length,
|
||||||
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||||||
},
|
},
|
||||||
rooms,
|
rooms,
|
||||||
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,8 @@ export interface WildernessPiece {
|
||||||
* fill pieces are scattered inside.
|
* fill pieces are scattered inside.
|
||||||
*/
|
*/
|
||||||
readonly border: boolean
|
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 unresolved?: string[]
|
||||||
readonly gateOpenings?: readonly GateOpening[]
|
readonly gateOpenings?: readonly GateOpening[]
|
||||||
readonly groundTransitions?: GroundTransitionResult
|
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. */
|
/** Canonical default open ground floor tiles per wilderness level type. */
|
||||||
|
|
@ -6336,6 +6341,7 @@ export function spawnLandmarkEntities(
|
||||||
export function generateWilderness(request: WildernessRequest): WildernessResult {
|
export function generateWilderness(request: WildernessRequest): WildernessResult {
|
||||||
const where = `level ${String(request.levelId)} (${request.levelName})`
|
const where = `level ${String(request.levelId)} (${request.levelName})`
|
||||||
if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`)
|
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 sizeX = request.sizeX
|
||||||
let sizeY = request.sizeY
|
let sizeY = request.sizeY
|
||||||
|
|
@ -6945,6 +6951,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||||||
notes: stats.notes,
|
notes: stats.notes,
|
||||||
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
|
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
|
||||||
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||||||
},
|
},
|
||||||
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,11 @@ import {
|
||||||
buildIsoMapScene, cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
|
buildIsoMapScene, cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
|
||||||
} from '../game/d2map.ts'
|
} from '../game/d2map.ts'
|
||||||
import type { CollisionGrid, IsoDraw } 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 type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
|
||||||
import { createIsoTerrain } from '../game/iso-terrain.ts'
|
import { createIsoTerrain } from '../game/iso-terrain.ts'
|
||||||
import { spawnMonsterPacks } from '../game/combat.ts'
|
import { spawnMonsterPacks } from '../game/combat.ts'
|
||||||
|
|
@ -229,6 +233,10 @@ export interface ActSceneState {
|
||||||
npcsNear: NpcEntity[]
|
npcsNear: NpcEntity[]
|
||||||
/** Count of animated tiles in the active map. */
|
/** Count of animated tiles in the active map. */
|
||||||
animatedTiles: number
|
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.
|
* 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,
|
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
|
||||||
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
|
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
|
||||||
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
|
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
|
||||||
|
animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
|
||||||
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
|
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
|
||||||
}
|
}
|
||||||
if (typeof window !== 'undefined') window.__d2webAct = state
|
if (typeof window !== 'undefined') window.__d2webAct = state
|
||||||
|
|
@ -336,6 +345,10 @@ interface MapRuntime {
|
||||||
readonly roofs: readonly Drawable[]
|
readonly roofs: readonly Drawable[]
|
||||||
/** Animated tile drawables whose frames cycle during rendering. */
|
/** Animated tile drawables whose frames cycle during rendering. */
|
||||||
readonly animatedDrawables?: readonly Drawable[] | undefined
|
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. */
|
/** Object draws: interleaved with walls in painter's order. */
|
||||||
readonly objectDrawables: readonly ObjectDrawable[]
|
readonly objectDrawables: readonly ObjectDrawable[]
|
||||||
readonly spawn: { x: number; y: number }
|
readonly spawn: { x: number; y: number }
|
||||||
|
|
@ -475,6 +488,10 @@ interface PackSceneJson {
|
||||||
readonly roofs?: readonly (readonly number[])[]
|
readonly roofs?: readonly (readonly number[])[]
|
||||||
readonly objects: readonly PackObject[]
|
readonly objects: readonly PackObject[]
|
||||||
readonly npcs?: 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 level's monsters, planned at bake time (absent in packs baked before
|
||||||
* the monster columns were read).
|
* the monster columns were read).
|
||||||
|
|
@ -763,6 +780,8 @@ async function buildPackRuntime(
|
||||||
animatedDrawables: [...floors, ...walls, ...roofs].filter(
|
animatedDrawables: [...floors, ...walls, ...roofs].filter(
|
||||||
d => d.animatedFrames && d.animatedFrames.length > 1
|
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,
|
objectDrawables,
|
||||||
spawn,
|
spawn,
|
||||||
npcs: scene.npcs ?? [],
|
npcs: scene.npcs ?? [],
|
||||||
|
|
@ -984,6 +1003,8 @@ async function loadLiveRuntime(
|
||||||
walls,
|
walls,
|
||||||
roofs,
|
roofs,
|
||||||
animatedDrawables,
|
animatedDrawables,
|
||||||
|
animSpeed: town.animSpeed,
|
||||||
|
frameDurationMs: frameDurationMsFromAnimSpeed(town.animSpeed),
|
||||||
objectDrawables: [],
|
objectDrawables: [],
|
||||||
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
|
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
|
||||||
npcs: [],
|
npcs: [],
|
||||||
|
|
@ -1947,6 +1968,8 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
state.pagesTotal = next.pages.length
|
state.pagesTotal = next.pages.length
|
||||||
state.pagesLoaded = 0
|
state.pagesLoaded = 0
|
||||||
state.animatedTiles = next.animatedDrawables?.length ?? 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) => {
|
onRender: (_alpha: number, frameMs: number) => {
|
||||||
const renderStarted = performance.now()
|
const renderStarted = performance.now()
|
||||||
if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) {
|
if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) {
|
||||||
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time')
|
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time', runtime.frameDurationMs)
|
||||||
}
|
}
|
||||||
if (state.pagesAtFirstFrame < 0) {
|
if (state.pagesAtFirstFrame < 0) {
|
||||||
state.pagesAtFirstFrame = countPages()
|
state.pagesAtFirstFrame = countPages()
|
||||||
|
|
@ -2571,6 +2594,8 @@ function publish(runtime: MapRuntime, base: string): void {
|
||||||
state.objects = runtime.objects
|
state.objects = runtime.objects
|
||||||
state.roofs = runtime.roofs.length
|
state.roofs = runtime.roofs.length
|
||||||
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
|
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
|
||||||
|
state.animSpeed = runtime.animSpeed
|
||||||
|
state.frameDurationMs = runtime.frameDurationMs
|
||||||
state.roofAlpha = 1
|
state.roofAlpha = 1
|
||||||
state.missing = 0
|
state.missing = 0
|
||||||
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
|
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', () => {
|
describe('Frame Advancement & Timing', () => {
|
||||||
test('computeFrameIndexFromTime handles millisecond time advancement', () => {
|
test('computeFrameIndexFromTime handles millisecond time advancement', () => {
|
||||||
const frameCount = 4
|
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(0, frameCount, duration)).toBe(0)
|
||||||
expect(computeFrameIndexFromTime(50, frameCount, duration)).toBe(0)
|
expect(computeFrameIndexFromTime(64, frameCount, duration)).toBe(0)
|
||||||
expect(computeFrameIndexFromTime(100, frameCount, duration)).toBe(1)
|
expect(computeFrameIndexFromTime(128, frameCount, duration)).toBe(1)
|
||||||
expect(computeFrameIndexFromTime(199, frameCount, duration)).toBe(1)
|
expect(computeFrameIndexFromTime(255, frameCount, duration)).toBe(1)
|
||||||
expect(computeFrameIndexFromTime(200, frameCount, duration)).toBe(2)
|
expect(computeFrameIndexFromTime(256, frameCount, duration)).toBe(2)
|
||||||
expect(computeFrameIndexFromTime(350, frameCount, duration)).toBe(3)
|
expect(computeFrameIndexFromTime(400, frameCount, duration)).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('computeFrameIndexFromTime supports custom duration', () => {
|
test('computeFrameIndexFromTime supports custom duration', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue