fix(scene): 让怪物美术加载失败可观测,不再静默变红方块

三层 catch 把「整个美术系统崩溃」变成「场景就绪 + 全场红方块」,只留一句
console.warn:

- `monster-art.ts` 逐 COF 层的 `catch` 直接丢层,怪物缺头缺臂而无任何日志。
  现在每一层失败都产出 `MonsterLayerFailure`(unknown-component / no-member /
  decode),`console.error` 输出,并随 `MonsterAnimationSheet.layerFailures`
  与 `LoadedMonsterArt.layerFailures` 返回。
- `loadMonsterArtMap` 改为返回 `MonsterArtLoad{ map, missing, errors,
  layerFailures }`:整函数 catch 与逐 spec catch 都计数并降级为 console.error,
  预烘焙资源 fetch 失败也不再完全无声。
- `ActSceneState` 新增 `missingMonsterArt` / `monsterArtErrors` /
  `monsterArtLayerFailures`,由 `recordMonsterArtLoad` 在两处加载点写入;
  HUD 追加 `monsterArtStatusText` 片段。
- 红方块绘制点自己上报 id(流式刷怪可能刷出加载时未知的类型)。

新增 tests/monster-art-diagnostics.test.ts(5 例):逐层 no-member / decode
失败的计数与日志,以及 `missingMonsterArtIds` / `monsterArtStatusText`。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 11:40:05 +00:00
parent 952ad00429
commit d890f558b1
3 changed files with 354 additions and 19 deletions

View File

