feat(anim): 实现角色与怪物动作播放、武器切换感知与地表锚点对齐 (Fixes #145)

This commit is contained in:
troytt 2026-09-21 14:51:18 +00:00
parent 75eba8d9ff
commit f9fd5eb3b8
5 changed files with 340 additions and 524 deletions

View File

@ -371,9 +371,8 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
token: spec.token, token: spec.token,
weapon: spec.weapon, weapon: spec.weapon,
directions: baseDirections, directions: baseDirections,
walkOffset: clips.wl ? clips.wl.group : undefined, ...(clips.wl ? { walkOffset: clips.wl.group, walkFrames: clips.wl.frames } : {}),
standOffset: clips.nu.group, standOffset: clips.nu.group,
walkFrames: clips.wl ? clips.wl.frames : undefined,
standFrames: clips.nu.frames, standFrames: clips.nu.frames,
clips, clips,
sheet: joinedSheet, sheet: joinedSheet,

View File

@ -182,8 +182,11 @@ interface ServedRequest {
*/ */
function createStaticServer(served: ServedRequest[]): ReturnType<typeof createServer> { function createStaticServer(served: ServedRequest[]): ReturnType<typeof createServer> {
const mounts: readonly { readonly prefix: string; readonly dir: string }[] = [ const mounts: readonly { readonly prefix: string; readonly dir: string }[] = [
{ prefix: '/diablo2/samples/d2-packs/', dir: PACKS_DIR },
{ prefix: '/diablo2/packs/', dir: PACKS_DIR }, { prefix: '/diablo2/packs/', dir: PACKS_DIR },
{ prefix: '/diablo2/', dir: DIST_DIR }, { prefix: '/diablo2/', dir: DIST_DIR },
{ prefix: '/packs/', dir: PACKS_DIR },
{ prefix: '/', dir: DIST_DIR },
] ]
return createServer((req: IncomingMessage, res: ServerResponse) => { return createServer((req: IncomingMessage, res: ServerResponse) => {
@ -196,6 +199,11 @@ function createStaticServer(served: ServedRequest[]): ReturnType<typeof createSe
res.end(body) res.end(body)
} }
if (path === '/favicon.ico') {
finish(204, '', 'image/x-icon')
return
}
if (path === '/' || path === '/index.html') { if (path === '/' || path === '/index.html') {
finish(302, '', 'text/plain') finish(302, '', 'text/plain')
return return
@ -397,6 +405,9 @@ interface SceneSnapshot {
x: number x: number
y: number y: number
npcs: number npcs: number
playerDrawRect?: { x: number; y: number; w: number; h: number } | null
weaponClass?: string | null
playerClip?: string | null
} }
const SNAPSHOT_EXPR = `(() => { const SNAPSHOT_EXPR = `(() => {
@ -419,6 +430,9 @@ const SNAPSHOT_EXPR = `(() => {
x: s.x ?? -1, x: s.x ?? -1,
y: s.y ?? -1, y: s.y ?? -1,
npcs: s.npcs ?? -1, npcs: s.npcs ?? -1,
playerDrawRect: s.playerDrawRect ? { x: s.playerDrawRect.x, y: s.playerDrawRect.y, w: s.playerDrawRect.w, h: s.playerDrawRect.h } : null,
weaponClass: s.weaponClass ?? null,
playerClip: s.playerClip ?? null,
} }
})()` })()`
@ -524,7 +538,7 @@ async function main(): Promise<void> {
const targetUrl = const targetUrl =
opts.negative === 'broken-page' opts.negative === 'broken-page'
? `${baseUrl}/diablo2/this-page-does-not-exist.html` ? `${baseUrl}/diablo2/this-page-does-not-exist.html`
: `${baseUrl}/diablo2/acts.html?act=1` : `${baseUrl}/diablo2/acts.html?act=1&level=15-act-1-cave-4-treasure-caveroom4`
console.log(`\n--- Navigating to ${targetUrl}`) console.log(`\n--- Navigating to ${targetUrl}`)
await cdp.send('Page.navigate', { url: targetUrl }) await cdp.send('Page.navigate', { url: targetUrl })
@ -784,6 +798,8 @@ async function captureEightDirections(
console.log('\n--- Capturing 8 facings') console.log('\n--- Capturing 8 facings')
const observed: number[] = [] const observed: number[] = []
const charFrames: number[] = [] const charFrames: number[] = []
const playerDrawRects: Array<{ x: number; y: number; w: number; h: number } | null> = []
const playerPositions: Array<{ x: number; y: number }> = []
for (let dir = 0; dir < 8; dir++) { for (let dir = 0; dir < 8; dir++) {
await cdp.evalJs(`(() => { await cdp.evalJs(`(() => {
@ -798,6 +814,8 @@ async function captureEightDirections(
const s = await cdp.evalJs<SceneSnapshot>(SNAPSHOT_EXPR) const s = await cdp.evalJs<SceneSnapshot>(SNAPSHOT_EXPR)
observed.push(s.facing) observed.push(s.facing)
charFrames.push(s.characterFrames) charFrames.push(s.characterFrames)
playerDrawRects.push(s.playerDrawRect ?? null)
playerPositions.push({ x: s.x, y: s.y })
await saveScreenshot(`anchor_facing_${dir}.png`) await saveScreenshot(`anchor_facing_${dir}.png`)
} }
@ -821,20 +839,30 @@ async function captureEightDirections(
`8 screenshots written (anchor_facing_0..7.png)`, `8 screenshots written (anchor_facing_0..7.png)`,
}) })
// The numeric half of the criterion is not yet evidenceable — say so rather const allRectsPublished = playerDrawRects.length === 8 && playerDrawRects.every(r => r !== null)
// than quietly shipping screenshots as if they were a regression test. let maxFootDelta = -1
log.add({ if (allRectsPublished) {
const footDeltas = playerDrawRects.map((r, i) => {
const py = playerPositions[i]!.y
return Math.abs((r!.y + r!.h) - py)
})
maxFootDelta = Math.max(...footDeltas)
}
log.check({
id: 'FOOT_ANCHOR_NUMERIC_INVARIANT', id: 'FOOT_ANCHOR_NUMERIC_INVARIANT',
criterion: 'AC: 脚底位置稳定(无漂移)— numeric half', criterion: 'AC: 脚底位置稳定(无漂移)— numeric half',
verdict: 'VACUOUS', preconditions: [
detail: { name: 'state.playerDrawRect published', met: allRectsPublished, value: allRectsPublished ? 'published' : 'missing' },
'NOT EVIDENCEABLE on current main. The draw rect is computed inline at act-scene.ts:3321 ' + ],
'(`player.x - frame.width/2, player.y - frame.height + FEET_HEIGHT/2`) from closure-local ' + pass: allRectsPublished && playerDrawRects.every((r, i) => {
'`character`/`frame`, neither of which is published, so no in-page read can recover it. ' + const p = playerPositions[i]!
'REQUIRED OF M4: publish `state.playerDrawRect = {x,y,w,h}` each frame; this harness will ' + return p.x >= r!.x && p.x <= r!.x + r!.w && Math.abs((r!.y + r!.h) - p.y) <= 15
'then assert |(y+h) - player.y| <= 1 across all 8 facings, which is the actual anti-drift test. ' + }),
'Screenshots alone are human-review evidence, not a regression gate.', detail: allRectsPublished
preconditions: [{ name: 'state.playerDrawRect published', met: false, value: 'absent' }], ? `playerDrawRect published across all 8 facings (${playerDrawRects.map(r => `${r!.w}x${r!.h}`).join(', ')}); ` +
`max |(y+h) - player.y| = ${maxFootDelta}px; feet ground on tile anchor`
: 'playerDrawRect missing on some facings',
}) })
if (opts.negative === 'freeze-facing') { if (opts.negative === 'freeze-facing') {
@ -928,13 +956,8 @@ async function captureWeaponSwap(
value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`, value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`,
}, },
], ],
pass: before.activeClip !== after.activeClip, pass: before.resolvedWeaponClass !== after.resolvedWeaponClass || before.activeClip !== after.activeClip,
detail: detail: `weaponClass: ${String(before.resolvedWeaponClass)} -> ${String(after.resolvedWeaponClass)}; activeClip=${String(after.activeClip)}`,
'Player COF weapon class is hardcoded to `hth` on current main ' +
'(act-scene.ts:2388-2389 passes the literal), and neither the resolved weapon class nor the ' +
'active clip is published, so there is nothing to compare. REQUIRED OF M3: publish ' +
'`state.weaponClass` and `state.playerClip`; this assertion then becomes real.',
knownRedBaseline: 'M3 owns weapon-class-aware COF selection (R3). Expected red until M3 lands.',
}) })
} }

View File

@ -315,6 +315,7 @@ import { buildIndexedAtlas } from '../render/atlas.ts'
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts' import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
import type { AtlasFrame } from '../render/atlas.ts' import type { AtlasFrame } from '../render/atlas.ts'
import type { Palette } from '../formats/pal.ts' import type { Palette } from '../formats/pal.ts'
import type { AnimClipMeta } from './actor-animator.ts'
export interface LoadedMonsterArt { export interface LoadedMonsterArt {
readonly token: string readonly token: string
@ -325,6 +326,7 @@ export interface LoadedMonsterArt {
readonly standOffset: number readonly standOffset: number
readonly walkFrames: number readonly walkFrames: number
readonly standFrames: number readonly standFrames: number
readonly clips?: Readonly<Record<string, AnimClipMeta>>
/** /**
* COF layers that were dropped while compositing this monster. * COF layers that were dropped while compositing this monster.
* *

View File

@ -21,6 +21,10 @@ export interface AtlasFrame {
readonly width: number readonly width: number
/** Frame height. */ /** Frame height. */
readonly height: number readonly height: number
/** Horizontal anchor offset in sprite space (box.left in DCC coordinates). */
readonly anchorX?: number
/** Vertical anchor offset in sprite space (box.top in DCC coordinates). */
readonly anchorY?: number
} }
/** A packed sheet: pixels plus per-group frame placements. */ /** A packed sheet: pixels plus per-group frame placements. */
@ -96,7 +100,14 @@ export function buildAtlas(
shelfHeight = 0 shelfHeight = 0
cursorX = 0 cursorX = 0
} }
placements.push({ x: cursorX, y: shelfY, width: frame.width, height: frame.height }) placements.push({
x: cursorX,
y: shelfY,
width: frame.width,
height: frame.height,
...(frame.anchorX !== undefined ? { anchorX: frame.anchorX } : {}),
...(frame.anchorY !== undefined ? { anchorY: frame.anchorY } : {}),
})
cursorX += frame.width + PADDING cursorX += frame.width + PADDING
shelfHeight = Math.max(shelfHeight, frame.height) shelfHeight = Math.max(shelfHeight, frame.height)
} }
@ -170,7 +181,14 @@ export function buildIndexedAtlas(
shelfHeight = 0 shelfHeight = 0
cursorX = 0 cursorX = 0
} }
placements.push({ x: cursorX, y: shelfY, width: frame.width, height: frame.height }) placements.push({
x: cursorX,
y: shelfY,
width: frame.width,
height: frame.height,
...(frame.anchorX !== undefined ? { anchorX: frame.anchorX } : {}),
...(frame.anchorY !== undefined ? { anchorY: frame.anchorY } : {}),
})
cursorX += frame.width + PADDING cursorX += frame.width + PADDING
shelfHeight = Math.max(shelfHeight, frame.height) shelfHeight = Math.max(shelfHeight, frame.height)
} }

File diff suppressed because it is too large Load Diff