feat(renderer/map): support animated tiles loop playback (fixes #43)
This commit is contained in:
parent
1fa3f8b7a2
commit
c56b808d7a
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue