diablo2-web/src/game/animation.ts

124 lines
4.0 KiB
TypeScript

/**
* Actor animation: direction-indexed frame playback at the simulation rate.
*
* Both games drive animation off the same 25 Hz tick the simulation runs on, so
* playback is counted in ticks rather than milliseconds — an animation that
* looked right at 60 fps would run at the wrong speed and, worse, at a
* different speed on different machines.
*
* The module is deliberately format-agnostic: a clip is just "frames per
* direction plus a tick rate". Diablo I's direction-major CL2 sheets, Diablo II's
* per-direction DC6 sheets and its composite DCC animations all reduce to that,
* and Diablo's own convention is preserved in the direction *index* (0 = south,
* turning west), which is also the group order inside those files.
*/
/** One playable animation: frames per direction, plus timing. */
export interface ActorClip {
/** Clip name (`walk`, `stand`, `attack`, …). */
readonly name: string
/** Frames per direction in play order; index 0 is south. */
readonly directions: readonly (readonly unknown[])[]
/** Ticks between frames (25 ticks per second). */
readonly ticksPerFrame: number
/** Whether the clip advances past its last frame. */
readonly loop: boolean
}
/** Convenience alias so callers can stay generic over the frame type. */
export type ActorAnimatorFrame<TFrame> = TFrame | undefined
/**
* Plays one clip at a time, for one direction at a time.
*
* @typeParam TFrame - the frame handle the renderer draws (an atlas placement in
* practice; kept generic here so the animator has no rendering dependency).
*/
export class ActorAnimator<TFrame> {
private readonly clips = new Map<string, ActorClip>()
private current: ActorClip | null = null
private direction = 0
private elapsed = 0
private index = 0
/**
* Register a clip. A later registration under the same name replaces it.
*
* @param clip - the clip to register.
*/
add(clip: ActorClip): void {
this.clips.set(clip.name, clip)
}
/** The clip currently playing, if any. */
get clipName(): string | null {
return this.current?.name ?? null
}
/** Current facing (0 = south, turning west). */
get facing(): number {
return this.direction
}
/** Index of the frame being shown. */
get frameIndex(): number {
return this.index
}
/**
* Switch clips, restarting only when the clip actually changes.
*
* A walk cycle that restarts every time the key is re-pressed stutters, so
* re-requesting the current clip is a no-op — the same rule the games use.
*
* @param name - clip name; unknown names are ignored.
* @param direction - facing to play it in.
*/
play(name: string, direction: number): void {
const clip = this.clips.get(name)
if (clip === undefined) return
this.direction = direction
if (this.current === clip) return
this.current = clip
this.elapsed = 0
this.index = 0
}
/**
* Advance one simulation tick.
*
* @param direction - facing to keep playing in.
*/
tick(direction: number): void {
this.direction = direction
const clip = this.current
if (clip === null) return
const frames = clip.directions[direction]
const count = frames?.length ?? 0
if (count <= 1 || clip.ticksPerFrame <= 0) return
this.elapsed += 1
if (this.elapsed < clip.ticksPerFrame) return
this.elapsed = 0
if (this.index + 1 < count) {
this.index += 1
return
}
this.index = clip.loop ? 0 : count - 1
}
/**
* The frame to draw now.
*
* @returns the frame handle, or undefined when nothing is playing — including
* when the current direction has no frames (a sheet with fewer directions than
* the actor was asked for).
*/
frame(): ActorAnimatorFrame<TFrame> {
const clip = this.current
if (clip === null) return undefined
const frames = clip.directions[this.direction]
if (frames === undefined || frames.length === 0) return undefined
return frames[Math.min(this.index, frames.length - 1)] as TFrame
}
}