/**
* Кадровый аниматор спрайта с именованными клипами: play/pause, режимы
* loop/once/pingpong, события onFrame/onFinish, рассинхрон толпы через
* randomPhase (seed). Тонкий Pixi-адаптер: математика — в anim/clip.ts,
* текстура пишется только при смене шага. Тикается вручную либо через
* engine.fx (реестр Updatables).
*/
import type { Sprite, Texture } from 'pixi.js';
import type { Updatable } from '../core/Updatables';
import { stepClip, type ClipLoop } from './clip';
export interface SpriteClip {
frames: readonly Texture[];
/** Кадров в секунду; без значения — дефолт аниматора. */
fps?: number;
loop?: ClipLoop;
speedScale?: number;
}
export interface SpriteAnimatorOptions {
/** Дефолт fps для клипов без своего (8, как у FrameAnimation). */
fps?: number;
/** Дефолтный режим для клипов без своего. */
loop?: ClipLoop;
speedScale?: number;
/** Seed: детерминированная случайная фаза старта (рассинхрон толпы). */
randomPhase?: number;
/** Шаг, который показывать до первого play(). */
startStep?: number;
}
export class SpriteAnimator implements Updatable {
readonly sprite: Sprite;
/** Вызывается при смене шага. */
onFrame?: (step: number) => void;
/** Вызывается один раз при finished у once/pingpong-прохода. */
onFinish?: (clip: string) => void;
private clips: Record<string, SpriteClip>;
private defaults: Required<Pick<SpriteAnimatorOptions, 'fps' | 'loop'>>;
private speedScale_: number;
private currentName: string | null = null;
private time = 0;
private step_ = 0;
private paused_ = false;
private finishedNotified = false;
/** Случайная фаза старта в долях шага [0,1) — из seed (без Math.random). */
private phase = 0;
constructor(
sprite: Sprite,
clips: Record<string, SpriteClip>,
current?: string,
opts: SpriteAnimatorOptions = {}
) {
this.sprite = sprite;
this.clips = clips;
this.defaults = { fps: opts.fps ?? 8, loop: opts.loop ?? 'loop' };
this.speedScale_ = opts.speedScale ?? 1;
if (opts.randomPhase !== undefined) this.applyRandomPhase(opts.randomPhase);
if (current !== undefined) {
this.play(current, { restart: true });
} else {
// Стартовый шаг первого клипа — просто отрисовать, не запуская время.
const first = Object.keys(clips)[0];
if (first !== undefined) {
this.currentName = first;
this.showStep(opts.startStep ?? 0);
}
}
}
/** Включить клип; тот же клип без restart продолжается с текущего шага. */
play(name: string, opts: { restart?: boolean } = {}): void {
const clip = this.clips[name];
if (!clip) throw new Error(`клип «${name}» не найден`);
if (name !== this.currentName || opts.restart) {
this.currentName = name;
this.time = this.phase;
this.finishedNotified = false;
this.setStep(this.currentName, 0);
}
}
pause(): void {
this.paused_ = true;
}
resume(): void {
this.paused_ = false;
}
get paused(): boolean {
return this.paused_;
}
get clip(): string | null {
return this.currentName;
}
get step(): number {
return this.step_;
}
get speedScale(): number {
return this.speedScale_;
}
set speedScale(v: number) {
this.speedScale_ = v;
}
/** Заморозить на конкретном шаге текущего клипа (idle-поза без клипа). */
showStep(step: number): void {
this.paused_ = true;
this.setStep(this.currentName, step);
}
/** Заменить кадры клипа на лету (пересборка атласа, вариации окраса). */
setClipFrames(name: string, frames: readonly Texture[]): void {
const clip = this.clips[name];
if (!clip) throw new Error(`клип «${name}» не найден`);
clip.frames = frames;
if (name === this.currentName) this.setStep(name, Math.min(this.step_, Math.max(0, frames.length - 1)));
}
update(dt: number): void {
if (this.paused_ || this.currentName === null) return;
const name = this.currentName;
const clip = this.clips[name];
if (clip.frames.length === 0) return;
const def = { fps: clip.fps ?? this.defaults.fps, loop: clip.loop ?? this.defaults.loop, speedScale: clip.speedScale };
const { time, tick } = stepClip(clip.frames.length, def, this.time, dt * this.speedScale_);
this.time = time;
if (tick.step !== this.step_) this.setStep(name, tick.step);
if (tick.finished && !this.finishedNotified) {
this.finishedNotified = true;
this.onFinish?.(name);
}
// pingpong/once с конечным временем: после конца время не двигается.
if (tick.finished && def.loop === 'once') this.paused_ = true;
}
/** Случайная фаза из seed: доля периода клипа, детерминированная. */
private applyRandomPhase(seed: number): void {
// xorshift-простое: [0,1) из целого seed без Math.random.
let s = seed | 0 || 0x9e3779b9;
s ^= s << 13;
s ^= s >>> 17;
s ^= s << 5;
this.phase = ((s >>> 0) % 1000) / 1000;
}
private setStep(name: string | null, step: number): void {
if (name === null) return;
const frames = this.clips[name].frames;
this.step_ = step;
const tex = frames[step];
if (tex && this.sprite.texture !== tex) this.sprite.texture = tex;
this.onFrame?.(step);
}
}