feat(anim): 实现角色与怪物动作播放、武器切换感知与地表锚点对齐 (Fixes #145)
This commit is contained in:
parent
75eba8d9ff
commit
f9fd5eb3b8
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue