From c56b808d7ac1fa152a15871da7a1f435de623023 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 06:34:02 +0000 Subject: [PATCH] feat(renderer/map): support animated tiles loop playback (fixes #43) --- src/formats/dt1.ts | 23 +++ src/game/animated-tiles.ts | 180 +++++++++++++++++++++ src/game/d2map.ts | 103 ++++++++++-- src/scene/act-scene.ts | 71 ++++++-- tests/animated-tiles.test.ts | 303 +++++++++++++++++++++++++++++++++++ 5 files changed, 649 insertions(+), 31 deletions(-) create mode 100644 src/game/animated-tiles.ts create mode 100644 tests/animated-tiles.test.ts diff --git a/src/formats/dt1.ts b/src/formats/dt1.ts index 8f82fd3..a610177 100644 --- a/src/formats/dt1.ts +++ b/src/formats/dt1.ts @@ -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, }, diff --git a/src/game/animated-tiles.ts b/src/game/animated-tiles.ts new file mode 100644 index 0000000..c7dd582 --- /dev/null +++ b/src/game/animated-tiles.ts @@ -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 +} diff --git a/src/game/d2map.ts b/src/game/d2map.ts index 134c137..ae61a53 100644 --- a/src/game/d2map.ts +++ b/src/game/d2map.ts @@ -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, } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 3e71dac..0e6e553 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 } diff --git a/tests/animated-tiles.test.ts b/tests/animated-tiles.test.ts new file mode 100644 index 0000000..1745f89 --- /dev/null +++ b/tests/animated-tiles.test.ts @@ -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]) + }) + }) +})