@ -84,6 +84,33 @@ function blit(
}
}
/**
* One COF layer that did not make it into the composite.
*
* A dropped layer is not a missing monster — it is a monster rendered without
* its head, or its weapon arm. That used to happen behind a bare `catch`, so
* the only evidence was the picture. Every drop is now described here and
* logged at error level.
*/
export interface MonsterLayerFailure {
/** The monster token, e.g. `fa`. */
readonly token: string
/** The animation the layer belonged to, e.g. `wl`. */
readonly animation: string
/** The composite component folder, e.g. `hd`; `null` for a layer type outside {@link MONSTER_COMPONENTS}. */
readonly component: string | null
/** The DCC member that failed, when one was found. */
readonly member: string | null
/**
* - `unknown-component` — the COF names a layer type this port does not map.
* - `no-member` — no DCC in the archives matches the layer's naming convention.
* - `decode` — the DCC was found but could not be read.
*/
readonly reason: 'unknown-component' | 'no-member' | 'decode'
/** The decoder's message, for `decode`. */
readonly message?: string
}
/**
* Result of compositing a monster animation.
*/
@ -93,6 +120,8 @@ export interface MonsterAnimationSheet {
readonly framesPerDirection: number
readonly layers: number
readonly decodedMembers: readonly string[]
/** Layers that were dropped; empty when the monster composited whole. */
readonly layerFailures: readonly MonsterLayerFailure[]
}
/**
@ -141,7 +170,13 @@ export async function compositeMonsterAnimation(
const layerResults = await Promise.all(cof.layers.map(async (layer) => {
const component = MONSTER_COMPONENTS[layer.type]
if (component === undefined) return { sprite: null, member: undefined }
if (component === undefined) {
return {
sprite: null,
member: undefined,
failure: { token: lower, animation: usedAnim, component: null, member: null, reason: 'unknown-component' } as MonsterLayerFailure,
}
}
const member = findMonsterLayerSprite(
names,
root,
@ -151,17 +186,44 @@ export async function compositeMonsterAnimation(
component,
layer.weaponClass,
)
if (member === undefined) return { sprite: null, member: undefined }
if (member === undefined) {
return {
sprite: null,
member: undefined,
failure: { token: lower, animation: usedAnim, component, member: null, reason: 'no-member' } as MonsterLayerFailure,
}
}
try {
const bytes = await archives.read(member)
return { sprite: decodeDcc(bytes), member }
} catch {
return { sprite: null, member: undefined }
return { sprite: decodeDcc(bytes), member, failure: undefined }
} catch (err) {
return {
sprite: null,
member: undefined,
failure: {
token: lower,
animation: usedAnim,
component,
member,
reason: 'decode',
message: (err as Error).message,
} as MonsterLayerFailure,
}
}
}))
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
// A monster missing a limb is a data failure, not a cosmetic one, so it is
// said out loud even though the composite still returns a usable sheet.
for (const failure of layerFailures) {
console.error(
`monster art layer dropped: ${failure.token}/${failure.component ?? '?'} (${failure.animation}) `
+ `— ${failure.reason}${failure.member !== null ? ` [${failure.member}]` : ''}`
+ `${failure.message !== undefined ? `: ${failure.message}` : ''}`,
)
}
// Determine maximum bounding box across all layer directions
let minX = 0
@ -222,6 +284,7 @@ export async function compositeMonsterAnimation(
framesPerDirection: cof.framesPerDirection,
layers: cof.layers.length,
decodedMembers,
layerFailures,
}
}
@ -242,6 +305,13 @@ export interface LoadedMonsterArt {
readonly standOffset: number
readonly walkFrames: number
readonly standFrames: number
/**
* COF layers that were dropped while compositing this monster.
*
* Non-empty means the sprite on screen is missing a body part. Pre-baked pack
* art has no layers of its own, so its loader reports an empty list.
*/
readonly layerFailures: readonly MonsterLayerFailure[]
}
/**
@ -276,5 +346,6 @@ export async function loadMonsterAtlas(
standOffset: walk.sheet.groups.length,
walkFrames: walk.framesPerDirection,
standFrames: stand.framesPerDirection,
layerFailures: [...walk.layerFailures, ...stand.layerFailures],
}
}

View File

@ -238,6 +238,17 @@ export interface ActSceneState {
npcsNear: NpcEntity[]
/** Count of animated tiles in the active map. */
animatedTiles: number
/**
* Monster ids the art loader could not dress, lower case.
*
* Each one renders as a red placeholder box. The scene stays `ready` — it can
* still be walked — but it is not showing the level it claims to.
*/
missingMonsterArt: string[]
/** Monster-art loads that threw: a whole level, or one monster type. */
monsterArtErrors: number
/** COF layers dropped inside art that did load: monsters missing body parts. */
monsterArtLayerFailures: number
}
declare global {
@ -257,6 +268,7 @@ const state: ActSceneState = {
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0,
}
if (typeof window !== 'undefined') window.__d2webAct = state
@ -1141,6 +1153,87 @@ interface PackedCharJson {
readonly groups: readonly (readonly [number, number, number, number])[][]
}
/**
* What a monster-art load produced, including what it failed to produce.
*
* The three failure channels used to be a `console.warn` and an empty `Map`,
* which the render loop turns into a red box per monster while the scene still
* reports `ready`. They are counted here so {@link ActSceneState} — and through
* it the HUD — can say so.
*/
export interface MonsterArtLoad {
/** Monster id (lower case) to its atlas. */
readonly map: Map<string, LoadedMonsterArt>
/** Monster ids left with no atlas: every one of these draws as a red box. */
readonly missing: string[]
/** Loads that threw — a per-spec failure, or the whole function. */
readonly errors: number
/** COF layers dropped inside the art that *did* load: monsters missing limbs. */
readonly layerFailures: number
}
/**
* The monster ids that got no atlas.
*
* @param monsterIds - the ids the level asked for.
* @param loaded - the ids that loaded, lower case.
* @returns the missing ids, lower case and deduplicated, in request order.
*/
export function missingMonsterArtIds(
monsterIds: readonly string[],
loaded: ReadonlySet<string>,
): string[] {
const missing: string[] = []
for (const id of monsterIds) {
const key = id.toLowerCase()
if (!loaded.has(key) && !missing.includes(key)) missing.push(key)
}
return missing
}
/**
* The HUD fragment describing a monster-art load, empty when it was clean.
*
* @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}.
* @returns the fragment, ready to append to the HUD line.
*/
export function monsterArtStatusText(load: {
readonly missing: readonly string[]
readonly errors: number
readonly layerFailures: number
}): string {
const parts: string[] = []
if (load.missing.length > 0) {
const shown = load.missing.slice(0, 3).join('、')
const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : ''
parts.push(`怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`)
}
if (load.errors > 0) parts.push(`加载错误 ${String(load.errors)}`)
if (load.layerFailures > 0) parts.push(`缺层 ${String(load.layerFailures)}`)
return parts.length === 0 ? '' : ` · ${parts.join(' · ')}`
}
/**
* Publish a load's failures into {@link ActSceneState}.
*
* Both the boot path and the level-change path call the loader, and both used to
* drop everything but the `Map` on the floor.
*
* @param load - what the loader produced.
*/
function recordMonsterArtLoad(load: MonsterArtLoad): void {
state.missingMonsterArt = [...load.missing]
state.monsterArtErrors = load.errors
state.monsterArtLayerFailures = load.layerFailures
if (load.missing.length > 0 || load.errors > 0 || load.layerFailures > 0) {
console.error(
`monster art incomplete: ${String(load.missing.length)} type(s) without art `
+ `(${load.missing.join(', ')}), ${String(load.errors)} load error(s), `
+ `${String(load.layerFailures)} dropped layer(s)`,
)
}
}
/**
* Load monster atlases on-demand for all monster types present in the scene.
* Prefers pre-baked indexed PNG + JSON from the act pack when available (< 200ms),
@ -1153,9 +1246,11 @@ async function loadMonsterArtMap(
renderer: SpriteRenderer,
packBase?: string,
act?: number,
): Promise<Map<string, LoadedMonsterArt>> {
): Promise<MonsterArtLoad> {
const map = new Map<string, LoadedMonsterArt>()
if (monsterIds.length === 0) return map
let errors = 0
let layerFailures = 0
if (monsterIds.length === 0) return { map, missing: [], errors, layerFailures }
try {
// Resolve unique (token, weapon) specs needed
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
@ -1200,11 +1295,15 @@ async function loadMonsterArtMap(
standOffset: meta.standOffset,
walkFrames: meta.walkFrames,
standFrames: meta.standFrames,
// Pre-baked art is already composited: there are no COF layers
// left to drop at load time.
layerFailures: [],
})
return
}
} catch {
// fall through to per-act PNG fallback
} catch (err) {
// Fall through to the per-act PNG, but do not pretend it did not happen.
console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`)
}
// 2. Fallback to per-act PNG if available
@ -1235,11 +1334,13 @@ async function loadMonsterArtMap(
standOffset: meta.standOffset,
walkFrames: meta.walkFrames,
standFrames: meta.standFrames,
layerFailures: [],
})
return
}
} catch {
// fall back to MPQ loading below
} catch (err) {
// Fall back to MPQ loading below, with the reason on the record.
console.warn(`monster art ${slug}: packed png unavailable: ${(err as Error).message}`)
}
}
fallbackSpecs.push([key, spec])
@ -1259,13 +1360,18 @@ async function loadMonsterArtMap(
const loaded = await loadMonsterAtlas(archives, spec.token, spec.weapon, palette, renderer)
loadedBySpecKey.set(key, loaded)
} catch (err) {
console.warn(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
// Every monster of this type will be a red box; say which type and
// count it so the scene cannot claim to be fully dressed.
errors += 1
console.error(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
}
})
)
}
}
for (const loaded of loadedBySpecKey.values()) layerFailures += loaded.layerFailures.length
// Map monster id to loaded art
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
@ -1278,9 +1384,12 @@ async function loadMonsterArtMap(
}
}
} catch (err) {
console.warn(`monster art loading error: ${(err as Error).message}`)
// This `catch` wraps the whole function, so landing here means *no* monster
// in the level has art. That is a broken scene, not a warning.
errors += 1
console.error(`monster art loading error: ${(err as Error).message}`)
}
return map
return { map, missing: missingMonsterArtIds(monsterIds, new Set(map.keys())), errors, layerFailures }
}
async function loadCharacterArt(
@ -1791,8 +1900,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
renderer,
packEntityBase,
packEntityAct,
).then((loadedMap) => {
monsterArtMap = loadedMap
).then((loaded) => {
monsterArtMap = loaded.map
recordMonsterArtLoad(loaded)
})
}
}
@ -2169,7 +2279,15 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
return
}
}
// Fallback to placeholder solid box if art not loaded or frame missing
// Fallback to placeholder solid box if art not loaded or frame missing.
// Streaming can spawn a type the art load never saw (a super unique's
// minions, say), so the box itself reports — otherwise the only record
// of it is the screen.
const missingId = monster.stats.id.toLowerCase()
if (!state.missingMonsterArt.includes(missingId)) {
state.missingMonsterArt.push(missingId)
console.error(`monster art missing at draw time: ${missingId} (drawn as a red box)`)
}
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.9, 0.2, 0.2, 1])
})
}
@ -2276,6 +2394,13 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
+ ` · 对象 ${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)})`
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
// A level whose monsters are all red boxes used to look exactly like a
// healthy one here.
+ monsterArtStatusText({
missing: state.missingMonsterArt,
errors: state.monsterArtErrors,
layerFailures: state.monsterArtLayerFailures,
})
}
},
})
@ -2317,8 +2442,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
renderer,
packEntityBase,
packEntityAct,
).then((loadedMap) => {
monsterArtMap = loadedMap
).then((loaded) => {
monsterArtMap = loaded.map
recordMonsterArtLoad(loaded)
})
}

View File

@ -0,0 +1,138 @@
/**
* Monster art failures must be observable (issue #58 item 3).
*
* Three `catch` blocks used to turn "the monster art system is broken" into "the
* scene is ready and every monster is a red box", with at most one
* `console.warn`. These tests pin the two halves of the fix:
*
* - `compositeMonsterAnimation` reports every COF layer it drops, rather than
* rendering a monster without its head and saying nothing.
* - `act-scene` can tell which monster ids ended up with no art, and says so in
* the HUD.
*/
import { describe, expect, it, vi } from 'vitest'
import { compositeMonsterAnimation } from '../src/game/monster-art.ts'
import type { MountedArchives } from '../src/mpq/mount.ts'
import { missingMonsterArtIds, monsterArtStatusText } from '../src/scene/act-scene.ts'
/**
* A COF with `layerCount` layers, one frame, one direction.
*
* Mirrors `decodeCof`'s reader: a 28-byte header followed by one 9-byte layer
* record each, then the frame-duration table and the per-direction layer order.
*/
function makeCof(layerCount: number): Uint8Array {
const directions = 1
const frames = 1
const size = 28 + layerCount * 9 + frames * 4 + directions * frames * layerCount
const bytes = new Uint8Array(size)
const view = new DataView(bytes.buffer)
bytes[0] = layerCount
bytes[1] = frames
bytes[2] = directions
let at = 28
for (let layer = 0; layer < layerCount; layer += 1) {
bytes[at] = layer // composite type: 0 = hd, 1 = tr, ...
bytes[at + 1] = 0 // shadow
bytes[at + 2] = 0 // selectable
bytes[at + 3] = 0 // override transparency
// weapon class, 4 characters, 'hth\0'
bytes[at + 4] = 0x68
bytes[at + 5] = 0x74
bytes[at + 6] = 0x68
bytes[at + 7] = 0
at += 9
}
for (let frame = 0; frame < frames; frame += 1) {
view.setInt32(at, 256, true)
at += 4
}
for (let i = 0; i < directions * frames * layerCount; i += 1) {
bytes[at] = i % layerCount
at += 1
}
return bytes
}
/** An archive holding only the COF: every DCC layer will fail to resolve. */
function archivesWithOnlyCof(cof: Uint8Array): MountedArchives {
const cofName = 'data\\global\\monsters\\fa\\cof\\fawlhth.cof'
return {
listFiles: async () => [cofName],
read: async (name: string) => {
if (name === cofName) return cof
throw new Error(`not found: ${name}`)
},
} as unknown as MountedArchives
}
describe('compositeMonsterAnimation layer failures', () => {
it('reports and logs every dropped COF layer instead of swallowing it', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const sheet = await compositeMonsterAnimation(archivesWithOnlyCof(makeCof(3)), 'fa', 'wl', 'hth')
// Three layers, no DCC members in the archive: three honest failures.
expect(sheet.layers).toBe(3)
expect(sheet.layerFailures.length).toBe(3)
expect(sheet.layerFailures.map(f => f.component)).toEqual(['hd', 'tr', 'lg'])
expect(sheet.layerFailures.every(f => f.reason === 'no-member')).toBe(true)
expect(sheet.decodedMembers).toEqual([])
// Silence was the bug; the log is part of the fix.
expect(error).toHaveBeenCalledTimes(3)
} finally {
error.mockRestore()
}
})
it('reports a layer whose DCC is present but undecodable', async () => {
const cofName = 'data\\global\\monsters\\fa\\cof\\fawlhth.cof'
const dccName = 'data\\global\\monsters\\fa\\hd\\fahdlitwlhth.dcc'
const cof = makeCof(1)
const archives = {
listFiles: async () => [cofName, dccName],
read: async (name: string) => {
if (name === cofName) return cof
// A truncated DCC: found, but `decodeDcc` cannot read it.
if (name === dccName) return new Uint8Array([0, 0, 0])
throw new Error(`not found: ${name}`)
},
} as unknown as MountedArchives
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const sheet = await compositeMonsterAnimation(archives, 'fa', 'wl', 'hth')
expect(sheet.layerFailures.length).toBe(1)
expect(sheet.layerFailures[0]?.reason).toBe('decode')
expect(sheet.layerFailures[0]?.member).toBe(dccName)
expect(error).toHaveBeenCalledTimes(1)
} finally {
error.mockRestore()
}
})
})
describe('act scene monster art diagnostics', () => {
it('names the monster ids that will draw as red boxes', () => {
expect(missingMonsterArtIds(['Fallen1', 'zombie1'], new Set(['fallen1'])))
.toEqual(['zombie1'])
expect(missingMonsterArtIds(['fallen1', 'fallen1'], new Set()))
.toEqual(['fallen1'])
expect(missingMonsterArtIds(['fallen1'], new Set(['fallen1']))).toEqual([])
})
it('says nothing in the HUD when the load was clean', () => {
expect(monsterArtStatusText({ missing: [], errors: 0, layerFailures: 0 })).toBe('')
})
it('shows missing art, load errors and dropped layers in the HUD', () => {
const text = monsterArtStatusText({
missing: ['fallen1', 'zombie1', 'skeleton1', 'quillrat1'],
errors: 2,
layerFailures: 5,
})
expect(text).toContain('怪物美术缺失 4')
expect(text).toContain('fallen1、zombie1、skeleton1…+1')
expect(text).toContain('加载错误 2')
expect(text).toContain('缺层 5')
})
})