diff --git a/docs/engine/README.md b/docs/engine/README.md index de9e895..1c60c89 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -9,6 +9,7 @@ |---|---| | [getting-started.md](getting-started.md) | Новая игра с нуля: bootstrap, сцены, ассеты | | [core.md](core.md) | Engine, GameLoop, EventBus, Tween, GameState, StateMachine, Settings | +| [anim.md](anim.md) | Клипы, SpriteAnimator, реестр тика engine.fx | | [render.md](render.md) | Renderer, pixel-perfect, камера, IsoDepthLayer, частицы | | [input.md](input.md) | Действия, клавиатура, мышь/тач, геймпад, виртуальный джойстик | | [maps.md](maps.md) | Изометрия, A*, формат карт, импорт из Tiled | diff --git a/docs/engine/anim.md b/docs/engine/anim.md new file mode 100644 index 0000000..fd35be0 --- /dev/null +++ b/docs/engine/anim.md @@ -0,0 +1,63 @@ +# Анимация: клипы, аниматоры, оживители, реестр тика + +Все анимации движка тикаются от фиксированного шага (60 Гц) — Pixi-тикер +не используется (`sharedTicker: false`), Pixi `AnimatedSprite` не применяется. +Чистая математика (клипы, оживители, симуляция частиц) живёт отдельно от +тонких Pixi-адаптеров и тестируется в Vitest без браузера. + +Модули: + +``` +anim/clip.ts чистый степпер клипов (loop/once/pingpong) +anim/SpriteAnimator.ts кадры → Sprite, именованные клипы, события +anim/FrameAnimation.ts @deprecated-обёртка совместимости над SpriteAnimator +``` + +## Реестр тика: `engine.fx` + +Эффекты, аниматоры и вспышки не требуют ручного `update(dt)` из сцены — +добавьте объект в `engine.fx` (`core/Updatables`), движок тикает его сам +между твин-ами и логикой сцены: + +```ts +engine.fx.add(emitter); // возвращает тот же объект +engine.fx.add(new SpriteMotion(view, { bob: {...} }, { seed: 7 })); +engine.fx.remove(fx); // редко: обычно достаточно destroyed +engine.fx.clear(); // при выходе из сцены, если она не разрушает дерево +``` + +Самозачистка: объект с `destroyed === true` вычищается из реестра после +очередного прохода (Pixi `destroy()` выставляет флаг сам — эффекты, +живущие в разрушаемом дереве мира, отпадают без кода). Допускается и +локальный инстанс `Updatables` внутри сцены (тот же класс, свой тик). + +## Клипы и SpriteAnimator + +```ts +import { SpriteAnimator } from '@rpg/engine'; + +const animator = new SpriteAnimator(sprite, { + walk_down: { frames: assets.frames('chars/hero_sheet.json', 'hero_down'), fps: 6 }, + idle: { frames: [assets.frames('chars/hero_sheet.json', 'hero_down')[0]], loop: 'once' } +}, 'walk_down', { randomPhase: seed }); // seed — рассинхрон толпы + +animator.play('walk_down', { restart: true }); // тот же клип без restart — продолжение +animator.pause(); // ... resume(), showStep(0) — заморозить на шаге +animator.onFrame = (step) => ...; // смена шага +animator.onFinish = (clip) => ...; // конец once/pingpong-прохода +animator.setClipFrames('walk_down', otherFrames); // кадры на лету (вариации окраса) +``` + +Режимы клипа (`ClipLoop`): `'loop'` — цикл; `'once'` — один раз и стоп +на последнем кадре (после конца аниматор сам паузится); `'pingpong'` — +туда-обратно без дубля крайних кадров. Математика — чистая `stepClip` +в `anim/clip.ts`: время накапливается в «шагах», dt больше периода +проматывает промежуточные шаги (сообщается только финальный) — по +аналогии с пересечением `delay` в Tween. + +`sprite.texture` пишется только при смене шага. Флип и anchor — зона +владельца спрайта (см. рецепт ходьбы в `recipes.md`). + +`FrameAnimation(sprite, frames, fps, loop)` — прежний API одного клипа, +оставлен как тонкая обёртка (`@deprecated`): `setFrames(f, reset)` +продолжает с текущего шага без reset и стартует с нуля с ним. \ No newline at end of file diff --git a/docs/engine/core.md b/docs/engine/core.md index 4f4ea11..a7024ae 100644 --- a/docs/engine/core.md +++ b/docs/engine/core.md @@ -55,6 +55,19 @@ Гарантии: значения не выходят за цель (зажим на последнем кадре), `onDone` вызывается один раз, тик, пересекающий конец `delay`, не «съедает» часть движения. +## Реестр эффектов: `engine.fx` + +Объекты, тикаемые движком сами между твин-ами и логикой сцены — эффекты, +аниматоры, вспышки (см. `anim.md`): + +```ts +engine.fx.add(emitter); // Updatable: { update(dt), destroyed? } +engine.fx.clear(); // при выходе из сцены, если она не разрушает дерево +``` + +Записи с `destroyed === true` (Pixi `destroy()` выставляет флаг сам) +вычищаются из реестра автоматически. + ## GameState Состояние прохождения: флаги и именованные переменные. Жанронезависимо — какие флаги diff --git a/packages/engine/src/anim/FrameAnimation.ts b/packages/engine/src/anim/FrameAnimation.ts index c0da8bf..a9e00c3 100644 --- a/packages/engine/src/anim/FrameAnimation.ts +++ b/packages/engine/src/anim/FrameAnimation.ts @@ -1,49 +1,30 @@ -import { Texture, Sprite } from 'pixi.js'; - /** - * Покадровая анимация на спрайте: список кадров + fps, зацикливание. - * Кадры можно менять на лету (повороты, состояния). + * @deprecated Тонкая обёртка совместимости над SpriteAnimator (один клип). + * Используйте SpriteAnimator: именованные клипы, pause/play, события, + * pingpong. Публичный API прежний — позиционный конструктор, setFrames, + * update; поведение сохранено байт-в-байт (loop=false → 'once'). */ +import type { Sprite, Texture } from 'pixi.js'; +import { SpriteAnimator } from './SpriteAnimator'; + export class FrameAnimation { - private frames: Texture[]; - private fps: number; - private loop: boolean; - private t = 0; - private index = 0; + private readonly animator: SpriteAnimator; + private clipName: string; - constructor( - private sprite: Sprite, - frames: Texture[], - fps = 8, - loop = true - ) { - this.frames = frames; - this.fps = fps; - this.loop = loop; + constructor(sprite: Sprite, frames: Texture[], fps = 8, loop = true) { + this.clipName = 'frames'; + this.animator = new SpriteAnimator( + sprite, + { [this.clipName]: { frames, fps, loop: loop ? 'loop' : 'once' } }, + 'frames' + ); } - setFrames(frames: Texture[], reset = false): void { - this.frames = frames; - if (reset) { - this.index = 0; - this.t = 0; - } + this.animator.setClipFrames(this.clipName, frames); + if (reset) this.animator.play(this.clipName, { restart: true }); } update(dt: number): void { - if (this.frames.length === 0) return; - this.t += dt * this.fps; - while (this.t >= 1) { - this.t -= 1; - if (this.index + 1 < this.frames.length) { - this.index++; - } else if (this.loop) { - this.index = 0; - } else { - this.t = 0; - break; - } - } - this.sprite.texture = this.frames[this.index] ?? this.sprite.texture; + this.animator.update(dt); } } \ No newline at end of file diff --git a/packages/engine/src/anim/SpriteAnimator.ts b/packages/engine/src/anim/SpriteAnimator.ts new file mode 100644 index 0000000..9ee9be1 --- /dev/null +++ b/packages/engine/src/anim/SpriteAnimator.ts @@ -0,0 +1,162 @@ +/** + * Кадровый аниматор спрайта с именованными клипами: 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; + private defaults: Required>; + 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, + 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); + } +} \ No newline at end of file diff --git a/packages/engine/src/anim/__tests__/FrameAnimation.test.ts b/packages/engine/src/anim/__tests__/FrameAnimation.test.ts new file mode 100644 index 0000000..85cefc7 --- /dev/null +++ b/packages/engine/src/anim/__tests__/FrameAnimation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { FrameAnimation } from '../FrameAnimation'; + +/** Подмена Sprite: аниматору нужен только writeable `texture`. */ +function fakeSprite() { + return { texture: 'initial' as unknown }; +} + +const t1 = { id: 1 } as never; +const t2 = { id: 2 } as never; +const t3 = { id: 3 } as never; + +describe('FrameAnimation (обёртка SpriteAnimator, совместимость)', () => { + it('цикл: кадры сменяются по fps, texture пишется', () => { + const sprite = fakeSprite(); + const anim = new FrameAnimation(sprite as never, [t1, t2, t3], 10, true); + expect(sprite.texture).toBe(t1); // стартовый кадр отрисован сразу + anim.update(0.1); + expect(sprite.texture).toBe(t2); + anim.update(0.1); + expect(sprite.texture).toBe(t3); + anim.update(0.1); + expect(sprite.texture).toBe(t1); // зациклилось + }); + + it('loop=false: стоп на последнем кадре', () => { + const sprite = fakeSprite(); + const anim = new FrameAnimation(sprite as never, [t1, t2], 10, false); + anim.update(0.1); + anim.update(0.1); + expect(sprite.texture).toBe(t2); + for (let i = 0; i < 5; i++) anim.update(0.1); + expect(sprite.texture).toBe(t2); // не уходит в минус индексы + }); + + it('setFrames без reset продолжает с текущего шага, с reset — с нуля', () => { + const sprite = fakeSprite(); + const anim = new FrameAnimation(sprite as never, [t1, t2], 10, true); + anim.update(0.1); // шаг 1 (t2) + anim.setFrames([t3, t1]); + expect(sprite.texture).toBe(t1); // шаг 1 нового клипа + anim.setFrames([t1, t2], true); + expect(sprite.texture).toBe(t1); // reset → шаг 0 + anim.update(0.1); + expect(sprite.texture).toBe(t2); + }); + + it('пустой список кадров не падает', () => { + const sprite = fakeSprite(); + const anim = new FrameAnimation(sprite as never, [], 10, true); + expect(() => anim.update(0.1)).not.toThrow(); + expect(sprite.texture).toBe('initial'); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/anim/__tests__/clip.test.ts b/packages/engine/src/anim/__tests__/clip.test.ts new file mode 100644 index 0000000..4183987 --- /dev/null +++ b/packages/engine/src/anim/__tests__/clip.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { stepClip, type ClipDef } from '../clip'; + +const loop: ClipDef = { fps: 10, loop: 'loop' }; +const once: ClipDef = { fps: 10, loop: 'once' }; +const ping: ClipDef = { fps: 10, loop: 'pingpong' }; + +describe('stepClip (время в шагах: fps=10 → 1 сек = 10 шагов)', () => { + it('loop: шаги идут по кругу, time не ограничено', () => { + let time = 0; + const steps: number[] = []; + for (let i = 0; i < 25; i++) { + const r = stepClip(4, loop, time, 0.1); + time = r.time; + steps.push(r.tick.step); + } + expect(steps).toEqual([1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1]); + expect(time).toBeCloseTo(25, 6); // время в шагах: 25 × (0.1 с × 10 fps) + }); + + it('loop: отрицательный dt ходит назад без ухода в минус', () => { + const r = stepClip(4, loop, 0, -0.1); + expect(r.tick.step).toBe(3); + }); + + it('once: стоп на последнем кадре, finished один раз', () => { + let time = 0; + const finished: boolean[] = []; + for (let i = 0; i < 5; i++) { + const r = stepClip(3, once, time, 0.1); + time = r.time; + finished.push(r.tick.finished); + } + // 3 кадра → конец после 2-го продвижения; дальше шаг держится на 2. + expect(finished).toEqual([false, true, false, false, false]); + let last: number | null = null; + for (let i = 0; i < 3; i++) last = stepClip(3, once, time, 0.1).tick.step; + expect(last).toBe(2); + }); + + it('pingpong: туда-обратно без дубля крайних кадров', () => { + let time = 0; + const steps: number[] = []; + for (let i = 0; i < 8; i++) { + const r = stepClip(3, ping, time, 0.1); + time = r.time; + steps.push(r.tick.step); + } + // Период 2*(3-1)=4: 0,1,2,1 | 0,1,2,1 + expect(steps).toEqual([1, 2, 1, 0, 1, 2, 1, 0]); + }); + + it('speedScale замедляет и ускоряет клип', () => { + // 0.1 сек при fps 10 = 1 шаг; с speedScale 0.5 — полшага. + expect(stepClip(4, { ...loop, speedScale: 0.5 }, 0, 0.1).tick.step).toBe(0); + expect(stepClip(4, { ...loop, speedScale: 2 }, 0, 0.1).tick.step).toBe(2); + }); + + it('dt больше периода: сообщается только финальный шаг', () => { + const r = stepClip(4, loop, 0, 10); // 10 сек = 100 шагов вперёд + expect(r.tick.step).toBe(0); // 100 % 4 = 0 + expect(r.tick.prevStep).toBe(0); + }); + + it('prevStep отражает шаг до продвижения', () => { + const r = stepClip(4, loop, 1.05, 0.1); + expect(r.tick.prevStep).toBe(1); + expect(r.tick.step).toBe(2); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/anim/clip.ts b/packages/engine/src/anim/clip.ts new file mode 100644 index 0000000..dba30c9 --- /dev/null +++ b/packages/engine/src/anim/clip.ts @@ -0,0 +1,60 @@ +/** + * Чистая математика покадровых клипов (без Pixi): время в «шагах», + * режимы loop/once/pingpong, события смены шага и завершения. + * Тикается от фиксированного шага движка; dt больше периода осознанно + * «проматывает» промежуточные шаги (сообщается только финальный) — + * по аналогии с пересечением границы delay в TweenManager. + */ + +/** Режим клипа: loop — цикл; once — один раз и стоп на последнем кадре; + * pingpong — цикл туда-обратно (крайние кадры без дубля). */ +export type ClipLoop = 'loop' | 'once' | 'pingpong'; + +export interface ClipDef { + /** Кадров в секунду. */ + fps: number; + loop?: ClipLoop; + /** Множитель скорости клипа (перемножается со speedScale аниматора). */ + speedScale?: number; +} + +export interface ClipTick { + /** Индекс шага после продвижения (для once не превышает len-1). */ + step: number; + /** Индекс шага до продвижения. */ + prevStep: number; + /** Клип дошёл до конца (once) — ровно один раз при пересечении конца. */ + finished: boolean; +} + +/** + * Продвинуть время клипа (сек) на dt и посчитать новый шаг. + * Время накапливается в шагах: time += dt * fps * speedScale. + */ +export function stepClip( + len: number, + def: ClipDef, + prevTime: number, + dt: number +): { time: number; tick: ClipTick } { + const scale = def.speedScale ?? 1; + const time = prevTime + dt * def.fps * scale; + const prevStep = stepAt(len, def.loop ?? 'loop', prevTime); + const step = stepAt(len, def.loop ?? 'loop', time); + // finished — только для once и только в тик, когда конец был пересечён. + const finished = def.loop !== 'loop' && time >= len - 1 && prevTime < len - 1; + return { time, tick: { step, prevStep, finished } }; +} + +/** Индекс шага в момент time (в шагах) для заданного режима. */ +function stepAt(len: number, loop: ClipLoop, time: number): number { + if (loop === 'once') return Math.min(Math.max(0, Math.floor(time)), len - 1); + if (loop === 'pingpong') { + // Период 2*(len-1): туда 0..len-1, обратно len-1..0; крайние кадры без дубля. + const period = Math.max(1, 2 * (len - 1)); + const m = ((Math.floor(time) % period) + period) % period; + return m < len ? m : period - m; + } + const m = ((Math.floor(time) % len) + len) % len; + return m; +} \ No newline at end of file diff --git a/packages/engine/src/core/Engine.ts b/packages/engine/src/core/Engine.ts index 593c4d6..3433205 100644 --- a/packages/engine/src/core/Engine.ts +++ b/packages/engine/src/core/Engine.ts @@ -5,6 +5,7 @@ import { GameLoop } from './GameLoop'; import { EventBus } from './EventBus'; import { TweenManager } from './Tween'; +import { Updatables } from './Updatables'; import { InputManager } from '../input/InputManager'; import { SceneManager } from '../scene/SceneManager'; @@ -26,6 +27,8 @@ readonly scenes = new SceneManager(); /** Твины и таймеры: обновляются каждым фиксированным шагом. */ readonly tweens = new TweenManager(); + /** Эффекты/аниматоры/флэши, тикаемые движком сами (фиксированный шаг). */ + readonly fx = new Updatables(); input!: InputManager; private loop: GameLoop; private started = false; @@ -85,6 +88,7 @@ this.loop.stop(); this.input?.dispose(); window.removeEventListener('resize', this.onWindowResize); + this.fx.clear(); this.events.clear(); } @@ -139,6 +143,7 @@ this.input?.update(); // опрос геймпадов до логики this.camera.update(dt); // тряска и прочее «живое» поведение камеры this.tweens.update(dt); + this.fx.update(dt); // эффекты/аниматоры поверх твин-движения, до логики сцены this.scenes.update(dt); this.input?.endTick(); } diff --git a/packages/engine/src/core/Updatables.ts b/packages/engine/src/core/Updatables.ts new file mode 100644 index 0000000..a34e5e4 --- /dev/null +++ b/packages/engine/src/core/Updatables.ts @@ -0,0 +1,45 @@ +/** + * Реестр объектов, тикаемых движком от фиксированного шага: эффекты, + * аниматоры, вспышки. Инстанс живёт в Engine (`engine.fx`) и тикается + * между tween-ами и сценами; допускается и локальный инстанс в сцене. + * + * Владение отсутствует: запись с `destroyed === true` (Pixi-объекты + * выставляют его при destroy) вычищается сама при очередном update. + */ + +/** Всё, что движок может тикать; destroyed — признак самозачистки. */ +export interface Updatable { + update(dt: number): void; + destroyed?: boolean; +} + +export class Updatables { + private list: Updatable[] = []; + + /** Добавить и вернуть тот же объект — удобно: `engine.fx.add(emitter)`. */ + add(u: Updatable): Updatable { + if (!this.list.includes(u)) this.list.push(u); + return u; + } + + remove(u: Updatable): void { + const i = this.list.indexOf(u); + if (i !== -1) this.list.splice(i, 1); + } + + clear(): void { + this.list.length = 0; + } + + /** Тик в порядке добавления; destroyed-записи вычищаются после прохода. */ + update(dt: number): void { + for (const u of this.list) u.update(dt); + if (this.list.some((u) => u.destroyed)) { + this.list = this.list.filter((u) => !u.destroyed); + } + } + + get count(): number { + return this.list.length; + } +} \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/Updatables.test.ts b/packages/engine/src/core/__tests__/Updatables.test.ts new file mode 100644 index 0000000..1d5edae --- /dev/null +++ b/packages/engine/src/core/__tests__/Updatables.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { Updatables, type Updatable } from '../Updatables'; + +/** Фейковый тикаемый: считает вызовы, может пометить себя destroyed. */ +function make(log: string[], name: string, destroyed = false): Updatable { + return { + destroyed, + update(dt: number) { + log.push(`${name}@${dt}`); + } + }; +} + +describe('Updatables', () => { + it('тикает в порядке добавления', () => { + const log: string[] = []; + const r = new Updatables(); + r.add(make(log, 'a')); + r.add(make(log, 'b')); + r.update(1 / 60); + expect(log).toEqual(['a@0.016666666666666666', 'b@0.016666666666666666']); + expect(r.count).toBe(2); + }); + + it('add возвращает тот же объект (chaining)', () => { + const u: Updatable = { update() {} }; + expect(new Updatables().add(u)).toBe(u); + }); + + it('add не дублирует и remove убирает', () => { + const r = new Updatables(); + const u: Updatable = { update() {} }; + r.add(u); + r.add(u); + expect(r.count).toBe(1); + r.remove(u); + expect(r.count).toBe(0); + r.remove(u); // отсутствие — не ошибка + }); + + it('destroyed-записи вычищаются после прохода, тик им всё же доставляется', () => { + const log: string[] = []; + const r = new Updatables(); + r.add(make(log, 'zombie', true)); + r.add(make(log, 'alive')); + r.update(0.1); + expect(log).toEqual(['zombie@0.1', 'alive@0.1']); // тик получен + expect(r.count).toBe(1); + r.update(0.1); + expect(log).toEqual(['zombie@0.1', 'alive@0.1', 'alive@0.1']); // zombie больше не тикается + }); + + it('clear опустошает реестр', () => { + const r = new Updatables(); + r.add({ update() {} }); + r.clear(); + expect(r.count).toBe(0); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 43e3074..df741f4 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -10,6 +10,7 @@ export { GameLoop } from './core/GameLoop'; export { EventBus } from './core/EventBus'; export { TweenManager, type TweenOptions, type TweenHandle } from './core/Tween'; +export { Updatables, type Updatable } from './core/Updatables'; export { linear, quadIn, @@ -126,6 +127,10 @@ // anim export { FrameAnimation } from './anim/FrameAnimation'; export { TintFlash, lerpColor } from './anim/flash'; +export { SpriteAnimator } from './anim/SpriteAnimator'; +export type { SpriteClip, SpriteAnimatorOptions } from './anim/SpriteAnimator'; +export { stepClip } from './anim/clip'; +export type { ClipLoop, ClipDef, ClipTick } from './anim/clip'; // dialogue export {