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:
parent
b3368e552a
commit
72731bb143
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(',')}`)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue