feat(renderer/map): support animated tiles loop playback (fixes #43)

This commit is contained in:
troytt 2026-09-17 06:34:02 +00:00
parent 1fa3f8b7a2
commit c56b808d7a
5 changed files with 649 additions and 31 deletions

View File

@ -123,6 +123,14 @@ export interface Dt1Tile {
* with the engine's own offset when it is wired up.
*/
readonly roofHeight: number
/**
* Animated flag, at file offset +7.
*
* Non-zero (specifically bit 0 set) when this tile is part of an animated sequence.
* For animated tiles, `rarityFrameIndex` specifies the 0-based animation frame index
* rather than a variant selection weight.
*/
readonly animated: number
/**
* Variant weight inside one `style:sequence` group, at file offset +32.
*
@ -240,6 +248,20 @@ export function subTileFlagsOf(raw: number): SubTileFlags {
}
}
/**
* Whether a DT1 tile is part of an animated sequence.
*
* In Diablo II DT1 files, byte +7 is the animated flag (bit 0 set for animated floor/wall tiles).
* When animated, `rarityFrameIndex` represents the 0-based animation frame index
* rather than a random variation weight.
*
* @param tile - the DT1 tile to check.
* @returns true if the tile is an animated frame.
*/
export function isAnimatedTile(tile: { readonly animated?: number }): boolean {
return typeof tile.animated === 'number' && ((tile.animated & 1) === 1 || tile.animated > 0)
}
/**
* Decode a DT1 file.
*
@ -315,6 +337,7 @@ export function decodeDt1(data: Uint8Array): Dt1 {
sequence: i32(data, at + 28),
materialFlags: u16(data, at + 6),
roofHeight: i16(data, at + 4),
animated: new DataView(data.buffer, data.byteOffset, data.byteLength).getUint8(at + 7),
rarityFrameIndex: i32(data, at + 32),
subTileFlags,
},

180
src/game/animated-tiles.ts Normal file
View File

@ -0,0 +1,180 @@
/**
* DT1 Animated Tiles controller and runtime helpers.
*
* In Diablo II, certain environmental tiles (water ripples, lava, torches,
* fountains, cauldrons) are stored in DT1 format as animated sequences.
*
* Each animated tile group shares the same (style, sequence, type), with
* 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).
*/
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
/** 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
/** One placed frame of an animated tile. */
export interface AnimatedFrame {
readonly frame: AtlasFrame
readonly page: number
}
/** An animated tile or drawable that can advance frames. */
export interface AnimatableTile {
frame: AtlasFrame
page: number
readonly animatedFrames?: readonly AnimatedFrame[] | undefined
currentFrameIndex?: number | undefined
animTimeMs?: number | undefined
}
/**
* Compute the frame index given elapsed time and total frames.
* Automatically wraps around (modulo) to create a seamless loop.
*
* @param elapsedMs - elapsed milliseconds (game clock or timestamp).
* @param totalFrames - total number of frames in the animation sequence.
* @param frameDurationMs - duration of each frame in milliseconds (default 100ms / 10 FPS).
* @returns the 0-based frame index within [0, totalFrames - 1].
*/
export function computeFrameIndexFromTime(
elapsedMs: number,
totalFrames: number,
frameDurationMs = DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
): number {
if (totalFrames <= 1) return 0
const duration = frameDurationMs > 0 ? frameDurationMs : DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS
return Math.floor(Math.max(0, elapsedMs) / duration) % totalFrames
}
/**
* Compute the frame index given game simulation ticks and total frames.
* Automatically wraps around (modulo) to create a seamless loop.
*
* @param tick - simulation tick count (at 25Hz).
* @param totalFrames - total number of frames in the animation sequence.
* @param ticksPerFrame - ticks elapsed per frame (default 2.5 ticks / frame).
* @returns the 0-based frame index within [0, totalFrames - 1].
*/
export function computeFrameIndexFromTick(
tick: number,
totalFrames: number,
ticksPerFrame = DEFAULT_TICKS_PER_ANIMATED_FRAME,
): number {
if (totalFrames <= 1) return 0
const rate = ticksPerFrame > 0 ? ticksPerFrame : DEFAULT_TICKS_PER_ANIMATED_FRAME
return Math.floor(Math.max(0, tick) / rate) % totalFrames
}
/**
* Advance a frame index by a given step with cycle wrapping.
*
* @param currentIndex - current frame index.
* @param frameCount - total number of frames.
* @param step - number of frames to advance (default 1, can be negative).
* @returns the new frame index within [0, frameCount - 1].
*/
export function advanceFrameIndex(currentIndex: number, frameCount: number, step = 1): number {
if (frameCount <= 1) return 0
const next = (currentIndex + step) % frameCount
return next >= 0 ? next : next + frameCount
}
/**
* Update an animatable tile's current frame according to clock (timestamp or tick).
*
* @param tile - the animatable tile to update.
* @param clock - current timestamp in ms or tick count.
* @param mode - 'time' for millisecond timestamps or 'tick' for simulation ticks.
* @param rate - optional override for frame duration (ms) or ticks per frame.
* @returns true if the frame index changed, false otherwise.
*/
export function updateAnimatableTile(
tile: AnimatableTile,
clock: number,
mode: 'time' | 'tick' = 'time',
rate?: number,
): boolean {
const frames = tile.animatedFrames
if (!frames || frames.length <= 1) return false
const targetIndex = mode === 'time'
? computeFrameIndexFromTime(clock, frames.length, rate)
: computeFrameIndexFromTick(clock, frames.length, rate)
if (targetIndex === tile.currentFrameIndex) return false
tile.currentFrameIndex = targetIndex
const targetFrame = frames[targetIndex]
if (targetFrame !== undefined) {
tile.frame = targetFrame.frame
tile.page = targetFrame.page
return true
}
return false
}
/**
* Advance an animatable tile by a discrete step.
*
* @param tile - the animatable tile.
* @param step - number of frames to advance (default 1).
* @returns true if the frame index changed.
*/
export function advanceAnimatableTile(tile: AnimatableTile, step = 1): boolean {
const frames = tile.animatedFrames
if (!frames || frames.length <= 1) return false
const currentIndex = tile.currentFrameIndex ?? 0
const nextIndex = advanceFrameIndex(currentIndex, frames.length, step)
if (nextIndex === tile.currentFrameIndex) return false
tile.currentFrameIndex = nextIndex
const targetFrame = frames[nextIndex]
if (targetFrame !== undefined) {
tile.frame = targetFrame.frame
tile.page = targetFrame.page
return true
}
return false
}
/**
* Update a list of animatable tiles according to clock (timestamp or tick).
*
* @param tiles - array of animatable tiles.
* @param clock - timestamp in ms or simulation tick.
* @param mode - 'time' or 'tick'.
* @param rate - optional rate override.
* @returns the number of tiles whose active frame changed.
*/
export function updateAnimatableTiles(
tiles: readonly AnimatableTile[],
clock: number,
mode: 'time' | 'tick' = 'time',
rate?: number,
): number {
let updated = 0
for (let i = 0; i < tiles.length; i += 1) {
if (updateAnimatableTile(tiles[i]!, clock, mode, rate)) {
updated += 1
}
}
return updated
}
/**
* Advance all animatable tiles by a discrete step.
*
* @param tiles - array of animatable tiles.
* @param step - step count.
* @returns the number of tiles updated.
*/
export function advanceAnimatableTiles(tiles: readonly AnimatableTile[], step = 1): number {
let updated = 0
for (let i = 0; i < tiles.length; i += 1) {
if (advanceAnimatableTile(tiles[i]!, step)) {
updated += 1
}
}
return updated
}

View File

@ -25,6 +25,7 @@
*/
import type { Ds1 } from '../formats/ds1.ts'
import type { Dt1, Dt1Tile } from '../formats/dt1.ts'
import { isAnimatedTile } from '../formats/dt1.ts'
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
@ -83,6 +84,13 @@ const TOP_CORNER_LEFT_TYPE = 4
export interface IsoDraw {
/** Index into {@link IsoMapScene.frames}. */
readonly frameIndex: number
/**
* Sequence of frame indices for animated tiles (e.g. water ripples, torches, fountains, lava).
*
* When present and containing more than 1 frame, the tile is animated and
* the renderer can cycle through these frame indices over time or game ticks.
*/
readonly animatedFrames?: readonly number[] | undefined
/** Screen x of the bitmap's left edge, origin applied. */
readonly x: number
/** Screen y of the bitmap's top edge, origin applied. */
@ -177,6 +185,8 @@ export interface IsoMapScene {
readonly cornerPairs: number
/** Top-corner-right walls whose left half could not be resolved (should be 0). */
readonly missingCornerPairs: number
/** Number of animated tiles in this scene. */
readonly animatedTileCount?: number | undefined
}
/**
@ -210,6 +220,10 @@ interface PoolTile {
readonly tile: number
/** Variant weight (`Dt1Tile.rarityFrameIndex`) used when picking per cell. */
readonly weight: number
/** Whether this tile is part of an animated sequence. */
readonly animated: boolean
/** 0-based animation frame index when animated is true. */
readonly frameIndex: number
}
/** The merged pool a DS1's references are resolved against. */
@ -246,7 +260,14 @@ function mergeLibraries(libraries: readonly Dt1[]): TilePool {
}
libraries.forEach((library, libraryIndex) => {
library.tiles.forEach((tile, tileIndex) => {
const entry = { library: libraryIndex, tile: tileIndex, weight: tile.rarityFrameIndex }
const animated = isAnimatedTile(tile)
const entry: PoolTile = {
library: libraryIndex,
tile: tileIndex,
weight: tile.rarityFrameIndex,
animated,
frameIndex: animated ? Math.max(0, tile.rarityFrameIndex) : 0,
}
// The third component is the DS1 wall `type` and it matches the DT1 tile's
// **`Type` field at offset +20**, not its `Direction` field at +0. `Direction`
// is the orientation/variant index (1..5 in practice) and never equals the
@ -309,6 +330,42 @@ function pickVariant(
return candidates[candidates.length - 1]
}
/**
* Result of resolving a candidate group.
*/
interface ResolvedCandidates {
readonly tile: PoolTile | undefined
readonly animatedTiles?: readonly PoolTile[] | undefined
}
/**
* Resolve a candidate group from the pool:
* If the candidates are animated tiles, sort them by frameIndex (0..N-1) and return
* the base frame as `tile` and the sequence as `animatedTiles`.
* Otherwise, pick one variant per cell using weighted random draw.
*/
function resolveCandidates(
candidates: readonly PoolTile[] | undefined,
cellX: number,
cellY: number,
seed: number,
): ResolvedCandidates {
if (candidates === undefined || candidates.length === 0) {
return { tile: undefined }
}
const isAnim = candidates.some(c => c.animated)
if (isAnim) {
const sorted = [...candidates].sort((a, b) => a.frameIndex - b.frameIndex)
return {
tile: sorted[0],
animatedTiles: sorted.length > 1 ? sorted : undefined,
}
}
return {
tile: pickVariant(candidates, cellX, cellY, seed),
}
}
/**
* Resolve a DS1 reference against the pool.
*
@ -316,7 +373,7 @@ function pickVariant(
* @param style - DS1 style field.
* @param sequence - DS1 sequence field.
* @param type - DS1 wall type (the third field of the reference).
* @returns the pool tile, or undefined when nothing matches.
* @returns the resolved pool tile and animated sequence.
*/
function resolveExactType(
pool: TilePool,
@ -326,8 +383,8 @@ function resolveExactType(
cellX: number,
cellY: number,
seed: number,
): PoolTile | undefined {
return pickVariant(pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`), cellX, cellY, seed)
): ResolvedCandidates {
return resolveCandidates(pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`), cellX, cellY, seed)
}
/**
@ -337,7 +394,7 @@ function resolveExactType(
* @param style - DS1 style field.
* @param sequence - DS1 sequence field.
* @param type - DS1 wall type (the third field of the reference), or null for floors.
* @returns the pool tile, or undefined when nothing matches.
* @returns the pool tile, animated tiles if any, and whether it matched via loose fallback.
*/
function resolveWithSource(
pool: TilePool,
@ -347,11 +404,12 @@ function resolveWithSource(
cellX: number,
cellY: number,
seed: number,
): { tile: PoolTile | undefined; viaLoose: boolean } {
): { tile: PoolTile | undefined; animatedTiles?: readonly PoolTile[] | undefined; viaLoose: boolean } {
const key = `${String(style)}:${String(sequence)}`
const exact = pickVariant(pool.exact.get(`${key}:${String(type)}`), cellX, cellY, seed)
if (exact !== undefined) return { tile: exact, viaLoose: false }
return { tile: pickVariant(pool.loose.get(key), cellX, cellY, seed), viaLoose: true }
const exact = resolveExactType(pool, style, sequence, type, cellX, cellY, seed)
if (exact.tile !== undefined) return { tile: exact.tile, animatedTiles: exact.animatedTiles, viaLoose: false }
const loose = resolveCandidates(pool.loose.get(key), cellX, cellY, seed)
return { tile: loose.tile, animatedTiles: loose.animatedTiles, viaLoose: true }
}
/**
@ -404,7 +462,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
const gridWidth = cellsX * SUB_TILES_PER_TILE
const gridHeight = cellsY * SUB_TILES_PER_TILE
const blocked = new Uint8Array(gridWidth * gridHeight)
const rawFloors: { frameIndex: number; x: number; y: number; cellX: number; cellY: number; library: number; tile: number }[] = []
const rawFloors: { frameIndex: number; animatedFrames?: readonly number[]; x: number; y: number; cellX: number; cellY: number; library: number; tile: number }[] = []
const rawWalls: typeof rawFloors = []
/** Roof draws (`wall.type` 15): kept apart so they can be painted last. */
const rawRoofs: typeof rawFloors = []
@ -463,8 +521,10 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
const found = picked.tile
if (found === undefined) { noteMissing('floor', floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY); continue }
const tile = libraries[found.library]!.tiles[found.tile]!
const animatedFrames = picked.animatedTiles?.map(t => frameFor(t.library, t.tile))
rawFloors.push({
frameIndex: frameFor(found.library, found.tile),
...(animatedFrames && animatedFrames.length > 1 ? { animatedFrames } : {}),
x: orthoX + TILE_ANCHOR_X,
y: orthoY,
cellX, cellY, library: found.library, tile: found.tile,
@ -480,7 +540,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
// Shadows must come from a **type 13** tile specifically: the loose
// fallback ignores the type field and would happily return a wall tile,
// stamping its blocking flags as if they were a shadow's.
const found = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed)
const found = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed).tile
if (found === undefined) continue
const tile = libraries[found.library]!.tiles[found.tile]!
for (let sub = 0; sub < SUB_TILES_PER_TILE * SUB_TILES_PER_TILE; sub += 1) {
@ -502,8 +562,10 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
if (found === undefined) { noteMissing('wall', wall.style, wall.sequence, wall.type, cellX, cellY); continue }
const tile = libraries[found.library]!.tiles[found.tile]!
if (tile.bitmapHeight > Math.abs(tile.height)) clippedTiles += 1
const animatedFrames = pickedWall.animatedTiles?.map(t => frameFor(t.library, t.tile))
const draw = {
frameIndex: frameFor(found.library, found.tile),
...(animatedFrames && animatedFrames.length > 1 ? { animatedFrames } : {}),
x: orthoX + TILE_ANCHOR_X,
// A wall's art extends above its cell, so it is pushed back down by
// exactly the block shift the decoder applied, plus one cell height.
@ -525,14 +587,17 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
// engine's DS1s never list the left half themselves, so this is the only way
// the corner gets both faces — 411 of them across the 35 preset levels.
if (tile.type === TOP_CORNER_RIGHT_TYPE) {
const companion = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed)
const companionPicked = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed)
const companion = companionPicked.tile
if (companion === undefined) {
missingCornerPairs += 1
noteMissing('corner-left', wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY)
} else {
const leftTile = libraries[companion.library]!.tiles[companion.tile]!
const companionAnim = companionPicked.animatedTiles?.map(t => frameFor(t.library, t.tile))
rawWalls.push({
frameIndex: frameFor(companion.library, companion.tile),
...(companionAnim && companionAnim.length > 1 ? { animatedFrames: companionAnim } : {}),
x: orthoX + TILE_ANCHOR_X,
y: orthoY + leftTile.minBlockY + WALL_SURFACE_HEIGHT,
cellX, cellY, library: companion.library, tile: companion.tile,
@ -567,6 +632,13 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
const originY = -minY
const shift = (draw: (typeof rawFloors)[number]): IsoDraw => ({ ...draw, x: draw.x + originX, y: draw.y + originY })
const floors = rawFloors.map(shift)
const walls = rawWalls.map(shift)
const roofs = rawRoofs.map(shift)
const animatedTileCount = [...floors, ...walls, ...roofs].filter(
d => d.animatedFrames && d.animatedFrames.length > 1
).length
return {
frames,
sheet: { groups: [{ frames }], width: null },
@ -576,9 +648,9 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
heightPx: maxY - minY,
originX,
originY,
floors: rawFloors.map(shift),
walls: rawWalls.map(shift),
roofs: rawRoofs.map(shift),
floors,
walls,
roofs,
blocked,
gridWidth,
gridHeight,
@ -590,6 +662,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
cornerPairs,
missingCornerPairs,
duplicateRefs: pool.duplicates,
animatedTileCount,
}
}

View File

@ -31,7 +31,9 @@ import {
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
buildIsoMapScene, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
} from '../game/d2map.ts'
import type { CollisionGrid } from '../game/d2map.ts'
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
import { updateAnimatableTiles } from '../game/animated-tiles.ts'
import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
import { createIsoTerrain } from '../game/iso-terrain.ts'
import { spawnMonsterPacks, spawnMonsters } from '../game/combat.ts'
import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
@ -193,6 +195,8 @@ export interface ActSceneState {
dialog: string[]
npcs: number
npcsNear: NpcEntity[]
/** Count of animated tiles in the active map. */
animatedTiles: number
}
declare global {
@ -211,19 +215,20 @@ const state: ActSceneState = {
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0,
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [],
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
}
window.__d2webAct = state
/** One tile to draw. */
interface Drawable {
readonly frame: AtlasFrame
interface Drawable extends AnimatableTile {
frame: AtlasFrame
readonly x: number
readonly y: number
readonly cellX: number
readonly cellY: number
/** Atlas page index this frame lives on. */
readonly page: number
page: number
readonly animatedFrames?: readonly AnimatedFrame[] | undefined
}
/** One object sprite to draw. */
@ -276,6 +281,8 @@ interface MapRuntime {
readonly walls: readonly Drawable[]
/** Roof draws: painted after the character, so a roof covers what walks under it. */
readonly roofs: readonly Drawable[]
/** Animated tile drawables whose frames cycle during rendering. */
readonly animatedDrawables?: readonly Drawable[] | undefined
/** Object draws: interleaved with walls in painter's order. */
readonly objectDrawables: readonly ObjectDrawable[]
readonly spawn: { x: number; y: number }
@ -559,6 +566,13 @@ async function buildPackRuntime(
}))
const toDrawable = (row: readonly number[]): Drawable => {
const frameIndex = row[0] ?? 0
const animFrames = row.length > 5 ? row.slice(5) : undefined
const anim = animFrames && animFrames.length > 1
? animFrames.map(fIdx => ({
frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 },
page: scene.framePlacement[fIdx]?.[0] ?? 0,
}))
: undefined
return {
frame: frames[frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row[1] ?? 0,
@ -566,6 +580,7 @@ async function buildPackRuntime(
cellX: row[3] ?? 0,
cellY: row[4] ?? 0,
page: scene.framePlacement[frameIndex]?.[0] ?? 0,
...(anim ? { animatedFrames: anim } : {}),
}
}
const floors = scene.floors.map(toDrawable)
@ -688,6 +703,9 @@ async function buildPackRuntime(
floors,
walls,
roofs,
animatedDrawables: [...floors, ...walls, ...roofs].filter(
d => d.animatedFrames && d.animatedFrames.length > 1
),
objectDrawables,
spawn,
npcs: scene.npcs ?? [],
@ -830,14 +848,29 @@ async function loadLiveRuntime(
const scene = buildIsoMapScene(level, libraries, levelSeed(town.ds1Names[index] ?? town.levelName))
const atlas = buildAtlasThatFits(renderer, scene.sheet, palette)
const frames = atlas.groups[0] ?? []
const toDrawable = (row: { frameIndex: number; x: number; y: number; cellX: number; cellY: number }): Drawable => ({
frame: frames[row.frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row.x,
y: row.y,
cellX: row.cellX,
cellY: row.cellY,
page: 0,
})
const toDrawable = (row: IsoDraw): Drawable => {
const anim = row.animatedFrames && row.animatedFrames.length > 1
? row.animatedFrames.map(fIdx => ({
frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 },
page: 0,
}))
: undefined
return {
frame: frames[row.frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row.x,
y: row.y,
cellX: row.cellX,
cellY: row.cellY,
page: 0,
...(anim ? { animatedFrames: anim } : {}),
}
}
const floors = scene.floors.map(toDrawable)
const walls = scene.walls.map(toDrawable)
const roofs = scene.roofs.map(toDrawable)
const animatedDrawables = [...floors, ...walls, ...roofs].filter(
d => d.animatedFrames && d.animatedFrames.length > 1
)
// Plan the level's population from its own `Levels.txt` row. The seed is the
// level's, so the same map always comes back with the same monsters in the
// same camps — which is what makes a save reloadable.
@ -875,9 +908,10 @@ async function loadLiveRuntime(
widthPx: scene.widthPx,
heightPx: scene.heightPx,
grid: scene,
floors: scene.floors.map(toDrawable),
walls: scene.walls.map(toDrawable),
roofs: scene.roofs.map(toDrawable),
floors,
walls,
roofs,
animatedDrawables,
objectDrawables: [],
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
npcs: [],
@ -1546,6 +1580,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
state.cellsY = next.cellsY
state.pagesTotal = next.pages.length
state.pagesLoaded = 0
state.animatedTiles = next.animatedDrawables?.length ?? 0
}
/**
@ -1753,6 +1788,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
},
onRender: () => {
const renderStarted = performance.now()
if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) {
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time')
}
if (state.pagesAtFirstFrame < 0) {
state.pagesAtFirstFrame = countPages()
state.firstFrameMs = Math.round(performance.now() - started)
@ -2121,6 +2159,7 @@ function publish(runtime: MapRuntime, base: string): void {
state.priorityPages = [...runtime.priorityPages]
state.objects = runtime.objects
state.roofs = runtime.roofs.length
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
state.missing = 0
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
}

View File

@ -0,0 +1,303 @@
import { describe, expect, test } from 'vitest'
import {
advanceFrameIndex,
computeFrameIndexFromTick,
computeFrameIndexFromTime,
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
DEFAULT_TICKS_PER_ANIMATED_FRAME,
updateAnimatableTile,
updateAnimatableTiles,
} from '../src/game/animated-tiles.ts'
import type { AnimatableTile } from '../src/game/animated-tiles.ts'
import { decodeDt1, isAnimatedTile, subTileFlagsOf } from '../src/formats/dt1.ts'
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
import { buildIsoMapScene } from '../src/game/d2map.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
describe('Animated Tiles (Issue #43)', () => {
describe('DT1 Animated Tile Detection', () => {
test('isAnimatedTile correctly identifies animated flag byte', () => {
expect(isAnimatedTile({ animated: 0 })).toBe(false)
expect(isAnimatedTile({})).toBe(false)
expect(isAnimatedTile({ animated: 1 })).toBe(true)
expect(isAnimatedTile({ animated: 3 })).toBe(true)
expect(isAnimatedTile({ animated: 0x81 })).toBe(true)
})
test('decodeDt1 parses animated flag at offset +7', () => {
// Construct a minimal valid DT1 buffer with 1 tile
// DT1 header:
// 0..3: magic1 (7)
// 4..7: magic2 (6)
// 8..267: unused header bytes (260 bytes zeros)
// 268..271: tileCount (1)
// 272..275: tileOffset (276)
// Tile header at 276:
// +0: direction (i32: 1)
// +4: roofHeight (i16: 0)
// +6: materialFlags (u16: 0)
// +7: animated (u8: 1) <-- tested byte
// +8: height (i32: -80)
// +12: width (i32: 160)
// +16: unused (4 bytes)
// +20: type (i32: 0)
// +24: style (i32: 1)
// +28: sequence (i32: 2)
// +32: rarityFrameIndex (i32: 0)
// +36..+59: subTileFlags (20 bytes)
// +60..+79: unused (20 bytes)
// +80: blockHeaderPointer (i32: 0)
// +84: blockDataOffset (i32: 0)
// +88: blockCount (i32: 0)
// Total tile header size = 96 bytes.
const buffer = new Uint8Array(276 + 96)
const view = new DataView(buffer.buffer)
view.setInt32(0, 7, true)
view.setInt32(4, 6, true)
view.setInt32(268, 1, true)
view.setInt32(272, 276, true)
const tileAt = 276
view.setInt32(tileAt + 0, 1, true) // direction
view.setInt16(tileAt + 4, 0, true) // roofHeight
view.setUint16(tileAt + 6, 0, true) // materialFlags
buffer[tileAt + 7] = 1 // animated flag
view.setInt32(tileAt + 8, -80, true) // height
view.setInt32(tileAt + 12, 160, true) // width
view.setInt32(tileAt + 20, 0, true) // type
view.setInt32(tileAt + 24, 1, true) // style
view.setInt32(tileAt + 28, 2, true) // sequence
view.setInt32(tileAt + 32, 0, true) // rarityFrameIndex (frame 0)
const dt1 = decodeDt1(buffer)
expect(dt1.tiles.length).toBe(1)
const tile = dt1.tiles[0]!
expect(tile.animated).toBe(1)
expect(isAnimatedTile(tile)).toBe(true)
expect(tile.rarityFrameIndex).toBe(0)
})
})
describe('Frame Advancement & Timing', () => {
test('computeFrameIndexFromTime handles millisecond time advancement', () => {
const frameCount = 4
const duration = DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS // 100ms
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)
})
test('computeFrameIndexFromTime supports custom duration', () => {
const frameCount = 5
const duration = 50 // 50ms per frame = 20 FPS
expect(computeFrameIndexFromTime(0, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(49, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(50, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(150, frameCount, duration)).toBe(3)
})
test('computeFrameIndexFromTime gracefully handles single-frame or invalid frame count', () => {
expect(computeFrameIndexFromTime(1000, 1)).toBe(0)
expect(computeFrameIndexFromTime(1000, 0)).toBe(0)
expect(computeFrameIndexFromTime(-500, 4)).toBe(0)
})
test('computeFrameIndexFromTick handles 25Hz game ticks', () => {
const frameCount = 4
const ticksPerFrame = DEFAULT_TICKS_PER_ANIMATED_FRAME // 2.5 ticks / frame
expect(computeFrameIndexFromTick(0, frameCount, ticksPerFrame)).toBe(0)
expect(computeFrameIndexFromTick(2, frameCount, ticksPerFrame)).toBe(0) // 2 / 2.5 = 0.8 -> 0
expect(computeFrameIndexFromTick(3, frameCount, ticksPerFrame)).toBe(1) // 3 / 2.5 = 1.2 -> 1
expect(computeFrameIndexFromTick(5, frameCount, ticksPerFrame)).toBe(2) // 5 / 2.5 = 2.0 -> 2
expect(computeFrameIndexFromTick(7, frameCount, ticksPerFrame)).toBe(2) // 7 / 2.5 = 2.8 -> 2
expect(computeFrameIndexFromTick(8, frameCount, ticksPerFrame)).toBe(3) // 8 / 2.5 = 3.2 -> 3
})
test('advanceFrameIndex manual frame increment and wrapping', () => {
expect(advanceFrameIndex(0, 4)).toBe(1)
expect(advanceFrameIndex(1, 4)).toBe(2)
expect(advanceFrameIndex(2, 4)).toBe(3)
expect(advanceFrameIndex(3, 4)).toBe(0) // Wrap!
expect(advanceFrameIndex(0, 4, 5)).toBe(1) // Advance 5 frames: 5 % 4 = 1
expect(advanceFrameIndex(2, 1)).toBe(0)
})
})
describe('Cycle Wrapping (Continuous Loop Playback)', () => {
test('time-based cycling loops indefinitely without drift', () => {
const frameCount = 3
const duration = 100 // ms
// Cycle 0: 0..299ms
expect(computeFrameIndexFromTime(0, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(100, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(200, frameCount, duration)).toBe(2)
// Cycle 1: 300..599ms
expect(computeFrameIndexFromTime(300, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(400, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(500, frameCount, duration)).toBe(2)
// Cycle 100: 30000ms
expect(computeFrameIndexFromTime(30000, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(30150, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(30250, frameCount, duration)).toBe(2)
})
test('tick-based cycling loops indefinitely', () => {
const frameCount = 4
const ticksPerFrame = 2.5 // total cycle = 10 ticks
// Cycle 0: ticks 0..9
expect(computeFrameIndexFromTick(0, frameCount, ticksPerFrame)).toBe(0)
expect(computeFrameIndexFromTick(3, frameCount, ticksPerFrame)).toBe(1)
expect(computeFrameIndexFromTick(5, frameCount, ticksPerFrame)).toBe(2)
expect(computeFrameIndexFromTick(8, frameCount, ticksPerFrame)).toBe(3)
// Cycle 1: ticks 10..19
expect(computeFrameIndexFromTick(10, frameCount, ticksPerFrame)).toBe(0)
expect(computeFrameIndexFromTick(13, frameCount, ticksPerFrame)).toBe(1)
expect(computeFrameIndexFromTick(15, frameCount, ticksPerFrame)).toBe(2)
expect(computeFrameIndexFromTick(18, frameCount, ticksPerFrame)).toBe(3)
})
})
describe('AnimatableTile Mutator Functions', () => {
test('updateAnimatableTile updates frame and page in-place', () => {
const frame0 = { x: 0, y: 0, width: 160, height: 80 }
const frame1 = { x: 160, y: 0, width: 160, height: 80 }
const frame2 = { x: 320, y: 0, width: 160, height: 80 }
const tile: AnimatableTile = {
frame: frame0,
page: 0,
animatedFrames: [
{ frame: frame0, page: 0 },
{ frame: frame1, page: 0 },
{ frame: frame2, page: 1 },
],
}
// At 0ms -> frame0, page 0
updateAnimatableTile(tile, 0, 'time', 100)
expect(tile.frame).toBe(frame0)
expect(tile.page).toBe(0)
// At 150ms -> frame1, page 0
updateAnimatableTile(tile, 150, 'time', 100)
expect(tile.frame).toBe(frame1)
expect(tile.page).toBe(0)
// At 250ms -> frame2, page 1
updateAnimatableTile(tile, 250, 'time', 100)
expect(tile.frame).toBe(frame2)
expect(tile.page).toBe(1)
// At 350ms -> wraps back to frame0, page 0
updateAnimatableTile(tile, 350, 'time', 100)
expect(tile.frame).toBe(frame0)
expect(tile.page).toBe(0)
})
test('updateAnimatableTiles updates an array of animatable tiles', () => {
const f0 = { x: 0, y: 0, width: 160, height: 80 }
const f1 = { x: 160, y: 0, width: 160, height: 80 }
const tileA: AnimatableTile = {
frame: f0,
page: 0,
animatedFrames: [{ frame: f0, page: 0 }, { frame: f1, page: 0 }],
}
const tileB: AnimatableTile = {
frame: f0,
page: 0,
animatedFrames: [{ frame: f0, page: 0 }, { frame: f1, page: 0 }],
}
const staticTile: AnimatableTile = {
frame: f0,
page: 0,
}
const list = [tileA, tileB, staticTile]
updateAnimatableTiles(list, 150, 'time', 100)
expect(tileA.frame).toBe(f1)
expect(tileB.frame).toBe(f1)
expect(staticTile.frame).toBe(f0) // Unchanged
})
})
describe('d2map buildIsoMapScene Animated Sequence Resolution', () => {
test('resolves multi-frame animated tiles into sequential animatedFrames', () => {
// Create 3 DT1 tiles with same style, sequence, type, with animated flag = 1
// and frame indices 0, 1, 2
const makeTile = (frameIndex: number): Dt1Tile => ({
direction: 1,
roofHeight: 0,
materialFlags: 0,
animated: 1,
height: -80,
width: 160,
type: 0, // floor
style: 5,
sequence: 1,
rarityFrameIndex: frameIndex,
subTileFlags: Array.from({ length: 25 }, () => subTileFlagsOf(0)),
blocks: [],
minBlockY: 0,
bitmapHeight: 80,
})
// Provide tiles out of order to ensure sorting by frameIndex works: [frame 2, frame 0, frame 1]
const dt1Library: Dt1 = {
versionMajor: 7,
versionMinor: 6,
tiles: [makeTile(2), makeTile(0), makeTile(1)],
warnings: [],
}
// Create a minimal 1x1 DS1 referencing style 5, sequence 1
const ds1: Ds1 = {
version: 18,
width: 1,
height: 1,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells: [[{
floors: [{ style: 5, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
}]],
objects: [],
npcPathOffset: null,
}
const scene = buildIsoMapScene(ds1, [dt1Library])
expect(scene.animatedTileCount).toBe(1)
expect(scene.floors.length).toBe(1)
const floorDraw = scene.floors[0]!
// animatedFrames should exist and have 3 frames
expect(floorDraw.animatedFrames).toBeDefined()
expect(floorDraw.animatedFrames?.length).toBe(3)
// The base frame index should be the first frame (frameIndex 0)
const frameIndices = floorDraw.animatedFrames!
expect(frameIndices.length).toBe(3)
// All 3 frames should be distinct frame indices in scene.frames
expect(new Set(frameIndices).size).toBe(3)
// Base frame should be frame 0
expect(floorDraw.frameIndex).toBe(frameIndices[0])
})
})
})