From d890f558b1d7b958fb7956c70375dc7869f7876b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 11:40:05 +0000 Subject: [PATCH] =?UTF-8?q?fix(scene):=20=E8=AE=A9=E6=80=AA=E7=89=A9?= =?UTF-8?q?=E7=BE=8E=E6=9C=AF=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=8F=98=E7=BA=A2=E6=96=B9=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三层 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 --- src/game/monster-art.ts | 81 +++++++++++++- src/scene/act-scene.ts | 154 +++++++++++++++++++++++--- tests/monster-art-diagnostics.test.ts | 138 +++++++++++++++++++++++ 3 files changed, 354 insertions(+), 19 deletions(-) create mode 100644 tests/monster-art-diagnostics.test.ts diff --git a/src/game/monster-art.ts b/src/game/monster-art.ts index 74f4fe5..ca4fa6e 100644 --- a/src/game/monster-art.ts +++ b/src/game/monster-art.ts @@ -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], } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 390b340..30b8922 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 + /** 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[] { + 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> { +): Promise { const map = new Map() - 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() @@ -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) }) } diff --git a/tests/monster-art-diagnostics.test.ts b/tests/monster-art-diagnostics.test.ts new file mode 100644 index 0000000..70e9f1d --- /dev/null +++ b/tests/monster-art-diagnostics.test.ts @@ -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') + }) +})