feat(map): authentic Diablo II 1.13c shadow layer (type 13) rendering and pack verification
This commit is contained in:
parent
8f8ee010ca
commit
752d7e8066
|
|
@ -1251,6 +1251,10 @@ async function bakeDs1Variant(
|
|||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||
}
|
||||
for (const draw of scene.shadows) {
|
||||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||
}
|
||||
for (const draw of scene.walls) {
|
||||
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
|
||||
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
|
||||
|
|
@ -1258,7 +1262,7 @@ async function bakeDs1Variant(
|
|||
}
|
||||
|
||||
const firstDrawAt = new Map<number, { x: number; y: number }>()
|
||||
for (const draw of [...scene.floors, ...scene.walls]) {
|
||||
for (const draw of [...scene.floors, ...scene.shadows, ...scene.walls]) {
|
||||
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
|
||||
}
|
||||
const bandOf = (frameIndex: number): number => {
|
||||
|
|
@ -1476,6 +1480,7 @@ async function bakeDs1Variant(
|
|||
return [place.page, place.x, place.y, place.width, place.height]
|
||||
}),
|
||||
floors: scene.floors.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||
shadows: scene.shadows.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||
walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||
roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
|
||||
objects,
|
||||
|
|
@ -1493,6 +1498,7 @@ async function bakeDs1Variant(
|
|||
waypoints: sceneLinks.waypoints,
|
||||
stats: {
|
||||
floors: scene.floors.length,
|
||||
shadows: scene.shadows.length,
|
||||
walls: scene.walls.length,
|
||||
roofs: scene.roofs.length,
|
||||
frames: scene.frames.length,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ interface PackedScene {
|
|||
readonly frameHash: readonly string[]
|
||||
readonly framePlacement: readonly (readonly number[])[]
|
||||
readonly floors: readonly (readonly number[])[]
|
||||
/** Shadow draws, painted after floors and before walls. */
|
||||
readonly shadows?: readonly (readonly number[])[]
|
||||
readonly walls: readonly (readonly number[])[]
|
||||
/** Roof draws, painted last; absent in packs baked before roofs were split out. */
|
||||
readonly roofs?: readonly (readonly number[])[]
|
||||
|
|
@ -170,6 +172,20 @@ for (const entry of index.levels) {
|
|||
}
|
||||
check(roofMismatch === 0, `${entry.path}: roof draw geometry and order (${String(roofMismatch)} mismatches)`)
|
||||
|
||||
// Shadows are painted after floors and before walls: same count, same order, same geometry.
|
||||
if (packed.shadows !== undefined) {
|
||||
const packedShadows = packed.shadows
|
||||
check(packedShadows.length === live.shadows.length,
|
||||
`${entry.path}: shadow count ${String(packedShadows.length)} vs ${String(live.shadows.length)}`)
|
||||
let shadowMismatch = 0
|
||||
for (let at = 0; at < Math.min(packedShadows.length, live.shadows.length); at += 1) {
|
||||
const packedRow = packedShadows[at]!
|
||||
const liveDraw = live.shadows[at]!
|
||||
if (packedRow[1] !== liveDraw.x || packedRow[2] !== liveDraw.y) shadowMismatch += 1
|
||||
}
|
||||
check(shadowMismatch === 0, `${entry.path}: shadow draw geometry and order (${String(shadowMismatch)} mismatches)`)
|
||||
}
|
||||
|
||||
// Collision grid: expand the runs and compare bytes.
|
||||
const expanded = new Uint8Array(packed.collision.width * packed.collision.height)
|
||||
let cursor = 0
|
||||
|
|
@ -263,7 +279,7 @@ for (const entry of index.levels) {
|
|||
check(pendingArt === 0, `${entry.path}: zero objects with art pending frame (${String(pendingArt)} pending)`)
|
||||
|
||||
console.log(
|
||||
`${entry.path.padEnd(26)} ${String(live.floors.length).padStart(5)} 地面 ${String(live.walls.length).padStart(4)} 墙 ${String(live.roofs.length).padStart(4)} 顶 `
|
||||
`${entry.path.padEnd(26)} ${String(live.floors.length).padStart(5)} 地面 ${String(live.shadows.length).padStart(4)} 阴影 ${String(live.walls.length).padStart(4)} 墙 ${String(live.roofs.length).padStart(4)} 顶 `
|
||||
+ `${String(live.frames.length).padStart(3)} 帧 ${String(packed.collision.width)}x${String(packed.collision.height)} 碰撞 `
|
||||
+ `${String(expected.length).padStart(4)} 对象 ${collisionDiff === 0 && hashMismatch === 0 ? '一致' : '不一致'}`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -206,6 +206,8 @@ export interface IsoMapScene {
|
|||
readonly originY: number
|
||||
/** Floor draws, in cell order. */
|
||||
readonly floors: readonly IsoDraw[]
|
||||
/** Shadow draws, in cell order (type 13, rendered after floors and before walls). */
|
||||
readonly shadows: readonly IsoDraw[]
|
||||
/** Wall draws, in painter's order (isometric depth). */
|
||||
readonly walls: readonly IsoDraw[]
|
||||
/**
|
||||
|
|
@ -555,6 +557,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
const blocked = new Uint8Array(gridWidth * gridHeight)
|
||||
const collisionMasks = new Uint16Array(gridWidth * gridHeight)
|
||||
const rawFloors: IsoDraw[] = []
|
||||
const rawShadows: IsoDraw[] = []
|
||||
const rawWalls: IsoDraw[] = []
|
||||
/** Roof draws (`wall.type` 15): kept apart so they can be painted last. */
|
||||
const rawRoofs: IsoDraw[] = []
|
||||
|
|
@ -656,17 +659,25 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
stamp(cellX, cellY, tile)
|
||||
}
|
||||
|
||||
// Shadows are a collision-only layer here: they are resolved exactly like the
|
||||
// engine resolves them (`type 13`) and folded into the grid, but nothing is
|
||||
// drawn from them.
|
||||
// Shadows are drawn on top of floors and beneath walls/actors. In the engine,
|
||||
// they are resolved as `type 13` tiles and blended with 50% transparency onto the ground.
|
||||
for (const shadow of cell.shadows) {
|
||||
if (shadow.prop1 === 0) continue
|
||||
// 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).tile
|
||||
const picked = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed)
|
||||
const found = picked.tile
|
||||
if (found === undefined) continue
|
||||
const tile = libraries[found.library]!.tiles[found.tile]!
|
||||
const animatedFrames = picked.animatedTiles?.map(t => frameFor(t.library, t.tile))
|
||||
rawShadows.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,
|
||||
})
|
||||
for (let sub = 0; sub < SUB_TILES_PER_TILE * SUB_TILES_PER_TILE; sub += 1) {
|
||||
const flags = tile.subTileFlags[sub]
|
||||
if (flags === undefined) continue
|
||||
|
|
@ -743,6 +754,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
// Isometric painter's order: depth grows along the diagonal, so walls sort by
|
||||
// (cellX + cellY) and then row, which keeps a nearer wall in front of the one
|
||||
// behind it without any depth buffer.
|
||||
rawShadows.sort((a, b) => (a.cellY + a.cellX) - (b.cellY + b.cellX) || a.cellY - b.cellY || a.x - b.x)
|
||||
rawWalls.sort((a, b) => (a.cellY + a.cellX) - (b.cellY + b.cellX) || a.cellY - b.cellY || a.x - b.x)
|
||||
rawRoofs.sort((a, b) => (a.cellY + a.cellX) - (b.cellY + b.cellX) || a.cellY - b.cellY || a.x - b.x)
|
||||
|
||||
|
|
@ -751,7 +763,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
let minY = 0
|
||||
let maxX = 0
|
||||
let maxY = 0
|
||||
for (const draw of [...rawFloors, ...rawWalls, ...rawRoofs]) {
|
||||
for (const draw of [...rawFloors, ...rawShadows, ...rawWalls, ...rawRoofs]) {
|
||||
const frame = frames[draw.frameIndex]!
|
||||
minX = Math.min(minX, draw.x)
|
||||
minY = Math.min(minY, draw.y)
|
||||
|
|
@ -763,9 +775,10 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
const shift = (draw: (typeof rawFloors)[number]): IsoDraw => ({ ...draw, x: draw.x + originX, y: draw.y + originY })
|
||||
|
||||
const floors = rawFloors.map(shift)
|
||||
const shadows = rawShadows.map(shift)
|
||||
const walls = rawWalls.map(shift)
|
||||
const roofs = rawRoofs.map(shift)
|
||||
const animatedTileCount = [...floors, ...walls, ...roofs].filter(
|
||||
const animatedTileCount = [...floors, ...shadows, ...walls, ...roofs].filter(
|
||||
d => d.animatedFrames && d.animatedFrames.length > 1
|
||||
).length
|
||||
|
||||
|
|
@ -779,6 +792,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
|||
originX,
|
||||
originY,
|
||||
floors,
|
||||
shadows,
|
||||
walls,
|
||||
roofs,
|
||||
blocked,
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ export interface ActSceneState {
|
|||
widthPx: number
|
||||
heightPx: number
|
||||
floors: number
|
||||
shadows?: number
|
||||
walls: number
|
||||
frames: number
|
||||
/** Atlas pages loaded and total. */
|
||||
|
|
@ -421,7 +422,7 @@ export function normalizeHeroToken(input: string | null | undefined): string {
|
|||
|
||||
const state: ActSceneState = {
|
||||
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
|
||||
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
|
||||
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, shadows: 0, walls: 0, frames: 0,
|
||||
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
||||
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
||||
heroToken: 'so', heroName: 'Sorceress',
|
||||
|
|
@ -590,6 +591,8 @@ export interface MapRuntime {
|
|||
readonly heightPx: number
|
||||
readonly grid: CollisionGrid
|
||||
readonly floors: readonly Drawable[]
|
||||
/** Shadow draws: painted after floors, before walls and objects. */
|
||||
readonly shadows?: readonly Drawable[]
|
||||
readonly walls: readonly Drawable[]
|
||||
/** Roof draws: painted after the character, so a roof covers what walks under it. */
|
||||
readonly roofs: readonly Drawable[]
|
||||
|
|
@ -1942,6 +1945,8 @@ interface PackSceneJson {
|
|||
readonly frames: readonly (readonly number[])[]
|
||||
readonly framePlacement: readonly (readonly number[])[]
|
||||
readonly floors: readonly (readonly number[])[]
|
||||
/** Shadow draws, painted after floors and before walls (absent in older packs). */
|
||||
readonly shadows?: readonly (readonly number[])[]
|
||||
readonly walls: readonly (readonly number[])[]
|
||||
/** Roof draws, painted after everything else (absent in older packs). */
|
||||
readonly roofs?: readonly (readonly number[])[]
|
||||
|
|
@ -2154,6 +2159,7 @@ async function buildPackRuntime(
|
|||
}
|
||||
floors.sort((a, b) => (a.cellX + a.cellY) - (b.cellX + b.cellY) || a.cellY - b.cellY)
|
||||
}
|
||||
const shadows = (scene.shadows ?? []).map(toDrawable)
|
||||
const walls = scene.walls.map(toDrawable)
|
||||
const roofs = (scene.roofs ?? []).map(toDrawable)
|
||||
|
||||
|
|
@ -2237,7 +2243,7 @@ async function buildPackRuntime(
|
|||
? null
|
||||
: { rgb: Uint8Array.from(paletteRgb), size: 256 }
|
||||
const pages: (AtlasHandle | null)[] = scene.pages.map(() => null)
|
||||
const priority = pagesNeededForViewport(floors, walls, spawn, viewport, ViewportCamera.coverZoom(scene.widthPx, scene.heightPx, viewport))
|
||||
const priority = pagesNeededForViewport([...floors, ...shadows], walls, spawn, viewport, ViewportCamera.coverZoom(scene.widthPx, scene.heightPx, viewport))
|
||||
const loadOrder = [...priority, ...pages.map((_, index) => index).filter(index => !priority.includes(index))]
|
||||
|
||||
return {
|
||||
|
|
@ -2265,9 +2271,10 @@ async function buildPackRuntime(
|
|||
heightPx: scene.heightPx,
|
||||
grid,
|
||||
floors,
|
||||
shadows,
|
||||
walls,
|
||||
roofs,
|
||||
animatedDrawables: [...floors, ...walls, ...roofs].filter(
|
||||
animatedDrawables: [...floors, ...shadows, ...walls, ...roofs].filter(
|
||||
d => d.animatedFrames && d.animatedFrames.length > 1
|
||||
),
|
||||
animSpeed: scene.animSpeed,
|
||||
|
|
@ -3031,6 +3038,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
// Culling bounds, rebuilt on level swap: the draw lists are immutable for the
|
||||
// life of a single runtime, so the per-frame cost is just the rejection test.
|
||||
let floorBounds = buildBounds(runtime.floors)
|
||||
let shadowBounds = buildBounds(runtime.shadows ?? [])
|
||||
let wallBounds = buildBounds(runtime.walls)
|
||||
let roofBounds = buildBounds(runtime.roofs)
|
||||
let objectBounds = buildBounds(runtime.objectDrawables)
|
||||
|
|
@ -3269,6 +3277,7 @@ if (typeof window !== 'undefined') {
|
|||
|
||||
runtime = next
|
||||
floorBounds = buildBounds(next.floors)
|
||||
shadowBounds = buildBounds(next.shadows ?? [])
|
||||
wallBounds = buildBounds(next.walls)
|
||||
roofBounds = buildBounds(next.roofs)
|
||||
objectBounds = buildBounds(next.objectDrawables)
|
||||
|
|
@ -3778,6 +3787,15 @@ if (typeof window !== 'undefined') {
|
|||
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
|
||||
}
|
||||
}
|
||||
const drawShadows = (list: readonly Drawable[], bounds: Float32Array): void => {
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
if (!visible(bounds, index)) { culledDraws += 1; continue }
|
||||
const draw = list[index]!
|
||||
const page = runtime.pages[draw.page]
|
||||
if (page === null || page === undefined) { skippedDraws += 1; continue }
|
||||
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page, tint: [1, 1, 1, 0.5] })
|
||||
}
|
||||
}
|
||||
const drawRoofs = (list: readonly Drawable[], bounds: Float32Array): void => {
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
if (!visible(bounds, index)) { culledDraws += 1; continue }
|
||||
|
|
@ -3793,6 +3811,9 @@ if (typeof window !== 'undefined') {
|
|||
}
|
||||
}
|
||||
drawTiles(runtime.floors, floorBounds)
|
||||
if (runtime.shadows && runtime.shadows.length > 0) {
|
||||
drawShadows(runtime.shadows, shadowBounds)
|
||||
}
|
||||
const cell = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y)
|
||||
const deltaMs = Number.isFinite(frameMs) && frameMs > 0 ? Math.min(frameMs, 100) : 16
|
||||
roofController.update(engine.world.player, deltaMs, cell)
|
||||
|
|
@ -4080,7 +4101,7 @@ if (typeof window !== 'undefined') {
|
|||
const renderFpsText = loop.fps.toFixed(0)
|
||||
const renderMsText = (loop.frameMs > 0 ? loop.frameMs : state.renderMs).toFixed(1)
|
||||
hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
|
||||
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
|
||||
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.shadows?.length ?? 0)} 阴影 / ${String(runtime.walls.length)} 墙`
|
||||
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
|
||||
+ ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
|
||||
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})`
|
||||
|
|
@ -4216,6 +4237,7 @@ function publish(runtime: MapRuntime, base: string): void {
|
|||
state.widthPx = runtime.widthPx
|
||||
state.heightPx = runtime.heightPx
|
||||
state.floors = runtime.floors.length
|
||||
state.shadows = runtime.shadows?.length ?? 0
|
||||
state.walls = runtime.walls.length
|
||||
state.frames = runtime.frames
|
||||
state.pagesTotal = runtime.pages.length
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
/**
|
||||
* @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. Blood Moor and Act 1 wilderness levels include Tree Fill and shadow-bearing presets.
|
||||
*/
|
||||
|
||||
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 {
|
||||
BLOOD_MOOR_RIVER_PRESETS,
|
||||
BLOOD_MOOR_POND_PRESETS,
|
||||
SPECIAL_PRESETS_BY_LEVEL,
|
||||
} from '../src/game/wilderness.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)
|
||||
}
|
||||
})
|
||||
|
||||
it('Blood Moor and Act 1 wilderness preset lists include shadow-bearing presets (Ruin, Tree Fill)', () => {
|
||||
expect(BLOOD_MOOR_RIVER_PRESETS).toContain('Act 1 - Ruin')
|
||||
expect(BLOOD_MOOR_POND_PRESETS).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[2]).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[3]).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[4]).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[5]).toContain('Act 1 - Tree Fill')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[5]).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[6]).toContain('Act 1 - Ruin')
|
||||
expect(SPECIAL_PRESETS_BY_LEVEL[7]).toContain('Act 1 - Ruin')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
Loading…
Reference in New Issue