124 lines
4.0 KiB
TypeScript
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
|
|
}
|
|
}
|