From 3fa29ec0b864e433c20e37ee4bbfea52cc4a79c1 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 21 Sep 2026 13:50:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(anim):=20=E5=AE=9E=E7=8E=B0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=208.8=20=E5=AE=9A=E7=82=B9=E6=95=B0=E6=97=B6=E9=92=9F?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E4=BD=93=E5=8A=A8=E7=94=BB=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA=E4=B8=8E=E4=BA=8B=E4=BB=B6=E8=B0=83=E5=BA=A6=E5=99=A8?= =?UTF-8?q?=20(ActorAnimator)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/actor-animator.ts | 155 ++++++++++++++++++++++++++ tests/actor-animator.test.ts | 208 +++++++++++++++++++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 src/game/actor-animator.ts create mode 100644 tests/actor-animator.test.ts diff --git a/src/game/actor-animator.ts b/src/game/actor-animator.ts new file mode 100644 index 0000000..c5933ec --- /dev/null +++ b/src/game/actor-animator.ts @@ -0,0 +1,155 @@ +/** + * Actor animator: fractional-speed 8.8 fixed-point playback with animation events. + * + * Strictly aligns with Diablo II v1.13c: + * - 25Hz simulation tick base. + * - Animation speed encoded as 8.8 fixed-point (256 = 1.0 frame per tick). + * - Exact fractional duration: cycleTicks = (framesPerDirection * 256) / speed. + * - Frame events (code 1 = ATTACK, code 2 = MISSILE, code 3 = SOUND) triggered in chronological order. + * - Looping clips (NU, WL, RN, TN, TW) wrap seamlessly. + * - One-shot clips (A1, A2, SC, GH, DT) stop at the final frame and assert `finished = true`. + */ + +export interface AnimFrameEvent { + readonly frame: number + readonly code: number +} + +export interface AnimClipMeta { + readonly group: number + readonly directions?: number + readonly frames: number + readonly speed: number + readonly loop: boolean + readonly events: readonly { readonly f: number; readonly c: number }[] +} + +export class ActorAnimator { + private currentMode: string | null = null + private currentClip: AnimClipMeta | null = null + private acc = 0 // 8.8 fixed-point accumulator + private index = 0 // current frame index (0 .. frames - 1) + private isFinished = false + + /** + * Current playing mode name (e.g. 'nu', 'wl', 'a1'). + */ + get mode(): string | null { + return this.currentMode + } + + /** + * Current active clip metadata. + */ + get clip(): AnimClipMeta | null { + return this.currentClip + } + + /** + * Current frame index within the active clip. + */ + get frameIndex(): number { + return this.index + } + + /** + * Whether a one-shot clip has completed playback and reached the terminal frame. + */ + get finished(): boolean { + return this.isFinished + } + + /** + * Play an animation mode with its associated clip metadata. + * By default, switching to the same mode that is already active does NOT restart playback, + * preventing walk cycle stutter on re-press. Pass `opts.restart = true` to force restart. + */ + play(mode: string, clip: AnimClipMeta, opts?: { restart?: boolean }): void { + const normalizedMode = mode.toLowerCase() + if (!opts?.restart && this.currentMode === normalizedMode && this.currentClip === clip && !this.isFinished) { + return + } + + this.currentMode = normalizedMode + this.currentClip = clip + this.acc = 0 + this.index = 0 + this.isFinished = clip.frames <= 1 && !clip.loop + } + + /** + * Advance one 25Hz simulation tick. + * + * @param speedScale - Optional speed scale multiplier in 8.8 fixed-point (default 256 = 1.0x). + * @returns Array of frame events crossed during this tick, in chronological order. + */ + tick(speedScale = 256): readonly AnimFrameEvent[] { + const clip = this.currentClip + if (!clip || clip.frames <= 0) { + return [] + } + + // Already finished one-shot clip remains static at final frame + if (this.isFinished) { + return [] + } + + // Single frame non-looping clip (e.g. corpse DD) finishes immediately + if (clip.frames <= 1 && !clip.loop) { + this.isFinished = true + return [] + } + + const effectiveSpeed = Math.round((clip.speed * speedScale) / 256) + if (effectiveSpeed <= 0) { + return [] + } + + this.acc += effectiveSpeed + const triggeredEvents: AnimFrameEvent[] = [] + + while (this.acc >= 256) { + this.acc -= 256 + const nextIndex = this.index + 1 + + if (nextIndex >= clip.frames) { + if (clip.loop) { + this.index = 0 + // Check for events at loop restart frame 0 + this.collectEventsForFrame(clip, 0, triggeredEvents) + } else { + this.index = clip.frames - 1 + this.isFinished = true + this.acc = 0 + break + } + } else { + this.index = nextIndex + this.collectEventsForFrame(clip, this.index, triggeredEvents) + } + } + + return triggeredEvents + } + + /** + * Reset the animator to idle state. + */ + reset(): void { + this.currentMode = null + this.currentClip = null + this.acc = 0 + this.index = 0 + this.isFinished = false + } + + private collectEventsForFrame(clip: AnimClipMeta, frame: number, out: AnimFrameEvent[]): void { + if (!clip.events || clip.events.length === 0) return + for (let i = 0; i < clip.events.length; i += 1) { + const e = clip.events[i]! + if (e.f === frame) { + out.push({ frame: e.f, code: e.c }) + } + } + } +} diff --git a/tests/actor-animator.test.ts b/tests/actor-animator.test.ts new file mode 100644 index 0000000..6004316 --- /dev/null +++ b/tests/actor-animator.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest' +import { ActorAnimator, type AnimClipMeta } from '../src/game/actor-animator.ts' + +describe('ActorAnimator: 8.8 fixed-point timing & frame events', () => { + it('advances exactly 1 frame per tick at speed 256 (1.0x)', () => { + const animator = new ActorAnimator() + const clip: AnimClipMeta = { + group: 0, + frames: 8, + speed: 256, + loop: true, + events: [], + } + + animator.play('wl', clip) + expect(animator.frameIndex).toBe(0) + expect(animator.finished).toBe(false) + + for (let tick = 1; tick <= 7; tick += 1) { + animator.tick() + expect(animator.frameIndex).toBe(tick) + } + + // 8th tick wraps back to frame 0 + animator.tick() + expect(animator.frameIndex).toBe(0) + }) + + it('advances 1 frame every 2 ticks at speed 128 (0.5x, e.g. SONUHTH)', () => { + const animator = new ActorAnimator() + const clip: AnimClipMeta = { + group: 16, + frames: 8, + speed: 128, + loop: true, + events: [], + } + + animator.play('nu', clip) + expect(animator.frameIndex).toBe(0) + + // Tick 1: acc = 128 -> frame 0 + animator.tick() + expect(animator.frameIndex).toBe(0) + + // Tick 2: acc = 256 -> frame 1 + animator.tick() + expect(animator.frameIndex).toBe(1) + + // Run remaining ticks to complete full 8-frame cycle (16 ticks total) + for (let t = 3; t <= 16; t += 1) { + animator.tick() + } + expect(animator.frameIndex).toBe(0) + }) + + it('accurately reproduces fractional pacing (BAWL1HS: 8 frames at speed 168 -> 12.19 ticks)', () => { + const animator = new ActorAnimator() + const clip: AnimClipMeta = { + group: 0, + frames: 8, + speed: 168, + loop: true, + events: [], + } + + animator.play('wl', clip) + let cycleCompletedAtTick = -1 + + for (let t = 1; t <= 25; t += 1) { + const prevFrame = animator.frameIndex + animator.tick() + if (prevFrame === 7 && animator.frameIndex === 0) { + cycleCompletedAtTick = t + break + } + } + + // 8 * 256 = 2048 accumulator units needed for a cycle. + // At speed 168 per tick: + // 12 ticks * 168 = 2016 (< 2048, still frame 7) + // 13 ticks * 168 = 2184 (>= 2048, wraps to frame 0 on tick 13) + expect(cycleCompletedAtTick).toBe(13) + }) + + it('dispatches frame events in chronological order on the tick they are crossed', () => { + const animator = new ActorAnimator() + const clip: AnimClipMeta = { + group: 48, + frames: 16, + speed: 256, + loop: false, + events: [{ f: 9, c: 1 }], // SOA1HTH: attack event code 1 at frame 9 + } + + animator.play('a1', clip) + const collectedEvents: { tick: number; frame: number; code: number }[] = [] + + for (let t = 1; t <= 16; t += 1) { + const events = animator.tick() + for (const e of events) { + collectedEvents.push({ tick: t, frame: e.frame, code: e.code }) + } + } + + expect(collectedEvents).toEqual([{ tick: 9, frame: 9, code: 1 }]) + expect(animator.finished).toBe(true) + expect(animator.frameIndex).toBe(15) // clamps to last frame (15) + }) + + it('dispatches multiple events in chronological order on fast ticks (speed > 256)', () => { + const animator = new ActorAnimator() + const clip: AnimClipMeta = { + group: 0, + frames: 10, + speed: 512, // 2 frames per tick + loop: false, + events: [ + { f: 2, c: 3 }, // sound + { f: 3, c: 1 }, // attack + ], + } + + animator.play('a1', clip) + + // Tick 1: advances frames 0 -> 1 -> 2 (triggers event at 2) + const events1 = animator.tick() + expect(events1).toEqual([{ frame: 2, code: 3 }]) + expect(animator.frameIndex).toBe(2) + + // Tick 2: advances frames 2 -> 3 -> 4 (triggers event at 3) + const events2 = animator.tick() + expect(events2).toEqual([{ frame: 3, code: 1 }]) + expect(animator.frameIndex).toBe(4) + }) + + it('stops at final frame and marks finished = true for one-shot clips', () => { + const animator = new ActorAnimator() + const clip: AnimClipMeta = { + group: 80, + frames: 5, + speed: 256, + loop: false, + events: [], + } + + animator.play('dt', clip) + for (let t = 1; t <= 4; t += 1) { + animator.tick() + expect(animator.finished).toBe(false) + } + + // On 5th tick, reaches frame 4 and marks finished + animator.tick() + expect(animator.frameIndex).toBe(4) + expect(animator.finished).toBe(true) + + // Further ticks remain at frame 4 and finished = true + animator.tick() + expect(animator.frameIndex).toBe(4) + expect(animator.finished).toBe(true) + }) + + it('marks single-frame one-shot clip (e.g. DD corpse) as finished immediately', () => { + const animator = new ActorAnimator() + const clip: AnimClipMeta = { + group: 96, + frames: 1, + speed: 256, + loop: false, + events: [], + } + + animator.play('dd', clip) + expect(animator.frameIndex).toBe(0) + expect(animator.finished).toBe(true) + + const events = animator.tick() + expect(events).toEqual([]) + expect(animator.frameIndex).toBe(0) + expect(animator.finished).toBe(true) + }) + + it('preserves playback state when re-requesting the same mode without restart flag', () => { + const animator = new ActorAnimator() + const clip: AnimClipMeta = { + group: 0, + frames: 8, + speed: 256, + loop: true, + events: [], + } + + animator.play('wl', clip) + animator.tick() + animator.tick() + animator.tick() + expect(animator.frameIndex).toBe(3) + + // Re-play same clip without restart -> no jitter / reset + animator.play('wl', clip) + expect(animator.frameIndex).toBe(3) + + // Re-play with restart -> resets to 0 + animator.play('wl', clip, { restart: true }) + expect(animator.frameIndex).toBe(0) + }) +})