304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
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])
|
|
})
|
|
})
|
|
})
|