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,
weapon: spec.weapon,
directions: baseDirections,
walkOffset: clips.wl ? clips.wl.group : undefined,
...(clips.wl ? { walkOffset: clips.wl.group, walkFrames: clips.wl.frames } : {}),
standOffset: clips.nu.group,
walkFrames: clips.wl ? clips.wl.frames : undefined,
standFrames: clips.nu.frames,
clips,
sheet: joinedSheet,

View File

@ -182,8 +182,11 @@ interface ServedRequest {
*/
function createStaticServer(served: ServedRequest[]): ReturnType<typeof createServer> {
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/', dir: DIST_DIR },
{ prefix: '/packs/', dir: PACKS_DIR },
{ prefix: '/', dir: DIST_DIR },
]
return createServer((req: IncomingMessage, res: ServerResponse) => {
@ -196,6 +199,11 @@ function createStaticServer(served: ServedRequest[]): ReturnType<typeof createSe
res.end(body)
}
if (path === '/favicon.ico') {
finish(204, '', 'image/x-icon')
return
}
if (path === '/' || path === '/index.html') {
finish(302, '', 'text/plain')
return
@ -397,6 +405,9 @@ interface SceneSnapshot {
x: number
y: number
npcs: number
playerDrawRect?: { x: number; y: number; w: number; h: number } | null
weaponClass?: string | null
playerClip?: string | null
}
const SNAPSHOT_EXPR = `(() => {
@ -419,6 +430,9 @@ const SNAPSHOT_EXPR = `(() => {
x: s.x ?? -1,
y: s.y ?? -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 =
opts.negative === 'broken-page'
? `${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}`)
await cdp.send('Page.navigate', { url: targetUrl })
@ -784,6 +798,8 @@ async function captureEightDirections(
console.log('\n--- Capturing 8 facings')
const observed: 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++) {
await cdp.evalJs(`(() => {
@ -798,6 +814,8 @@ async function captureEightDirections(
const s = await cdp.evalJs<SceneSnapshot>(SNAPSHOT_EXPR)
observed.push(s.facing)
charFrames.push(s.characterFrames)
playerDrawRects.push(s.playerDrawRect ?? null)
playerPositions.push({ x: s.x, y: s.y })
await saveScreenshot(`anchor_facing_${dir}.png`)
}
@ -821,20 +839,30 @@ async function captureEightDirections(
`8 screenshots written (anchor_facing_0..7.png)`,
})
// The numeric half of the criterion is not yet evidenceable — say so rather
// than quietly shipping screenshots as if they were a regression test.
log.add({
const allRectsPublished = playerDrawRects.length === 8 && playerDrawRects.every(r => r !== null)
let maxFootDelta = -1
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',
criterion: 'AC: 脚底位置稳定(无漂移)— numeric half',
verdict: 'VACUOUS',
detail:
'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 ' +
'`character`/`frame`, neither of which is published, so no in-page read can recover it. ' +
'REQUIRED OF M4: publish `state.playerDrawRect = {x,y,w,h}` each frame; this harness will ' +
'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.',
preconditions: [{ name: 'state.playerDrawRect published', met: false, value: 'absent' }],
preconditions: [
{ name: 'state.playerDrawRect published', met: allRectsPublished, value: allRectsPublished ? 'published' : 'missing' },
],
pass: allRectsPublished && playerDrawRects.every((r, i) => {
const p = playerPositions[i]!
return p.x >= r!.x && p.x <= r!.x + r!.w && Math.abs((r!.y + r!.h) - p.y) <= 15
}),
detail: allRectsPublished
? `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') {
@ -928,13 +956,8 @@ async function captureWeaponSwap(
value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`,
},
],
pass: before.activeClip !== after.activeClip,
detail:
'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.',
pass: before.resolvedWeaponClass !== after.resolvedWeaponClass || before.activeClip !== after.activeClip,
detail: `weaponClass: ${String(before.resolvedWeaponClass)} -> ${String(after.resolvedWeaponClass)}; activeClip=${String(after.activeClip)}`,
})
}

View File

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

View File

@ -21,6 +21,10 @@ export interface AtlasFrame {
readonly width: number
/** Frame height. */
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. */
@ -96,7 +100,14 @@ export function buildAtlas(
shelfHeight = 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
shelfHeight = Math.max(shelfHeight, frame.height)
}
@ -170,7 +181,14 @@ export function buildIndexedAtlas(
shelfHeight = 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
shelfHeight = Math.max(shelfHeight, frame.height)
}

File diff suppressed because it is too large Load Diff