331 lines
11 KiB
TypeScript
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)
|
|
})
|