diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 0dd0420..994ff38 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -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() - 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, diff --git a/scripts/verify-packs.ts b/scripts/verify-packs.ts index 029232e..93c5076 100644 --- a/scripts/verify-packs.ts +++ b/scripts/verify-packs.ts @@ -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 ? '一致' : '不一致'}`, ) diff --git a/src/game/d2map.ts b/src/game/d2map.ts index 2f8d5c7..a4c6070 100644 --- a/src/game/d2map.ts +++ b/src/game/d2map.ts @@ -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, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index c541cd8..1e65d1f 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 diff --git a/tests/shadow-rendering.test.ts b/tests/shadow-rendering.test.ts new file mode 100644 index 0000000..f9cbfc6 --- /dev/null +++ b/tests/shadow-rendering.test.ts @@ -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) +})