import { EventEmitter, EventKeys, MathUtils } from "zogra-renderer"; export interface Keyframe { time: number, values: T, } interface AnimationFrameState { time: number, frameTime: number, deltaTime: number, progress: number, frame: Frame, target?: Target, animator: AnimationPlayback, } interface SimpleTimeline { loop?: boolean, duration: number, frames: { [key: number]: Frame }, updater?: SimpleUpdater, } type SimpleUpdater = (frame: Frame, target: Target) => void; export interface Timeline { loop: boolean, duration: number, frames: Keyframe[], updater?: SimpleUpdater, } export function Timeline(timeline: SimpleTimeline): Timeline { const times = Object.keys(timeline.frames).map(t => ({ key: t, time: parseFloat(t) })).sort((a, b) => a.time - b.time); const output: Timeline = { loop: timeline.loop || false, duration: timeline.duration, frames: [], updater: timeline.updater, }; for (const time of times) { output.frames.push({ time: time.time, values: timeline.frames[time.key as unknown as number], }); } return output; } type AnimationCallback = (frame: AnimationFrameState) => void; export interface IPlayback { finished: boolean; play(): Promise; stop(): void; update(dt: number): void; reject(): void; } export class AnimationPlayback implements IPlayback> { frameTime: number = 0; time: number = 0; timeScale = 1; target: Target | undefined = undefined; updater: AnimationCallback | undefined = undefined; timeline: Timeline; loop: boolean; state: "pending" | "playing" | "stopped" = "stopped"; currentFrame: Frame = {} as any; duration: number; private resolver?: (t: this) => void; private rejector?: () => void; constructor(timeline: Timeline, target?: Target, updater?: AnimationCallback) { this.frameTime = 0; this.timeline = timeline; this.loop = timeline.loop; this.duration = timeline.duration; this.target = target; this.updater = updater; if (!this.updater && target && timeline.updater) { this.updater = (frame) => { (timeline.updater as SimpleUpdater)(frame.frame, target); } } } get playing() { return this.state === "playing" || this.state === "pending" } get finished() { return this.state === "stopped" } play(time = 0): Promise { return new Promise((resolve, reject) => { this.resolver = resolve; this.rejector = reject; this.frameTime = time; this.frameTime = time; this.state = "pending"; if (this.timeline && this.timeline.frames.length > 0) Object.assign(this.currentFrame, this.timeline.frames[0].values); }) } stop() { this.state = "stopped"; } update(dt: number) { switch (this.state) { case "stopped": return; case "pending": this.state = "playing"; this.checkEnd(); this.updateAnimation(dt); break; case "playing": this.time += dt * this.timeScale; this.checkEnd(); this.updateAnimation(dt); break; } } reject() { this.rejector?.(); } private updateAnimation(dt: number) { if (!this.updater) return; if (this.loop) this.frameTime = this.time % this.timeline.duration; else this.frameTime = this.time; this.updateFrame(); this.updater({ deltaTime: dt, frame: this.currentFrame, animator: this, target: this.target, time: this.time, frameTime: this.frameTime, progress: this.frameTime / this.duration }); } private updateFrame() { if (this.timeline && this.timeline.frames.length > 0) { for (let i = 0; i < this.timeline.frames.length; i++) { if (this.timeline.frames[i].time >= this.frameTime) { if (i === 0 || this.timeline.frames[i].time === this.frameTime) Object.assign(this.currentFrame, this.timeline.frames[i].values); else { this.interpolate(this.currentFrame, this.timeline.frames[i - 1], this.timeline.frames[i]); } return this.currentFrame; } } if (this.loop) { this.interpolate(this.currentFrame, this.timeline.frames[this.timeline.frames.length - 1], this.timeline.frames[0]); } else { Object.assign(this.currentFrame, this.timeline.frames[this.timeline.frames.length - 1].values); } } } private interpolate(frame: Frame, previous: Keyframe, next: Keyframe) { let t = (this.frameTime - previous.time) / (next.time - previous.time); if (next.time < previous.time) t = (this.frameTime - previous.time) / (this.timeline.duration + next.time - previous.time); for (const key in previous.values) { frame[key] = previous.values[key]; if (typeof (previous.values[key]) === "number" && typeof (next.values[key]) === "number") { frame[key] = MathUtils.lerp(previous.values[key] as any, next.values[key] as any, t) as any; } } return frame; } private checkEnd() { if (this.time >= this.duration) { this.time = this.duration; this.state = "stopped"; this.resolver?.(this); } } } class ProceduralPlayback implements IPlayback { currentTime: number = 0 totalTime: number; state: "pending" | "playing" | "stopped" = "stopped"; resolver?: () => void; rejector?: () => void; updater?: (t: number, dt: number) => void; constructor(time: number, updater?: (t: number, dt: number) => void) { this.totalTime = time; this.updater = updater; } get finished() { return this.state === "stopped" } play(): Promise { return new Promise((resolve, reject) => { this.rejector = reject; this.resolver = resolve; if (this.state === "stopped") this.state = "pending"; }); } stop(): void { this.resolver = undefined; this.state = "stopped"; } update(dt: number): void { switch (this.state) { case "stopped": return; case "pending": this.state = "playing"; case "playing": this.currentTime += dt; this.checkEnd(); this.updater?.(this.currentTime / this.totalTime, dt); break; } } reject() { this.rejector?.(); } private checkEnd() { if (this.currentTime >= this.totalTime) { this.currentTime = this.totalTime; this.state = "stopped"; this.resolver?.(); } } } interface AnimatorEvent { start(timeline: AnimationPlayback, time: number): void, finish(timeline: AnimationPlayback): void, } export interface AnimationPlaybackOptions { updater: AnimationCallback, playDuration: number, loop: boolean, startTime: number, } export class Animator { defaultTarget: AnimatorTarget | undefined; tracks: Array | undefined> = []; constructor(target?: AnimatorTarget) { this.defaultTarget = target; } playOn( track: number, timeline: Timeline, target: Target = this.defaultTarget as unknown as Target, duration: number = timeline.duration, updater?: AnimationCallback ): Promise> { const playback = new AnimationPlayback(timeline, target, updater); playback.duration = duration; const promise = playback.play(); if (this.tracks[track]) (this.tracks[track] as IPlayback).reject(); this.tracks[track] = playback; return promise; } play( timeline: Timeline, target: Target = this.defaultTarget as unknown as Target, duration: number = timeline.duration, updater?: AnimationCallback ): Promise> { return this.playOn(this.tracks.length, timeline, target, duration, updater); } playProceduralOn(track: number, time: number, updater?: (t: number, dt: number) => void, startTime = 0): Promise { const playback = new ProceduralPlayback(time, updater); playback.currentTime = startTime; const promise = playback.play(); if (this.tracks[track]) (this.tracks[track] as IPlayback).reject(); this.tracks[track] = playback; return promise; } playProcedural(time: number, updater?: (t: number, dt: number)=>void, startTime = 0): Promise { return this.playProceduralOn(this.tracks.length, time, updater); } wait(time: number, callback: () => void) { const playback = new ProceduralPlayback(time); const promise = playback.play(); this.tracks.push(playback); promise.then(callback); } update(dt: number) { for (let i = 0; i < this.tracks.length; i++) { const playback = this.tracks[i]; if (!playback) continue; playback.update(dt); if (playback.finished) { this.tracks[i] = undefined; } } } clear() { for (const track of this.tracks) track?.reject(); this.tracks.length = 0; } }