diablo2-web/tests/shadow-rendering.test.ts

331 lines
11 KiB
TypeScript

/**
* @file tests/shadow-rendering.test.ts
*
* Tests for the Shadow Layer (DT1 Type 13) rendering and placement parity with
* Diablo II v1.13c.
*
* Ground Truth Invariants:
* 1. DT1 tiles with `type === 13` are shadow tiles.
* 2. Shadows are ground-aligned: screen anchor uses (orthoX - 80, orthoY) identical
* to floor tiles, without wall vertical block adjustment (`minBlockY + 80`).
* 3. `IsoMapScene` exposes `shadows: readonly IsoDraw[]`, sorted by isometric depth.
* 4. Sub-tile collision flags of shadow layers are unioned into `collisionMasks` and `blocked`.
* 5. Real Act 1 presets (Ruin2.ds1) carry shadow tiles that resolve to DT1 type 13.
*/
import { describe, it, expect } from 'vitest'
import {
buildIsoMapScene,
ORTHO_CELL_HEIGHT,
ORTHO_CELL_WIDTH,
} from '../src/game/d2map.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import { decodeDt1, subTileFlagsOf, type Dt1, type Dt1Tile } from '../src/formats/dt1.ts'
import { loadActTables, resolveLevelLibraries } from '../src/game/acts.ts'
import { existsSync } from 'node:fs'
describe('Shadow Layer (DT1 Type 13) Rendering & Parity', () => {
const makeFloorTile = (style = 1, sequence = 1): Dt1Tile => ({
direction: 1,
roofHeight: 0,
materialFlags: 0,
animated: 0,
height: -80,
width: 160,
type: 0, // floor
style,
sequence,
rarityFrameIndex: 0,
subTileFlags: Array.from({ length: 25 }, () => subTileFlagsOf(0)),
blocks: [],
minBlockY: 0,
bitmapHeight: 80,
})
const makeShadowTile = (style = 2, sequence = 1, animated = 0, blockWalk = false): Dt1Tile => ({
direction: 1,
roofHeight: 0,
materialFlags: 0,
animated,
height: -80,
width: 160,
type: 13, // shadow
style,
sequence,
rarityFrameIndex: 0,
subTileFlags: Array.from({ length: 25 }, (_, i) =>
subTileFlagsOf(blockWalk && i === 12 ? 0x0001 : 0),
),
blocks: [],
minBlockY: 0,
bitmapHeight: 80,
})
it('buildIsoMapScene parses and outputs shadows array with exact type 13', () => {
const floorTile = makeFloorTile(1, 1)
const shadowTile = makeShadowTile(2, 1)
const dt1: Dt1 = {
versionMajor: 7,
versionMinor: 6,
tiles: [floorTile, shadowTile],
warnings: [],
}
const ds1: Ds1 = {
version: 18,
width: 2,
height: 2,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells: [
[
{
floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
substitutions: [],
},
{
floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
},
],
[
{
floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
},
{
floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
substitutions: [],
},
],
],
objects: [],
npcPathOffset: null,
}
const scene = buildIsoMapScene(ds1, [dt1])
expect(scene.shadows).toBeDefined()
expect(scene.shadows.length).toBe(2)
// Check first shadow draw at cell(0, 0)
const s0 = scene.shadows[0]!
expect(s0.cellX).toBe(0)
expect(s0.cellY).toBe(0)
expect(s0.x).toBe(scene.originX - 80)
expect(s0.y).toBeCloseTo(scene.originY)
// Check second shadow draw at cell(1, 1)
const s1 = scene.shadows[1]!
expect(s1.cellX).toBe(1)
expect(s1.cellY).toBe(1)
expect(s1.x).toBe(scene.originX - 80)
expect(s1.y).toBeCloseTo(scene.originY + 2 * ORTHO_CELL_HEIGHT)
// Both must point to the shadow tile (index 1)
expect(s0.tile).toBe(1)
expect(s1.tile).toBe(1)
})
it('shadows are ground-anchored and do NOT inherit wall minBlockY offset', () => {
// If a shadow tile has minBlockY = -40 (like some trees), it must STILL be drawn
// on the floor ground plane (draw.y === originY + (cx + cy) * 40).
const floorTile = makeFloorTile(1, 1)
const shadowTile: Dt1Tile = {
...makeShadowTile(2, 1),
minBlockY: -64,
}
const dt1: Dt1 = {
versionMajor: 7,
versionMinor: 6,
tiles: [floorTile, shadowTile],
warnings: [],
}
const ds1: Ds1 = {
version: 18,
width: 1,
height: 1,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells: [
[
{
floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
substitutions: [],
},
],
],
objects: [],
npcPathOffset: null,
}
const scene = buildIsoMapScene(ds1, [dt1])
const floor = scene.floors[0]!
const shadow = scene.shadows[0]!
// Floor and shadow must have identical screen origins
expect(shadow.x).toBe(floor.x)
expect(shadow.y).toBe(floor.y)
expect(shadow.y - scene.originY).toBe(0)
})
it('shadows fold blocking flags into collisionMasks and blocked array', () => {
const floorTile = makeFloorTile(1, 1)
const shadowTile = makeShadowTile(2, 1, 0, true) // sub-tile 12 blocks walk
const dt1: Dt1 = {
versionMajor: 7,
versionMinor: 6,
tiles: [floorTile, shadowTile],
warnings: [],
}
const ds1: Ds1 = {
version: 18,
width: 1,
height: 1,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells: [
[
{
floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
substitutions: [],
},
],
],
objects: [],
npcPathOffset: null,
}
const scene = buildIsoMapScene(ds1, [dt1])
expect(scene.shadowBlockedSubtiles).toBe(1)
expect(scene.blocked[12]).toBe(1)
expect(scene.collisionMasks[12]! & 0x0001).toBe(0x0001)
})
it('shadows sort by isometric depth order (cellX + cellY)', () => {
const floorTile = makeFloorTile(1, 1)
const shadowTile = makeShadowTile(2, 1)
const dt1: Dt1 = {
versionMajor: 7,
versionMinor: 6,
tiles: [floorTile, shadowTile],
warnings: [],
}
const ds1: Ds1 = {
version: 18,
width: 3,
height: 3,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells: [
[
{ floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [] },
{ floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [], substitutions: [] },
{ floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [] },
],
[
{ floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [], substitutions: [] },
{ floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [] },
{ floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [], substitutions: [] },
],
[
{ floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [] },
{ floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [], substitutions: [] },
{ floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [] },
],
],
objects: [],
npcPathOffset: null,
}
const scene = buildIsoMapScene(ds1, [dt1])
expect(scene.shadows.length).toBe(5)
for (let i = 0; i < scene.shadows.length - 1; i += 1) {
const a = scene.shadows[i]!
const b = scene.shadows[i + 1]!
const depthA = a.cellX + a.cellY
const depthB = b.cellX + b.cellY
expect(depthA <= depthB).toBe(true)
}
})
const mpqDir = existsSync('/usr/local/google/home/taodao/d2-data/d2data.mpq')
? '/usr/local/google/home/taodao/d2-data'
: existsSync('samples/d2/d2data.mpq')
? 'samples/d2'
: null
const hasMpq = mpqDir !== null
it.skipIf(!hasMpq)('real MPQ: verifies shadow resolution and DT1 Type 13 in Act 1 Trees & Ruin', async () => {
const archives = new MountedArchives()
for (const name of ['d2exp.mpq', 'd2data.mpq', 'Patch_D2.mpq']) {
try {
archives.add(name, await MpqArchive.open(await fileSource(`${mpqDir}/${name}`)))
} catch {}
}
const tables = await loadActTables(archives)
const { dt1Names } = resolveLevelLibraries(tables, 2)
const libraries: Dt1[] = []
for (const name of dt1Names) {
try {
libraries.push(decodeDt1(await archives.read(name)))
} catch {}
}
expect(libraries.length).toBeGreaterThan(0)
// Load Ruin2.ds1 (contains ruins and shadow layer)
const ruinBuf = await archives.read('data/global/tiles/ACT1/OUTDOORS/Ruin2.ds1')
const ds1 = decodeDs1(ruinBuf)
const scene = buildIsoMapScene(ds1, libraries, 42)
// Verify shadows were found and extracted
expect(scene.shadows.length).toBeGreaterThan(0)
for (const shadowDraw of scene.shadows) {
const tile = libraries[shadowDraw.library]!.tiles[shadowDraw.tile]!
// Must be DT1 Type 13
expect(tile.type).toBe(13)
// Must be on the floor isometric plane
const expectedY = scene.originY + (shadowDraw.cellX + shadowDraw.cellY) * ORTHO_CELL_HEIGHT
expect(shadowDraw.y).toBe(expectedY)
const expectedX = scene.originX + (shadowDraw.cellX - shadowDraw.cellY) * ORTHO_CELL_WIDTH - 80
expect(shadowDraw.x).toBe(expectedX)
}
}, 30000)
})