feat(objects): 完整实现对象层解码、打包图集与场景深度绘制 (#5)

- 数据与查找修复:
  - 修复 src/game/object-lookup-data.ts 中 Act 3 墨菲斯托红门 (104:342:1Y:ON) 错位问题,恢复 3,615 条记录唯一性。
  - 排除 COF 非图元文件,优先选取主体图元 (tr) 与 DCC/DC6 静态帧。
- 资产打包与图集生成 (scripts/pack-act-assets.ts):
  - 引入全局 decodedMemberCache,一次性解码去重。
  - 将所有有效美术对象装箱至 objectPages (objects-*.png),并正确计算象限锚点偏移 (-16 + box.left / f.offsetX, 16 + box.top / (offsetY - height + 1))。
  - 动态统计 objectsArtPending,62 张地图全部实现 0 待解。
- 场景渲染与深度排序 (src/scene/act-scene.ts):
  - 预加载 scene.objectPages 纹理并在 beforeunload 中释放,避免内存泄漏。
  - 将 objectDrawables 与 runtime.walls 按 (subX+subY)/5 与 cellX+cellY 的统一深度双指针交替绘制,并保持与角色前后的画家算法正确遮挡。
- 自动化验证 (scripts/verify-packs.ts & scripts/verify-object-lookup.ts):
  - 新增图集页边界与 0 待解帧断言 (1302/1302 全数通过)。
  - 更新查找表统计与点位断言 (20/20 全数通过)。
This commit is contained in:
troytt 2026-09-14 09:27:54 +00:00
parent b3368e552a
commit 72731bb143
6 changed files with 280 additions and 24 deletions

View File

@ -18,6 +18,7 @@
"verify:renderer": "npx tsx scripts/verify-renderer-lifecycle.ts",
"verify:all": "node scripts/verify-combat.ts && node scripts/verify-items.ts && node scripts/verify-m4.ts && node scripts/verify-m5.ts && node scripts/verify-net.ts && node scripts/verify-collision-orientation.ts",
"verify:acts": "node scripts/verify-acts.ts",
"verify:generators": "tsx scripts/verify-generators.ts samples/d2",
"build:game": "vite build --base=/diablo2/ --outDir dist-game",
"pack:data": "node scripts/pack-act-assets.ts",
"verify:packs": "node scripts/verify-packs.ts",

View File

@ -39,6 +39,9 @@ import { decodePl2 } from '../src/formats/pl2.ts'
import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts'
import type { IsoMapScene } from '../src/game/d2map.ts'
import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts'
import { decodeDcc } from '../src/formats/dcc.ts'
import { decodeDc6 } from '../src/formats/dc6.ts'
import type { SpriteFrame } from '../src/formats/sprite.ts'
import { encodeIndexedPng } from './png.ts'
/**
@ -348,16 +351,23 @@ for (const name of allNames) {
modes.get(mode)!.push(name)
}
const COMPONENT_PREFERENCE = ['tr', 'hd', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 'lg', 'ra', 'la', 'rh', 'lh', 'sh'] as const
function compRank(path: string): number {
const parts = path.toLowerCase().split('\\')
const comp = parts[4] ?? ''
const idx = COMPONENT_PREFERENCE.indexOf(comp as (typeof COMPONENT_PREFERENCE)[number])
return idx >= 0 ? idx : 99
}
/**
* Pick the member that stands in for an object's art.
*
* A token ships many variants (lit/unlit, per-mode, per-weapon-class); the pack
* is static art, so one deterministic pick is recorded — mode order first, then
* file name — and the number of alternatives goes in the manifest so the choice
* is visible rather than implied.
*
* The mode token lives in the *file name* (`<token><component>lit<mode>hth.dcc`), not in
* the directory, so the mode the lookup table gives is matched against file names.
* component preference (body layer 'tr' first), then file format (DCC before DC6)
* — and COF definition files are filtered out since they are compositing metadata,
* not sprites.
*
* @param token - object token from the lookup table.
* @param modeToken - animation mode token the engine places the object in (`NU`/`OP`/…).
@ -366,8 +376,12 @@ for (const name of allNames) {
function pickObjectMember(token: string, modeToken: string): { member: string; candidates: number } | null {
const dirs = objectMembers.get(token)
if (dirs === undefined) return null
const all = [...dirs.values()].flat()
const all = [...dirs.values()].flat().filter(name => {
const lower = name.toLowerCase()
return lower.endsWith('.dcc') || lower.endsWith('.dc6')
})
const candidates = all.length
if (candidates === 0) return null
const wanted = modeToken.trim().toLowerCase() === '' ? 'nu' : modeToken.trim().toLowerCase()
const order = [wanted, ...OBJECT_MODES.map(mode => mode.toLowerCase()).filter(mode => mode !== wanted)]
for (const mode of order) {
@ -376,14 +390,33 @@ function pickObjectMember(token: string, modeToken: string): { member: string; c
const base = (name.split('\\').pop() ?? '').toLowerCase()
return base.includes(mode) || (mode !== 'nu' && base.includes(`lit${mode}`))
})
.sort((a, b) => (a.toLowerCase().endsWith('.dcc') ? 0 : 1) - (b.toLowerCase().endsWith('.dcc') ? 0 : 1) || a.localeCompare(b))
.sort((a, b) => {
const aDcc = a.toLowerCase().endsWith('.dcc') ? 0 : 1
const bDcc = b.toLowerCase().endsWith('.dcc') ? 0 : 1
if (aDcc !== bDcc) return aDcc - bDcc
const aRank = compRank(a)
const bRank = compRank(b)
if (aRank !== bRank) return aRank - bRank
return a.localeCompare(b)
})
if (files.length > 0) return { member: files[0]!, candidates }
}
// No file carries the mode token: fall back to any member, deterministically.
const fallback = [...all].sort()[0]
const fallback = [...all].sort((a, b) => {
const aDcc = a.toLowerCase().endsWith('.dcc') ? 0 : 1
const bDcc = b.toLowerCase().endsWith('.dcc') ? 0 : 1
if (aDcc !== bDcc) return aDcc - bDcc
const aRank = compRank(a)
const bRank = compRank(b)
if (aRank !== bRank) return aRank - bRank
return a.localeCompare(b)
})[0]
return fallback === undefined ? null : { member: fallback, candidates }
}
/** Cache decoded member frames across levels so each unique member is decoded once. */
const decodedMemberCache = new Map<string, { frame: SpriteFrame; offsetX: number; offsetY: number }>()
const index: Record<string, unknown> = {
version: 1,
generated: new Date().toISOString(),
@ -473,13 +506,14 @@ for (const entry of LEVELS) {
totalPngBytes += page.png.byteLength
}
// Object placements and their art members. The art itself is COF+DCC (see
// OBJECT_MODES), so this records what each object *is* and exactly which
// members would draw it, and reports how many are waiting on that decoder.
// Object placements and their art members. The art itself is decoded from DCC/DC6
// and shelf-packed into objectPages so the client can draw objects at depth.
const objectPages = new PageBuilder(palette)
const objects: unknown[] = []
const missingObjects: string[] = []
let objectsWithArt = 0
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
for (const object of level.objects) {
// The DS1 `id` is an index into the hardcoded per-act object table, not an
// `Objects.txt` row: act 1 id 0 is the rogue fountain (`Objects.txt` 12), not
@ -497,6 +531,71 @@ for (const entry of LEVELS) {
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode)
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
let objectFrame: {
page: number
x: number
y: number
width: number
height: number
offsetX: number
offsetY: number
} | null = null
if (pick !== null) {
let placed = placementByMember.get(pick.member)
if (placed === undefined) {
let art = decodedMemberCache.get(pick.member)
if (art === undefined) {
try {
const bytes = await archives.read(pick.member)
if (pick.member.toLowerCase().endsWith('.dc6')) {
const sheet = decodeDc6(bytes)
const group = sheet.groups[0]
const f = group?.frames[0]
if (f) {
art = {
frame: f,
offsetX: -16 + f.offsetX,
offsetY: 16 + (f.offsetY - f.height + 1),
}
}
} else {
const dcc = decodeDcc(bytes)
const dir = dcc.directions[0]
const f = dir?.frames[0]
if (f && dir) {
art = {
frame: f.frame,
offsetX: -16 + dir.box.left,
offsetY: 16 + dir.box.top,
}
}
}
if (art) decodedMemberCache.set(pick.member, art)
} catch (err) {
console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`)
}
}
if (art) {
const placement = objectPages.add(art.frame)
placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY }
placementByMember.set(pick.member, placed)
}
}
if (placed) {
objectFrame = {
page: placed.placement.page,
x: placed.placement.x,
y: placed.placement.y,
width: placed.placement.width,
height: placed.placement.height,
offsetX: placed.offsetX,
offsetY: placed.offsetY,
}
}
}
objects.push({
id: object.id,
type: object.type,
@ -514,7 +613,7 @@ for (const entry of LEVELS) {
x: Math.round(orthoX),
y: Math.round(orthoY),
depth: (object.x + object.y) / 5,
frame: null,
frame: objectFrame,
})
if (pick !== null) objectsWithArt += 1
else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`)
@ -579,7 +678,7 @@ for (const entry of LEVELS) {
objects: objects.length,
objectsWithArt,
objectsUnresolved: missingObjects,
objectsArtPending: objects.length,
objectsArtPending: objects.filter((o: any) => o.member !== null && o.frame === null).length,
dt1Libraries: info.dt1Names.length,
},
}
@ -619,11 +718,13 @@ for (const entry of LEVELS) {
bytes: manifest.pngBytes + sceneBytes.byteLength,
})
totalLevels += 1
const bakedFramesCount = objects.filter((o: any) => o.frame !== null).length
const pendingCount = objects.filter((o: any) => o.member !== null && o.frame === null).length
console.log(
`act${String(entry.act)}/${label.padEnd(22)} ${String(scene.cellsX)}x${String(scene.cellsY)} `
+ `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG `
+ `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB `
+ `· 对象 ${String(objects.length)}/${String(level.objects.length)}(美术待 DCC/COF:${String(objectsWithArt)})`
+ `· 对象 ${String(objects.length)}/${String(level.objects.length)}(已接帧:${String(bakedFramesCount)},待解:${String(pendingCount)})`
+ ` · 缺失瓦片 ${String(scene.missingTiles)}`,
)
}

View File

@ -86,7 +86,7 @@ async function main(): Promise<void> {
`${String(stats.rows)} = ${String(OBJECT_LOOKUP_WITH_ART)} + ${String(OBJECT_LOOKUP_WITHOUT_ART)}`)
check('有 token 的记录数稳定', stats.withArt === OBJECT_LOOKUP_WITH_ART && stats.withRow === OBJECT_LOOKUP_WITH_ROW,
`withArt=${String(stats.withArt)} withRow=${String(stats.withRow)}`)
check('记录数 = 3,614(3,615 条物体行里有 1 条重复 id 被覆盖)', stats.rows === 3614, `${String(stats.rows)} 条`)
check('记录数 = 3,615(每个 DS1 id 唯一)', stats.rows === 3615, `${String(stats.rows)} 条`)
check('无 token 的记录数 = 193(不可见/占位对象)', stats.withoutArt === 193, `${String(stats.withoutArt)} 条`)
// 具体点位:这些名字是从社区表里读出来的,写成断言是为了让"表换了版本"立刻暴露。
@ -96,6 +96,7 @@ async function main(): Promise<void> {
[1, 2, 'RB', 'ON', 'Fire, rogue camp'],
[1, 5, 'L1', 'NU', 'Chest, R Large'],
[2, 17, 'JE', 'NU', 'jerhyn'],
[3, 104, '1Y', 'ON', 'mephisto red portal'],
[5, 0, 'AO', 'NU', 'act 5 first object'],
]
for (const [act, id, token, mode, label] of spots) {
@ -152,7 +153,7 @@ async function main(): Promise<void> {
check('覆盖条数 = 26', diffToken === TOKEN_OVERRIDES.length, `${String(diffToken)} 条`)
check('白名单没有过期条目', missingOverrides.length === 0,
missingOverrides.length === 0 ? '全部仍然有效' : missingOverrides.slice(0, 5).join(' | '))
check('与 Objects.txt 完全一致的记录数 = 527', same === 527, `${String(same)} 条`)
check('与 Objects.txt 完全一致的记录数 = 528', same === 528, `${String(same)} 条`)
check('表说无 token 的记录数 = 193', emptyToken === 193, `${String(emptyToken)} 条`)
check('行号缺失的 3 条仍然带真 token', missingRow === 3 && missingRowTokens.sort().join(',') === '7C,PX,PY',
`缺失行号的 token:${missingRowTokens.join(',')}`)

View File

@ -50,6 +50,7 @@ interface PackedScene {
readonly walls: readonly (readonly number[])[]
/** Roof draws, painted last; absent in packs baked before roofs were split out. */
readonly roofs?: readonly (readonly number[])[]
readonly objectPages?: readonly { readonly file: string; readonly width: number; readonly height: number }[]
readonly objects: readonly {
readonly id: number
readonly type: number
@ -60,6 +61,16 @@ interface PackedScene {
readonly mode: string
/** `Objects.txt` row the table points at, or -1 when it points at none. */
readonly objectsTxtId: number
readonly member?: string | null
readonly frame?: {
readonly page: number
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly offsetX: number
readonly offsetY: number
} | null
}[]
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
readonly spawn: readonly number[] | null
@ -229,6 +240,23 @@ for (const entry of index.levels) {
check(tokenMismatch === 0, `${entry.path}: object tokens/modes match the lookup (${String(tokenMismatch)} mismatches)`)
unknownObjectIds += unknownIds
// Object frames: every object with a frame references a valid page and rect,
// and no object with an art member is left pending a frame.
const objectPages = packed.objectPages ?? []
let invalidFrames = 0
let pendingArt = 0
for (const obj of packed.objects) {
if (obj.frame) {
if (obj.frame.page < 0 || obj.frame.page >= objectPages.length || obj.frame.width <= 0 || obj.frame.height <= 0) {
invalidFrames += 1
}
} else if (obj.member !== null && obj.member !== undefined) {
pendingArt += 1
}
}
check(invalidFrames === 0, `${entry.path}: object frame placement within bounds (${String(invalidFrames)} invalid)`)
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)} 顶 `
+ `${String(live.frames.length).padStart(3)} 帧 ${String(packed.collision.width)}x${String(packed.collision.height)} 碰撞 `

File diff suppressed because one or more lines are too long

View File

@ -147,6 +147,16 @@ interface Drawable {
readonly page: number
}
/** One object sprite to draw. */
interface ObjectDrawable {
readonly frame: AtlasFrame
readonly x: number
readonly y: number
readonly depth: number
/** Object atlas page index this frame lives on. */
readonly page: number
}
/** Everything the render loop needs, from either data path. */
interface MapRuntime {
readonly source: 'pack' | 'live'
@ -172,6 +182,8 @@ interface MapRuntime {
readonly walls: readonly Drawable[]
/** Roof draws: painted after the character, so a roof covers what walks under it. */
readonly roofs: readonly Drawable[]
/** Object draws: interleaved with walls in painter's order. */
readonly objectDrawables: readonly ObjectDrawable[]
readonly spawn: { x: number; y: number }
readonly objects: number
readonly frames: number
@ -179,6 +191,8 @@ interface MapRuntime {
readonly notes: readonly string[]
/** Handles by page index; a hole means "not loaded yet". */
readonly pages: (AtlasHandle | null)[]
/** Object atlas page handles. */
readonly objectPages: (AtlasHandle | null)[]
/** Archive directories to try for the character archive, in order. */
readonly charBases: readonly string[]
/** Act palette for the character atlas, when the pack carries one. */
@ -274,6 +288,34 @@ interface PackIndex {
}[]
}
/** Object frame inside an object atlas page. */
interface PackObjectFrame {
readonly page: number
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly offsetX: number
readonly offsetY: number
}
/** Object instance serialized in scene.json. */
interface PackObject {
readonly id: number
readonly type: number
readonly name?: string | null
readonly token?: string
readonly mode?: string
readonly objectsTxtId?: number
readonly hp?: number
readonly member?: string | null
readonly alternatives?: number
readonly x: number
readonly y: number
readonly depth?: number
readonly frame?: PackObjectFrame | null
}
/** A pack's per-map scene file. */
interface PackSceneJson {
readonly act: number
@ -286,13 +328,14 @@ interface PackSceneJson {
readonly widthPx: number
readonly heightPx: number
readonly pages: readonly { readonly file: string; readonly width: number; readonly height: number }[]
readonly objectPages?: readonly { readonly file: string; readonly width: number; readonly height: number }[]
readonly frames: readonly (readonly number[])[]
readonly framePlacement: readonly (readonly number[])[]
readonly floors: readonly (readonly number[])[]
readonly walls: readonly (readonly number[])[]
/** Roof draws, painted after everything else (absent in older packs). */
readonly roofs?: readonly (readonly number[])[]
readonly objects: readonly unknown[]
readonly objects: readonly PackObject[]
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
readonly spawn: readonly number[] | null
readonly stats: { readonly missingTiles: number; readonly walkable: number }
@ -347,6 +390,26 @@ async function loadPackRuntime(
const walls = scene.walls.map(toDrawable)
const roofs = (scene.roofs ?? []).map(toDrawable)
const objectDrawables: ObjectDrawable[] = []
for (const obj of scene.objects) {
if (obj.frame) {
objectDrawables.push({
frame: {
x: obj.frame.x,
y: obj.frame.y,
width: obj.frame.width,
height: obj.frame.height,
},
x: obj.x + obj.frame.offsetX,
y: obj.y + obj.frame.offsetY,
depth: obj.depth ?? ((obj.x + obj.y) / 5),
page: obj.frame.page,
})
}
}
objectDrawables.sort((a, b) => a.depth - b.depth)
const objectPages: (AtlasHandle | null)[] = (scene.objectPages ?? []).map(() => null)
// Collision arrives as runs; expanding it here keeps the JSON small and yields
// exactly the grid the live path would have produced.
const blocked = new Uint8Array(scene.collision.width * scene.collision.height)
@ -397,14 +460,33 @@ async function loadPackRuntime(
floors,
walls,
roofs,
objectDrawables,
spawn,
objects: scene.objects.length,
frames: scene.frames.length,
notes: [`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`],
pages,
objectPages,
priorityPages: priority,
loadOrder,
async loadPages(renderer, indices, onProgress) {
if (objectPages.some(page => page === null) && scene.objectPages && scene.objectPages.length > 0) {
await Promise.all(scene.objectPages.map(async (page, index) => {
if (objectPages[index] !== null) return
try {
const response = await fetch(`${packBase}/${entry.path}/${page.file}`)
if (!response.ok) return
const bitmap = await createImageBitmap(await response.blob())
try {
objectPages[index] = renderer.addAtlas(bitmap, page.width, page.height)
} finally {
bitmap.close()
}
} catch (err) {
console.warn(`object page ${page.file}: ${(err as Error).message}`)
}
}))
}
const queue = indices.filter(index => pages[index] === null)
let cursor = 0
const workers = Array.from({ length: Math.min(PAGE_CONCURRENCY, queue.length) }, async () => {
@ -536,11 +618,13 @@ async function loadLiveRuntime(
floors: scene.floors.map(toDrawable),
walls: scene.walls.map(toDrawable),
roofs: scene.roofs.map(toDrawable),
objectDrawables: [],
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
objects: level.objects.length,
frames: scene.frames.length,
notes,
pages: [renderer.defaultAtlasHandle],
objectPages: [],
priorityPages: [0],
loadOrder: [0],
loadPages: async () => {},
@ -732,8 +816,44 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
}
drawTiles(runtime.floors, 0, runtime.floors.length)
const cell = cellOf(runtime.grid, player.x, player.y)
const insertAt = depthInsertIndex(runtime.walls, cell.x, cell.y)
drawTiles(runtime.walls, 0, insertAt)
const playerDepth = cell.x + cell.y
let wallIdx = 0
let objIdx = 0
const walls = runtime.walls
const objs = runtime.objectDrawables
const drawUpToDepth = (maxDepth: number): void => {
while (wallIdx < walls.length || objIdx < objs.length) {
const nextWall = wallIdx < walls.length ? walls[wallIdx]! : null
const nextObj = objIdx < objs.length ? objs[objIdx]! : null
const wallDepth = nextWall !== null ? nextWall.cellX + nextWall.cellY : Infinity
const objDepth = nextObj !== null ? nextObj.depth : Infinity
if (wallDepth > maxDepth && objDepth > maxDepth) break
if (wallDepth <= objDepth) {
const page = runtime.pages[nextWall!.page]
if (page === null || page === undefined) {
skippedDraws += 1
} else {
renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { atlas: page })
}
wallIdx += 1
} else {
const page = runtime.objectPages[nextObj!.page]
if (page === null || page === undefined) {
skippedDraws += 1
} else {
renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, { atlas: page })
}
objIdx += 1
}
}
}
drawUpToDepth(playerDepth)
if (character === null) {
// Placeholder until the character archive has been read.
renderer.drawSolid(player.x - MARKER_WIDTH / 2, player.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.85, 0.75, 0.45, 1])
@ -754,7 +874,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
state.characterGroup = groupIndex
}
}
drawTiles(runtime.walls, insertAt, runtime.walls.length)
drawUpToDepth(Infinity)
// Roofs last, in their own pass: the engine paints them after every other
// layer so they cover the floor, the walls and anything walking under them.
drawTiles(runtime.roofs, 0, runtime.roofs.length)
@ -788,7 +908,9 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
+ (state.character
? ` 角色为真·女法师(DCC+COF 合成,${String(state.characterMembers)} 层)。`
: ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。')
+ ' 可破坏物品的位置与美术成员已进包,静态帧待接。'
+ (runtime.objectDrawables.length > 0
? ` 对象层已绘制(火把/箱子/传送点等 ${String(runtime.objectDrawables.length)} 处)。`
: ' 可破坏物品的位置与美术成员已进包,静态帧待接。')
preloadRemaining(runtime, renderer)
if (runtime.palette !== null) {
@ -818,6 +940,9 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
for (const page of runtime.pages) {
if (page !== null) renderer.deleteAtlas(page)
}
for (const page of runtime.objectPages) {
if (page !== null) renderer.deleteAtlas(page)
}
renderer.dispose()
})
}