diablo2-web/scripts/verify-tile-alignment.ts

297 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// verify-tile-alignment.ts — 用实测把"地面比引擎低/高 80 px"这个疑点结掉
//
// 背景:上一轮的 `verify-objects` 打印过一张对照表,说引擎的
// `DUNGEON_GameToClientTileDrawPositionCoords`(D2MOO 移植过来的真引擎函数)算出 cell(12,7) 的地面
// 在 (320, 840),而本项目画在 (320, 760),差 (+0, +80),看起来像"地面整体偏了 80 px"。
//
// 这份脚本证明那**不是错位**,而是两个坐标系之间的**常量平移**,并且用像素实测证明同一格里
// 地面的下沿与墙脚是对齐的:
//
// 1. 常量性:D2MOO 的 tile 位置与本项目的地面绘制位置的差在所有格子上都是 (+80, +80),
// 与 (x,y) 无关 → 整张图一起平移,相机又跟着角色走,所以对相对关系没有任何影响。
// 2. 公式一致性:我们画墙用的 `minBlockY + 80` 与解析出来的 `bitmapHeight`,必须等于引擎从原始
// block 字节独立推出的 `tileMinY + 80` 与 `realHeight = max(|Height|, maxY - minY)`。
// 3. 接缝实测:同一格里取墙位图与地面位图**真正的不透明像素**,比较墙脚(墙最下面一行不透明像素)
// 与地面下沿。差值应当是 0。
//
// node scripts/verify-tile-alignment.ts [--dir=samples/d2] [--limit=35]
//
// 退出码非 0 表示有断言没过。
export {}
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { cell, loadActTables, resolveLevel, resolveLevelLibraries } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import { decodeDt1 } from '../src/formats/dt1.ts'
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
import { levelSeed, buildIsoMapScene, ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH } from '../src/game/d2map.ts'
/** 归档目录。 */
const DIR = process.argv.find(argument => argument.startsWith('--dir='))?.slice('--dir='.length) ?? 'samples/d2'
/** 最多走多少个预置关卡。 */
const LIMIT = Number(process.argv.find(argument => argument.startsWith('--limit='))?.slice('--limit='.length) ?? '35')
/** 断言结果。 */
const checks: { name: string; ok: boolean; detail: string }[] = []
/**
* 记录一条断言。
*
* @param name - what was checked.
* @param ok - whether it held.
* @param detail - evidence.
*/
function check(name: string, ok: boolean, detail: string): void {
checks.push({ name, ok, detail })
console.log(`${ok ? 'ok ' : 'FAIL'} ${name} — ${detail}`)
}
/**
* 位图里最后一行不透明像素的行号(没有不透明像素时返回 -1)。
*
* @param tile - the decoded DT1 tile.
* @returns the row index, or -1.
*/
function lastOpaqueRow(tile: Dt1Tile): number {
// 每个 block 的像素缓冲已经是"整块位图尺寸"的画布,解码时按 `-minBlockY` 平移过,
// 所以缓冲里的行号就是位图内的行号,不需要再加 block.y(block.y 保留的是原始值)。
const stride = tile.width
let last = -1
for (const block of tile.blocks) {
if (stride <= 0) break
const rows = Math.floor(block.pixels.length / stride)
for (let row = rows - 1; row >= 0; row -= 1) {
const start = row * stride
let opaque = false
for (let column = 0; column < stride; column += 1) {
if ((block.pixels[start + column] ?? 0) !== 0) { opaque = true; break }
}
if (opaque) { last = Math.max(last, row); break }
}
}
return last
}
/**
* 从原始字节独立算出引擎的 `tileMinY` / `realHeight`(OpenDiablo2 `generateWallCache` 的公式)。
*
* @param tile - the decoded DT1 tile; `block.y` is already shifted by `minBlockY`.
* @returns the engine's numbers.
*/
function engineMetrics(tile: Dt1Tile): { tileMinY: number; tileMaxY: number; realHeight: number } {
// `block.y` 是原始值(解码时像素另有平移),所以这里直接用,不要再叠加 minBlockY。
let tileMinY = 0
let tileMaxY = 0
for (const block of tile.blocks) {
tileMinY = Math.min(tileMinY, block.y)
tileMaxY = Math.max(tileMaxY, block.y + 32)
}
return { tileMinY, tileMaxY, realHeight: Math.max(Math.abs(tile.height), tileMaxY - tileMinY) }
}
/**
* 主流程。
*/
async function main(): Promise<void> {
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`${DIR}/${name}`)))
}
const tables = await loadActTables(archives)
const presetRows = tables.levels.rows.filter(row => cell(tables.levels, row, 'DrlgType') === '2')
const libraries = new Map<string, Dt1>()
let cells = 0
let pairs = 0
const deltaHistogram = new Map<number, number>()
const samples: string[] = []
const seenDeltas = new Set<number>()
const baseDeltas = new Map<number, number>()
const offsetsD2moo = new Set<string>()
const formulaMismatches: string[] = []
let floorMinYNonZero = 0
let roofDraws = 0
let roofMatches = 0
const wallTypes = new Map<number, number>()
let wallTiles = 0
let levelsWalked = 0
// 键的正确性回归:DS1 wall type 必须与 DT1 头 +20 的 `Type` 对齐,而不是 +0 的 `Direction`。
// 键错时树(14)/屋顶(15)/影子(13)全落 loose、画成地面。这里按 type 键全量复核,loose/missing 应为 0。
let refsTotal = 0
let refsExact = 0
let refsLoose = 0
let refsMissing = 0
const looseRefs = new Set<string>()
for (const row of presetRows.slice(0, LIMIT)) {
const levelId = Number(cell(tables.levels, row, 'Id'))
let info
try {
info = resolveLevel(tables, levelId)
} catch { continue }
const { dt1Names } = resolveLevelLibraries(tables, levelId)
const libs: Dt1[] = []
for (const name of dt1Names) {
const cached = libraries.get(name)
if (cached !== undefined) { libs.push(cached); continue }
try {
const decoded = decodeDt1(await archives.read(name))
libraries.set(name, decoded)
libs.push(decoded)
} catch { /* 库缺失时后面的 resolveLevelLibraries 已经报过 */ }
}
if (libs.length === 0) continue
let ds1
try {
ds1 = decodeDs1(await archives.read(info.ds1Names[0]!))
} catch { continue }
levelsWalked += 1
const scene = buildIsoMapScene(ds1, libs, levelSeed(info.ds1Names[0] ?? String(levelId)))
// 键正确性复核(独立于 buildIsoMapScene,用原始 DS1 + 库重算一遍)。
const exactKeys = new Set<string>()
const looseKeys = new Set<string>()
for (const lib of libs) for (const tile of lib.tiles) {
exactKeys.add(`${String(tile.style)}:${String(tile.sequence)}:${String(tile.type)}`)
looseKeys.add(`${String(tile.style)}:${String(tile.sequence)}`)
}
for (const cellRef of ds1.cells.flat()) for (const wall of cellRef.walls) {
if (wall.hidden || wall.prop1 === 0) continue
refsTotal += 1
if (exactKeys.has(`${String(wall.style)}:${String(wall.sequence)}:${String(wall.type)}`)) { refsExact += 1; continue }
if (looseKeys.has(`${String(wall.style)}:${String(wall.sequence)}`)) { refsLoose += 1; looseRefs.add(`${String(wall.style)}/${String(wall.sequence)}/${String(wall.type)}`); continue }
refsMissing += 1
}
// Roofs: the engine offsets them by `-roofHeight` instead of `minBlockY + 80`.
for (const roof of scene.roofs) {
const tile = libs[roof.library]?.tiles[roof.tile]
if (tile === undefined) continue
roofDraws += 1
// The scene shifts every draw to keep coordinates positive, so the formula is
// checked against the *unshifted* placement (scene.originY is that shift).
if (roof.y - scene.originY === (roof.cellX + roof.cellY) * ORTHO_CELL_HEIGHT - tile.roofHeight) roofMatches += 1
}
// 按格子收集地面与墙
type Draw = (typeof scene.floors)[number]
const byCell = new Map<string, { floors: Draw[]; walls: Draw[] }>()
for (const cell of ds1.cells.flat()) {
for (const wall of cell.walls) {
if (wall.prop1 === 0 || wall.hidden) continue
wallTypes.set(wall.type, (wallTypes.get(wall.type) ?? 0) + 1)
}
}
for (const draw of scene.floors) {
const key = `${String(draw.cellX)},${String(draw.cellY)}`
const entry = byCell.get(key) ?? { floors: [], walls: [] }
entry.floors.push(draw)
byCell.set(key, entry)
}
for (const draw of scene.walls) {
const key = `${String(draw.cellX)},${String(draw.cellY)}`
const entry = byCell.get(key) ?? { floors: [], walls: [] }
entry.walls.push(draw)
byCell.set(key, entry)
}
for (const [key, entry] of byCell) {
if (entry.floors.length === 0 || entry.walls.length === 0) continue
cells += 1
const [cxText, cyText] = key.split(',')
const cx = Number(cxText)
const cy = Number(cyText)
// (1) 常量性:D2MOO 的 tile 位置 vs 本项目的地面位置
const oursX = (cx - cy) * ORTHO_CELL_WIDTH - 80
const oursY = (cx + cy) * ORTHO_CELL_HEIGHT
const d2mooX = (cx - cy) * ORTHO_CELL_WIDTH
const d2mooY = (cx + cy) * ORTHO_CELL_HEIGHT + 80
offsetsD2moo.add(`${String(d2mooX - oursX)},${String(d2mooY - oursY)}`)
const floor = entry.floors[0]!
const wall = entry.walls[0]!
const floorTile = libs[floor.library]?.tiles[floor.tile]
const wallTile = libs[wall.library]?.tiles[wall.tile]
if (floorTile === undefined || wallTile === undefined) continue
if (floorTile.minBlockY !== 0) floorMinYNonZero += 1
wallTiles += 1
// (2) 公式一致性:我们的 minBlockY / bitmapHeight 必须等于引擎独立算出的值
const metrics = engineMetrics(wallTile)
if (wallTile.minBlockY !== metrics.tileMinY) {
if (formulaMismatches.length < 6) {
formulaMismatches.push(`${String(levelId)} wall style ${String(wallTile.style)}: minBlockY ${String(wallTile.minBlockY)} != engine ${String(metrics.tileMinY)}`)
}
} else if (wallTile.bitmapHeight !== metrics.realHeight) {
if (formulaMismatches.length < 6) {
formulaMismatches.push(`${String(levelId)} wall style ${String(wallTile.style)}: bitmapHeight ${String(wallTile.bitmapHeight)} != engine realHeight ${String(metrics.realHeight)}`)
}
}
// (3) 接缝实测:墙脚(最下面一行不透明像素)与地面下沿之差
const floorBottom = floor.y + lastOpaqueRow(floorTile)
const wallBottom = wall.y + lastOpaqueRow(wallTile)
// 真正的"接缝"判据是**基线**而不是"最下面一行不透明像素":引擎给墙加的
// `YAdjust = minBlockY + 80` 正好抵消解码时按 `-minBlockY` 做的平移,使墙的原始 y=0
// 落在格子基线(地面位图下沿所在的 80 px 处);不同 tile 类型的美术在基线上下伸出的
// 多少各不相同,所以像素行差只能当参考,不能当判据。
const wallBase = wall.y - wallTile.minBlockY
const floorBase = floor.y + 80
baseDeltas.set(wallBase - floorBase, (baseDeltas.get(wallBase - floorBase) ?? 0) + 1)
const delta = wallBottom - floorBottom
deltaHistogram.set(delta, (deltaHistogram.get(delta) ?? 0) + 1)
if (samples.length < 8 && !seenDeltas.has(delta)) {
seenDeltas.add(delta)
samples.push(`Δ${String(delta)} cell(${String(cx)},${String(cy)}) `
+ `floor[y=${String(floor.y)} last=${String(floorBottom - floor.y)} bh=${String(floorTile.bitmapHeight)} minY=${String(floorTile.minBlockY)}] `
+ `wall[y=${String(wall.y)} last=${String(wallBottom - wall.y)} bh=${String(wallTile.bitmapHeight)} minY=${String(wallTile.minBlockY)} h=${String(wallTile.height)} style=${String(wallTile.style)}/${String(wallTile.sequence)}/${String(wallTile.type)}]`)
}
pairs += 1
}
}
console.log(`\n走了 ${String(levelsWalked)} 个预置关卡:含"地面+墙"的格子 ${String(cells)},参与接缝实测的配对 ${String(pairs)}`)
console.log(` 屋顶层(DS1 wall type 15):${String(roofDraws)} 个,其中偏移与引擎公式 -roofHeight 相符的 ${String(roofMatches)} 个`)
console.log(` DS1 墙 type 分布:${[...wallTypes].sort((left, right) => left[0] - right[0]).map(([type, count]) => `${String(type)}×${String(count)}`).join(' ')}`)
console.log(` D2MOO 位置 - 本项目地面位置 的不同取值:${[...offsetsD2moo].join(' | ')}`)
const deltas = [...deltaHistogram].sort((left, right) => right[1] - left[1])
console.log(` 接缝差(墙脚 - 地面下沿)分布:${deltas.slice(0, 8).map(([value, count]) => `${String(value)}px ×${String(count)}`).join(' ')}`)
console.log(` 地面瓦片里 minBlockY != 0 的:${String(floorMinYNonZero)} / ${String(cells)}`)
for (const sample of samples) console.log(` ${sample}`)
console.log(`\n 墙引用键复核:${String(refsExact)}/${String(refsTotal)} 精确命中,loose ${String(refsLoose)},缺失 ${String(refsMissing)}`)
if (refsLoose > 0) console.log(` loose 引用:${[...looseRefs].slice(0, 8).join(' | ')}`)
check('DS1 wall type 与 DT1 `Type`(+20) 精确对齐(无 loose/缺失)', refsTotal > 0 && refsExact === refsTotal,
`${String(refsExact)}/${String(refsTotal)} 精确命中`)
check('D2MOO 与本项目的差在所有格子上是同一个常量', offsetsD2moo.size === 1,
[...offsetsD2moo].join(' | '))
check('该常量与"整张图平移"一致(解释 80 px 疑点)', [...offsetsD2moo][0] === '80,80',
`偏移 ${[...offsetsD2moo][0] ?? '?'}:X 差一格半宽(位图锚点),Y 差一个 tile 高(D2MOO 的口径)`)
check('屋顶偏移等于引擎的 -roofHeight(没有屋顶层的关卡按通过算)', roofDraws === 0 || roofMatches === roofDraws,
`${String(roofMatches)}/${String(roofDraws)} 个屋顶`)
check('墙的 minBlockY / bitmapHeight 等于引擎从原始字节算出的值', formulaMismatches.length === 0,
formulaMismatches.length === 0 ? `${String(wallTiles)} 面墙全部一致` : formulaMismatches.join(' | '))
// 墙脚与地面下沿:多数应当正好重合;差值不为 0 的那些是"美术本身伸到格子下沿之外"的墙
// (栅栏、树、台阶等),它们的脚本来就在格子外,属于正常。
const baseExact = baseDeltas.get(0) ?? 0
check('接缝判据:墙的基线与地面基线完全重合', pairs > 0 && baseExact === pairs,
`${String(baseExact)}/${String(pairs)} 对基线差为 0`)
console.log(` (参考值,不作为判据)最下面一行不透明像素之差最常见 ${String(deltas[0]?.[0] ?? 0)}px:`
+ '不同 tile 类型的美术在基线上下伸出的量不同,栅栏/树/台阶本来就越过格子下沿')
const passed = checks.filter(entry => entry.ok).length
console.log(`\n${String(passed)}/${String(checks.length)} passed`)
if (passed !== checks.length) process.exitCode = 1
}
await main()