diff --git a/docs/engine/core.md b/docs/engine/core.md index d4f4f83..f185d81 100644 --- a/docs/engine/core.md +++ b/docs/engine/core.md @@ -81,6 +81,13 @@ тикаются и вычищаются из реестра автоматически — разрушенный Pixi-объект касаться нельзя (transform обнулён). +Для one-shot визуальных эффектов предпочтителен **`FxLayer`** (`render/fx/`, +см. `render.md`): слой добавляется в `engine.fx` один раз, сам тикает +и вычищает всё, что спавнит (`fx.ring/floatText/fade/pop/flash/burst/trail`). +Прямое `engine.fx.add(emitter)` оставлено для движковых эффектов вне сцены. +`SpriteFlash` теперь имеет `destroyed` (следует за спрайтом) — его можно +писать в реестр напрямую; повторный `start()` продолжает работать. + ## GameState Состояние прохождения: флаги и именованные переменные. Жанронезависимо — какие флаги diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 44dfbbe..1602cb6 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -378,6 +378,22 @@ слот, последний побеждает. В сценах не забыть `maxLights: MAX_LIGHTS` — иначе движковый пул (16) молча теряет источники сверх себя. +## Ситуация: добавляю визуальный эффект + +1. **One-shot эффект — через `fx.()` слоя `render/fx/`, не руками**: + кольцо (`ring`), всплывающий текст (`floatText`), растворение (`fade`), + pop-появление (`pop`), хит-флэш (`flash`), разовые частицы (`burst` + + пресеты `spark/smoke/ember/dust`), шлейф (`trail`). Слой добавляется в + сцену один раз (`engine.fx.add(fx)`) и сам тикает/вычищает всё, что спавнит. +2. **Никогда не добавлять эмиттер в мир без реестра**: `world.addChild(emitter)` + без `engine.fx.add` (или без слоя) = «эффект висит статичной графикой и + не уничтожается» — класс бага, который слой чинит сам по себе. +3. Нужен свой тип эффекта — сэмпл-математика в `fxSim.ts` (headless-тест), + тонкий адаптер `Fx* implements Updatable` с `cancel()`/`done`/`destroyed`, + метод-фабрика на `FxLayer`. Хелперы ≤70 строк. +4. Эффекты по группе: слой на группу (мир / UI) — `clear()` гасит только свой + слой; зацикленные телеграфы (`repeat: Infinity`) гасит сцена через `cancel()`. + ## Грабли среды (кратко, подробности в CLAUDE.md) - **Раскладка тулз по правилу «знает ли контент игры»**: знает (палитра, diff --git a/docs/engine/recipes.md b/docs/engine/recipes.md index e673f43..3b82816 100644 --- a/docs/engine/recipes.md +++ b/docs/engine/recipes.md @@ -244,6 +244,59 @@ engine.fx.add(boom); // тик и самоуничтожение — сами ``` +## Кольцо / волна / телеграф зоны + +```ts +const fx = new FxLayer(); +worldRoot.addChild(fx); +engine.fx.add(fx); + +// расходящееся кольцо резонанса (мировая точка — через worldToScreen) +fx.ring({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }, worldToScreen(wx, wy)); + +// телеграф опасной зоны: зацикленный пульс ромба с заливкой +const zone = fx.ring({ + color: 0xd05a5a, from: 20, to: 40, duration: 0.6, gap: 0.2, repeat: Infinity, + shape: 'diamond', fill: 0xd05a5a +}, at); +// когда зона перестала быть опасной (или сцена кончается): zone.cancel() +``` + +## Всплывающий текст (урон / тост / подбор) + +```ts +// число урона улетает вбок и вверх +fx.floatText('-2', worldToScreen(wx, wy), { duration: 0.8, rise: 12, driftX: 10 }); + +// тост поверх UI: держим, пока читается (readSeconds), потом растворяем вверх +uiFx.clear(); // предыдущий тост не должен наслаиваться +uiFx.floatText(text, { x: 240, y: 40 }, { + hold: readSeconds(text), fadeIn: 0.25, fadeOut: 0.5, + style: { size: 11, color: 0xd8c79a } +}); +``` + +## Растворение вьюхи и pop-появление + +```ts +// подбор предмета: вьюха тает и удаляется сама (защита от двойного destroy внутри) +fx.fade(view, { duration: 0.5, onDone: () => sink.release() }); + +// проявление до 40% (призрак) +fx.fade(view, { duration: 0.3, to: 0.4, keep: true }); + +// появление с пружинкой (подбор, спавн, UI-подтверждение) +fx.pop(view, { duration: 0.25, fromScale: 0.5, overshoot: 1.12 }); +``` + +## Шлейф за снарядом + +```ts +const trail = fx.trail(projectileView, { color: 0x86868f, rate: 30, lifetime: [0.2, 0.4] }); +// каждый тик FxLayer сам тянет эмиттер за вьюхой; когда снаряд погас: +trail.stop(); // хвост самораспадается и уничтожается +``` + ## Меню с клавиатурой и мышью Смотри `apps/game/src/scenes/MenuScene.ts`: MenuList + `advance`/`up`/`down` действия. diff --git a/docs/engine/render.md b/docs/engine/render.md index 8663c6c..bab57a6 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -263,6 +263,51 @@ Частицы — display-layer: все внутренние величины эмиттера — экранные px относительно его origin; в мире эмиттер позиционируется через `worldToScreen`. +## Визуальные эффекты: `render/fx/` + +Слой one-shot эффектов и предзаготовленные пресеты. `FxLayer` — контейнер, +реализующий `Updatable`: добавляется в сцену **один раз** (`engine.fx.add(layer)`), +сам тикает всё, что спавнит, и сам вычищает закончившиеся. Не нужно помнить про +`engine.fx.add` на каждый эффект — типичный источник багов «эффект висит +статичной графикой». + +```ts +import { FxLayer, sparkPreset } from '@rpg/engine'; + +const fx = new FxLayer(); +worldRoot.addChild(fx); // или uiRoot для тостов +engine.fx.add(fx); // один раз на сцену; в exit() — fx.destroy() + +fx.ring({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }, at); // кольцо/волна +fx.floatText('+2', at, { rise: 10 }); // всплывающий текст +fx.fade(view, { duration: 0.5 }); // растворение + destroy +fx.pop(view, { fromScale: 0.5 }); // появление с овершутом +fx.flash(sprite, 0xf2b45a, 0.15); // хит-флэш (1 на спрайт) +fx.burst(10, at, sparkPreset(0xf2b45a, { seed })); // разовые частицы +fx.trail(projectile, { color: 0x86868f, rate: 30 }); // шлейф за вьюхой +``` + +Правила: + +- **Позиции — локальные px контейнера, куда добавлен слой.** Слой жанронезависим: + изометрию знает сцена (мировые эффекты — через `worldToScreen`/`camera.toScreen`, + как у Lighting). Для depth-сортировки передайте `{ parent: depthLayer }` и + сами вызовите `addFx(view, tx, ty)`. +- **Пресет владеет своей вьюхой**: создаёт, тикает и уничтожает сам; вызывающий + получает хэндл с `cancel()`. Повторный `flash()` по тому же спрайту перезапускает + вспышку (не создаёт новую). +- **`clear()` гасит всё активное** (смена тоста, exit сцены). Если нужны независимые + группы эффектов — заведите по слою на группу (мир / UI). +- **Зацикленные телеграфы зон** — `ring` с `repeat: Infinity` (опц. `shape: 'diamond'`, + `fill`): гасятся только `cancel()` или `clear()` — сцена обязана погасить, когда + зона перестала быть актуальной. +- Чистая математика сэмплов — `fxSim.ts` (`sampleRing`, `sampleFloat`, `readSeconds`, + `sampleFade`, `samplePop`), тестируется headless; адаптеры только применяют сэмплы. + Фабрики частиц — `sparkPreset`/`smokePreset`/`emberPreset`/`dustPreset` (поверх + `mergeEmitterOptions`). +- Тик слоя идёт внутри `engine.fx` — **до** `scenes.update`: позиция, снятая из мира, + отстаёт на 1 тик (тот же лаг у `SpriteMotion`/`Lighting`). + ## Порядок отрисовки Стек сцен рисуется снизу вверх; внутри сцены порядок задают дочерние контейнеры diff --git a/packages/engine/src/core/Updatables.ts b/packages/engine/src/core/Updatables.ts index efee0e4..725e4a1 100644 --- a/packages/engine/src/core/Updatables.ts +++ b/packages/engine/src/core/Updatables.ts @@ -31,6 +31,11 @@ this.list.length = 0; } + /** Обойти живые записи (например, чтобы погасить все эффекты слоя). */ + forEach(fn: (u: Updatable) => void): void { + for (const u of this.list) fn(u); + } + /** * Тик в порядке добавления; разрушенные записи не тикаются (Pixi после * destroy обнуляет transform — касаться их нельзя) и вычищаются после прохода. diff --git a/packages/engine/src/core/easing.ts b/packages/engine/src/core/easing.ts index db2113f..782896c 100644 --- a/packages/engine/src/core/easing.ts +++ b/packages/engine/src/core/easing.ts @@ -14,4 +14,11 @@ export const cubicOut: EaseFn = (t) => 1 - Math.pow(1 - t, 3); -export const sineInOut: EaseFn = (t) => -(Math.cos(Math.PI * t) - 1) / 2; \ No newline at end of file +export const sineInOut: EaseFn = (t) => -(Math.cos(Math.PI * t) - 1) / 2; + +/** Затухающий овершут: 0 → ~1.1 → 1 («появление с пружинкой», pop-эффекты). */ +export const backOut: EaseFn = (t) => { + const s = 1.70158; + const u = t - 1; + return 1 + (s + 1) * u * u * u + s * u * u; +}; \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index ce8967d..2f7714c 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -19,6 +19,7 @@ cubicIn, cubicOut, sineInOut, + backOut, type EaseFn } from './core/easing'; @@ -141,6 +142,29 @@ } from './render/Lighting'; export { Shake } from './render/shake'; export { SpriteFlash } from './render/spriteFx'; + +// fx (слой one-shot эффектов и пресеты) +export { FxLayer, type FxLayerOptions } from './render/fx/FxLayer'; +export { FxRing } from './render/fx/FxRing'; +export { FxFloatText } from './render/fx/FxFloatText'; +export { FxFade, type FxFadeOptions } from './render/fx/FxFade'; +export { FxPop } from './render/fx/FxPop'; +export { FxTrail } from './render/fx/FxTrail'; +export { sparkPreset, smokePreset, emberPreset, dustPreset } from './render/fx/particlePresets'; +export { + sampleRing, + ringDuration, + sampleFloat, + readSeconds, + sampleFade, + samplePop, + type FxRingSpec, + type FxRingSample, + type FxFloatSpec, + type FxFloatSample, + type FxFadeSpec, + type FxPopSpec +} from './render/fx/fxSim'; export { GLOW_BASE_PX, flickerFactor, diff --git a/packages/engine/src/render/__tests__/spriteFx.test.ts b/packages/engine/src/render/__tests__/spriteFx.test.ts new file mode 100644 index 0000000..1119744 --- /dev/null +++ b/packages/engine/src/render/__tests__/spriteFx.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { Sprite, Texture } from 'pixi.js'; +import { SpriteFlash } from '../spriteFx'; + +describe('SpriteFlash', () => { + it('вспышка перекрашивает спрайт и возвращает базовый tint', () => { + const sprite = new Sprite(Texture.WHITE); + const flash = new SpriteFlash(sprite, 0xff0000, 0.15); + flash.start(); + flash.update(1 / 60); + expect(sprite.tint).not.toBe(0xffffff); + for (let i = 0; i < 15; i++) flash.update(1 / 60); // 0.15+ с — доиграл + expect(sprite.tint).toBe(0xffffff); + }); + + it('destroyed следует за разрушением спрайта (самозачистка из реестра)', () => { + const sprite = new Sprite(Texture.WHITE); + const flash = new SpriteFlash(sprite, 0xff0000); + expect(flash.destroyed).toBe(false); + flash.update(1 / 60); // неактивен — no-op + sprite.destroy(); + expect(flash.destroyed).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxFade.ts b/packages/engine/src/render/fx/FxFade.ts new file mode 100644 index 0000000..181a146 --- /dev/null +++ b/packages/engine/src/render/fx/FxFade.ts @@ -0,0 +1,57 @@ +import { Container } from 'pixi.js'; +import type { Updatable } from '../../core/Updatables'; +import { sampleFade, type FxFadeSpec } from './fxSim'; + +/** Опции растворения: математика (FxFadeSpec) + жизненный цикл вьюхи. */ +export interface FxFadeOptions extends FxFadeSpec { + /** true — не уничтожать вьюху в конце (только альфа). */ + keep?: boolean; + /** Колбэк завершения (в т.ч. после destroy вьюхи). */ + onDone?: () => void; +} + +/** + * Растворение/проявление произвольной вьюхи + авто-удаление. Не display-объект: + * тикается в реестре, применяет альфу к чужой вьюхе. В конце removeChild + + * destroy({children: true}) — с защитой от двойного destroy (вьюху могли + * уничтожить извне, пока эффект играл). + */ +export class FxFade implements Updatable { + private t = 0; + private finished = false; + private readonly from: number; + + constructor( + private readonly view: Container, + private readonly opts: FxFadeOptions + ) { + this.from = view.alpha; + } + + /** Прекратить эффект и вернуть базовую альфу (вьюху не трогаем дальше). */ + cancel(): void { + this.finished = true; + if (!this.view.destroyed) this.view.alpha = this.from; + } + + /** Вьюха разрушена извне или эффект закончился — запись вычистится сама. */ + get destroyed(): boolean { + return this.finished || this.view.destroyed; + } + + update(dt: number): void { + if (this.finished) return; + if (this.view.destroyed) return; // реестр вычистит по destroyed + this.t += dt; + this.view.alpha = sampleFade(this.t, this.from, this.opts); + const total = (this.opts.delay ?? 0) + this.opts.duration; + if (this.t >= total - 1e-6) { // эпсилон: накопление 1/60 не дотягивает до границы + this.finished = true; + if (!this.opts.keep) { + this.view.parent?.removeChild(this.view); + this.view.destroy({ children: true }); + } + this.opts.onDone?.(); + } + } +} \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxFloatText.ts b/packages/engine/src/render/fx/FxFloatText.ts new file mode 100644 index 0000000..409071b --- /dev/null +++ b/packages/engine/src/render/fx/FxFloatText.ts @@ -0,0 +1,52 @@ +import type { Updatable } from '../../core/Updatables'; +import type { Vec2 } from '../../math/Vec2'; +import { PixelText, type PixelTextOptions } from '../../ui/PixelText'; +import { sampleFloat, type FxFloatSpec } from './fxSim'; + +/** + * Всплывающий текст: подъём + затухание (числа урона, тосты, подборы). + * Владеет своим PixelText: создаёт, тикает и уничтожает себя по завершении. + * Позиция задаётся при создании (at), update только смещает от неё. + */ +export class FxFloatText extends PixelText implements Updatable { + private t = 0; + private cancelled = false; + private readonly baseX: number; + private readonly baseY: number; + + constructor( + text: string, + at: Vec2, + private readonly spec: FxFloatSpec, + style?: Partial + ) { + super({ text, size: 11, ...style }); + this.anchor.set(0.5); + this.baseX = at.x; + this.baseY = at.y; + this.position.set(at.x, at.y); + } + + /** Досрочно погасить (например, новый тост вытесняет старый). */ + cancel(): void { + if (this.cancelled) return; + this.cancelled = true; + this.destroy({ children: true }); + } + + /** true, когда текст доиграл (или погашен). */ + get done(): boolean { + return this.cancelled || this.t >= this.spec.duration - 1e-6; + } + + update(dt: number): void { + if (this.destroyed) return; + this.t += dt; + const s = sampleFloat(this.t, this.spec); + this.x = this.baseX + s.x; + this.y = this.baseY + s.y; + this.alpha = s.alpha; + this.scale.set(s.scale); + if (this.done) this.destroy({ children: true }); + } +} \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxLayer.ts b/packages/engine/src/render/fx/FxLayer.ts new file mode 100644 index 0000000..54137f6 --- /dev/null +++ b/packages/engine/src/render/fx/FxLayer.ts @@ -0,0 +1,133 @@ +import { Container, Sprite } from 'pixi.js'; +import { Updatables, type Updatable } from '../../core/Updatables'; +import type { Vec2 } from '../../math/Vec2'; +import type { PixelTextOptions } from '../../ui/PixelText'; +import { ParticleEmitter, type EmitterOptions } from '../Particles'; +import { SpriteFlash } from '../spriteFx'; +import { FxRing } from './FxRing'; +import { FxFloatText } from './FxFloatText'; +import { FxFade, type FxFadeOptions } from './FxFade'; +import { FxPop } from './FxPop'; +import { FxTrail } from './FxTrail'; +import type { FxRingSpec, FxFloatSpec, FxPopSpec } from './fxSim'; + +/** Опции слоя. */ +export interface FxLayerOptions { + /** Seed по умолчанию для разовых частиц (burst), если opts не передал свой. */ + seed?: number; +} + +/** Опции floatText: математика + стиль текста + родитель (для depth-слоёв). */ +export interface FxLayerFloatOptions extends Partial { + style?: Partial; + parent?: Container; +} + +/** Родитель вьюхи эффекта вместо самого слоя (для IsoDepthLayer.addFx). */ +export interface FxParentOptions { + parent?: Container; +} + +/** + * Слой визуальных эффектов: сам тикается (реализует Updatable — добавить один + * раз в engine.fx), спавнит one-shot пресеты и сам вычищает закончившиеся. + * Позиции — локальные px контейнера, куда добавлен слой (worldRoot — мировые + * эффекты через проекцию сцены, uiRoot — тосты/HUD). Изометрию слой не знает. + */ +export class FxLayer extends Container implements Updatable { + private readonly items = new Updatables(); + private readonly flashes = new Map(); + private readonly defaultSeed: number; + private seedCounter = 0; + + constructor(opts?: FxLayerOptions) { + super(); + this.defaultSeed = opts?.seed ?? 0x5eed; + } + + /** Ручное добавление своего эффекта (эмиттер, SpriteMotion, FxTrail…). */ + add(fx: T): T { + this.items.add(fx); + return fx; + } + + /** Погасить и уничтожить все активные эффекты (exit сцены, смена тоста). */ + clear(): void { + this.items.forEach((u) => { + const cancellable = u as { cancel?: () => void }; + if (cancellable.cancel) cancellable.cancel(); + else if (!u.destroyed && u instanceof Container) { + u.destroy({ children: true }); + } + }); + this.items.clear(); + this.flashes.clear(); + } + + update(dt: number): void { + this.items.update(dt); + for (const [sprite, flash] of this.flashes) { + if (sprite.destroyed) { + this.flashes.delete(sprite); + continue; + } + flash.update(dt); + } + } + + /** Расходящееся/сходящееся кольцо; repeat > 1 — телеграф зоны. */ + ring(spec: FxRingSpec, at: Vec2, opts?: FxParentOptions): FxRing { + const fx = new FxRing(spec); + fx.position.set(at.x, at.y); + return this.mount(fx, opts); + } + + /** Всплывающий текст (числа урона, тосты, подборы). */ + floatText(text: string, at: Vec2, opts?: FxLayerFloatOptions): FxFloatText { + const { style, parent, ...spec } = opts ?? {}; + const fx = new FxFloatText(text, at, { duration: 1, ...spec }, style); + return this.mount(fx, { parent }); + } + + /** Растворение (to < 1) или проявление произвольной вьюхи + авто-destroy. */ + fade(view: Container, spec: FxFadeOptions): FxFade { + return this.items.add(new FxFade(view, spec)) as FxFade; + } + + /** Появление с овершутом масштаба. */ + pop(view: Container, spec?: FxPopSpec): FxPop { + return this.items.add(new FxPop(view, spec)) as FxPop; + } + + /** Хит-флэш: один SpriteFlash на спрайт, повторный вызов перезапускает. */ + flash(sprite: Sprite, color: number, seconds?: number): void { + let flash = this.flashes.get(sprite); + if (!flash) { + flash = new SpriteFlash(sprite, color, seconds); + this.flashes.set(sprite, flash); + } + flash.start(); + } + + /** Разовые частицы в точке (ParticleEmitter.oneShot) с самоуничтожением. */ + burst(count: number, at: Vec2, opts: EmitterOptions, parent?: Container): ParticleEmitter { + const seed = opts.seed ?? (this.defaultSeed + this.seedCounter++); + const fx = ParticleEmitter.oneShot(count, { ...opts, seed }); + fx.position.set(at.x, at.y); + (parent ?? this).addChild(fx); + return this.items.add(fx) as ParticleEmitter; + } + + /** Шлейф за движущейся вьюхой (снаряды, герой). */ + trail(view: Container, opts: EmitterOptions, parent?: Container): FxTrail { + const fx = new FxTrail(view, opts); + (parent ?? this).addChild(fx.view); + return this.items.add(fx) as FxTrail; + } + + /** Повесить вьюху эффекта на слой или переданного родителя. */ + private mount(fx: T, opts?: FxParentOptions): T { + (opts?.parent ?? this).addChild(fx); + return this.items.add(fx) as T; + } +} \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxPop.ts b/packages/engine/src/render/fx/FxPop.ts new file mode 100644 index 0000000..835c496 --- /dev/null +++ b/packages/engine/src/render/fx/FxPop.ts @@ -0,0 +1,40 @@ +import { Container } from 'pixi.js'; +import type { Updatable } from '../../core/Updatables'; +import { samplePop, type FxPopSpec } from './fxSim'; + +/** + * Появление с овершутом масштаба (подбор, спавн, UI-подтверждение). Базовый + * масштаб снимается в конструкторе — эффект не конфликтует с вьюхами, масштаб + * которых меняет игра; вьюху не уничтожает (она «уже есть»). + */ +export class FxPop implements Updatable { + private t = 0; + private finished = false; + private readonly base: number; + + constructor( + private readonly view: Container, + private readonly spec: FxPopSpec = {} + ) { + this.base = view.scale.x; + } + + /** Прекратить эффект и вернуть базовый масштаб. */ + cancel(): void { + this.finished = true; + if (!this.view.destroyed) this.view.scale.set(this.base); + } + + /** Вьюха разрушена извне или эффект закончился. */ + get destroyed(): boolean { + return this.finished || this.view.destroyed; + } + + update(dt: number): void { + if (this.finished) return; + if (this.view.destroyed) return; + this.t += dt; + this.view.scale.set(this.base * samplePop(this.t, this.spec)); + if (this.t >= (this.spec.duration ?? 0.25) - 1e-6) this.finished = true; + } +} \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxRing.ts b/packages/engine/src/render/fx/FxRing.ts new file mode 100644 index 0000000..bd9faae --- /dev/null +++ b/packages/engine/src/render/fx/FxRing.ts @@ -0,0 +1,61 @@ +import { Graphics } from 'pixi.js'; +import type { Updatable } from '../../core/Updatables'; +import { sampleRing, ringDuration, type FxRingSpec, type FxRingSample } from './fxSim'; + +/** + * Кольцо/волна: расходящаяся или сходящаяся окружность (или ромб — «footprint» + * зоны). repeat > 1 — телеграф зоны (проходы с паузами); repeat: Infinity — + * зацикленный индикатор, гасится только cancel(). Перерисовка каждый тик + * (clear + один stroke) — тот же приём, что у рукописных колец боевых вьюх. + */ +export class FxRing extends Graphics implements Updatable { + private t = 0; + private cancelled = false; + + constructor(private readonly spec: FxRingSpec) { + super(); + this.draw(sampleRing(0, spec)); + } + + /** Досрочно погасить (смена телеграфа, exit сцены). */ + cancel(): void { + if (this.cancelled) return; + this.cancelled = true; + this.destroy(); + } + + /** true, когда все проходы сыграны (или кольцо погашено). */ + get done(): boolean { + return this.cancelled || this.t >= ringDuration(this.spec) - 1e-6; + } + + update(dt: number): void { + if (this.destroyed) return; + this.t += dt; + this.draw(sampleRing(this.t, this.spec)); + if (this.done) this.destroy(); + } + + private draw(s: FxRingSample): void { + this.clear(); + if (this.spec.fill) { + this.shapePath(s.radius).fill({ + color: this.spec.fill, + alpha: (this.spec.fillAlpha ?? 0.25) * s.alpha + }); + } + this.shapePath(s.radius).stroke({ + color: this.spec.color, + width: this.spec.width ?? 1, + alpha: s.alpha + }); + } + + /** Контур зоны текущего радиуса: круг или ромб (квадрат, повёрнутый на 45°). */ + private shapePath(r: number): this { + if (this.spec.shape === 'diamond') { + return this.poly([0, -r, r, 0, 0, r, -r, 0]); + } + return this.circle(0, 0, r); + } +} \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxTrail.ts b/packages/engine/src/render/fx/FxTrail.ts new file mode 100644 index 0000000..c8846c9 --- /dev/null +++ b/packages/engine/src/render/fx/FxTrail.ts @@ -0,0 +1,47 @@ +import { Container } from 'pixi.js'; +import type { Updatable } from '../../core/Updatables'; +import { ParticleEmitter, type EmitterOptions } from '../Particles'; + +/** + * Шлейф за движущейся вьюхой (снаряды, бегущий герой): эмиттер тянется за + * якорем каждый тик, пока не остановлен. stop() выключает спавн; когда + * последняя частица умрёт, эмиттер уничтожается и эффект заканчивается. + */ +export class FxTrail implements Updatable { + /** Готовый эмиттер (можно дотюнить setOptions — цвет, rate, lifetime). */ + readonly view: ParticleEmitter; + private stopped = false; + private finished = false; + + constructor( + private readonly anchor: Container, + opts: EmitterOptions + ) { + this.view = new ParticleEmitter({ + ...opts, + rate: opts.rate ?? 24, + lifetime: opts.lifetime ?? [0.3, 0.6] + }); + this.view.position.set(anchor.x, anchor.y); + } + + /** Перестать испускать (снаряд погас/умер): хвост самораспадается. */ + stop(): void { + this.stopped = true; + this.view.setOptions({ rate: 0 }); + } + + get destroyed(): boolean { + return this.finished || this.anchor.destroyed; + } + + update(dt: number): void { + if (this.finished) return; + if (!this.stopped) this.view.position.set(this.anchor.x, this.anchor.y); + this.view.update(dt); + if (this.stopped && this.view.count === 0) { + this.view.destroy({ children: true }); + this.finished = true; + } + } +} \ No newline at end of file diff --git a/packages/engine/src/render/fx/__tests__/FxLayer.test.ts b/packages/engine/src/render/fx/__tests__/FxLayer.test.ts new file mode 100644 index 0000000..d3cbb3c --- /dev/null +++ b/packages/engine/src/render/fx/__tests__/FxLayer.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from 'vitest'; +import { Container, Sprite, Texture } from 'pixi.js'; +import { FxLayer } from '../FxLayer'; +import { FxRing } from '../FxRing'; + +const TICK = 1 / 60; + +describe('FxLayer.ring', () => { + it('кольцо доигрывает и удаляется из слоя', () => { + const layer = new FxLayer(); + const ring = layer.ring({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }, { x: 10, y: 20 }); + expect(layer.children).toContain(ring); + for (let i = 0; i < 24; i++) layer.update(TICK); + expect(ring.destroyed).toBe(true); + expect(layer.children).not.toContain(ring); + }); + + it('зацикленное кольцо не самоуничтожается, cancel() гасит', () => { + const layer = new FxLayer(); + const ring = layer.ring( + { color: 0xff0000, from: 8, to: 96, duration: 0.3, repeat: Infinity }, + { x: 0, y: 0 } + ); + for (let i = 0; i < 120; i++) layer.update(TICK); + expect(ring.destroyed).toBe(false); + ring.cancel(); + expect(ring.destroyed).toBe(true); + }); + + it('родитель-опция: вьюха уходит в переданный контейнер', () => { + const layer = new FxLayer(); + const depth = new Container(); + const ring = layer.ring({ color: 0xffffff, from: 0, to: 10, duration: 0.1 }, { x: 0, y: 0 }, { parent: depth }); + expect(ring.parent).toBe(depth); + }); +}); + +describe('FxLayer.floatText', () => { + it('текст создаётся, поднимается и уничтожается по завершении', () => { + const layer = new FxLayer(); + const fx = layer.floatText('+1', { x: 100, y: 100 }, { duration: 1, rise: 8 }); + expect(layer.children).toContain(fx); + layer.update(TICK); + expect(fx.alpha).toBeGreaterThan(0); + expect(fx.y).toBeLessThanOrEqual(100); + for (let i = 0; i < 60; i++) layer.update(TICK); + expect(fx.destroyed).toBe(true); + expect(layer.children).not.toContain(fx); + }); +}); + +describe('FxLayer.fade', () => { + it('вьюха растворяется и уничтожается', () => { + const layer = new FxLayer(); + const parent = new Container(); + const view = new Container(); + view.alpha = 1; + parent.addChild(view); + layer.fade(view, { duration: 0.5 }); + for (let i = 0; i < 30; i++) layer.update(TICK); + expect(view.destroyed).toBe(true); + expect(view.parent).toBeNull(); + }); + + it('keep: true — вьюха жива с целевой альфой', () => { + const layer = new FxLayer(); + const parent = new Container(); + const view = new Container(); + parent.addChild(view); + layer.fade(view, { duration: 0.5, to: 0.4, keep: true }); + for (let i = 0; i < 30; i++) layer.update(TICK); + expect(view.destroyed).toBe(false); + expect(view.alpha).toBeCloseTo(0.4, 5); + expect(view.parent).toBe(parent); + }); + + it('вьюха, разрушенная извне во время эффекта, не роняет update', () => { + const layer = new FxLayer(); + const view = new Container(); + layer.fade(view, { duration: 1 }); + layer.update(TICK); + view.destroy({ children: true }); + expect(() => layer.update(TICK)).not.toThrow(); + }); +}); + +describe('FxLayer.pop', () => { + it('масштаб возвращается к базовому, вьюха жива', () => { + const layer = new FxLayer(); + const view = new Container(); + view.scale.set(2); + layer.pop(view, { duration: 0.25, fromScale: 0.5 }); + layer.update(TICK); + expect(view.scale.x).toBeGreaterThan(0.5 * 2); // разгоны от базового + for (let i = 0; i < 15; i++) layer.update(TICK); + expect(view.scale.x).toBeCloseTo(2, 5); + expect(view.destroyed).toBe(false); + }); +}); + +describe('FxLayer.flash', () => { + it('повторный флэш по тому же спрайту не дублируется, destroy спрайта чистится', () => { + const layer = new FxLayer(); + const sprite = new Sprite(Texture.WHITE); + layer.flash(sprite, 0xff0000); + layer.flash(sprite, 0xff0000); // перезапуск, а не вторая запись + layer.update(TICK); + expect(sprite.tint).not.toBe(0xffffff); // вспышка применилась + for (let i = 0; i < 20; i++) layer.update(TICK); // 0.15 с — флэш доиграл + expect(sprite.tint).toBe(0xffffff); + sprite.destroy(); + expect(() => layer.update(TICK)).not.toThrow(); + }); +}); + +describe('FxLayer.burst', () => { + it('детерминирован по seed: одинаковые позиции частиц', () => { + const a = new FxLayer({ seed: 42 }); + const b = new FxLayer({ seed: 42 }); + const opts = { rate: 0, lifetime: [0.5, 0.5] as [number, number], radialSpeed: [50, 100] as [number, number] }; + const ea = a.burst(5, { x: 0, y: 0 }, opts); + const eb = b.burst(5, { x: 0, y: 0 }, opts); + a.update(TICK); + b.update(TICK); + expect(ea.children.map((c) => `${c.x},${c.y}`)).toEqual(eb.children.map((c) => `${c.x},${c.y}`)); + }); + + it('эмиттер самоуничтожается, когда частицы умерли', () => { + const layer = new FxLayer({ seed: 1 }); + const fx = layer.burst(2, { x: 0, y: 0 }, { rate: 0, lifetime: [0.2, 0.2] }); + for (let i = 0; i < 20; i++) layer.update(TICK); + expect(fx.destroyed).toBe(true); + }); +}); + +describe('FxLayer.trail', () => { + it('тянет эмиттер за якорем; stop() гасит хвост и завершает эффект', () => { + const layer = new FxLayer(); + const anchor = new Container(); + anchor.position.set(10, 10); + const trail = layer.trail(anchor, { rate: 30, lifetime: [0.1, 0.1], seed: 7 }); + anchor.position.set(50, 30); + layer.update(TICK); + expect(trail.view.x).toBe(50); + trail.stop(); + for (let i = 0; i < 20; i++) layer.update(TICK); + expect(trail.destroyed).toBe(true); + }); +}); + +describe('FxLayer.clear', () => { + it('гасит все активные эффекты и флэш-записи', () => { + const layer = new FxLayer(); + const ring = layer.ring({ color: 0xffffff, from: 0, to: 10, duration: 5 }, { x: 0, y: 0 }); + const view = new Container(); + layer.pop(view, { duration: 5 }); + const sprite = new Sprite(Texture.WHITE); + layer.flash(sprite, 0xff0000); + layer.clear(); + expect(ring.destroyed).toBe(true); + expect(view.scale.x).toBe(1); // pop вернул базовый масштаб + layer.update(TICK); // не падает + }); +}); + +describe('FxRing', () => { + it('радиус растёт по спеку, альфа гаснет к концу', () => { + const ring = new FxRing({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }); + ring.update(0.2); + ring.update(0.2); + expect(ring.done).toBe(true); + expect(ring.destroyed).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/render/fx/__tests__/fxSim.test.ts b/packages/engine/src/render/fx/__tests__/fxSim.test.ts new file mode 100644 index 0000000..037d172 --- /dev/null +++ b/packages/engine/src/render/fx/__tests__/fxSim.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { + sampleRing, ringDuration, sampleFloat, readSeconds, sampleFade, samplePop +} from '../fxSim'; +import { quadOut } from '../../../core/easing'; + +describe('sampleRing', () => { + const spec = { color: 0xffffff, from: 8, to: 96, duration: 0.4 }; + + it('границы прохода: t=0 → from/alphaFrom, t=duration → to/alphaTo', () => { + expect(sampleRing(0, spec)).toEqual({ radius: 8, alpha: 1 }); + expect(sampleRing(0.4, spec)).toEqual({ radius: 96, alpha: 0 }); + }); + + it('ease применяется к радиусу, но не к альфе', () => { + const eased = sampleRing(0.2, { ...spec, ease: quadOut }); + // quadOut(0.5) = 0.75 → радиус дальше середины + expect(eased.radius).toBeCloseTo(8 + 88 * 0.75, 5); + expect(eased.alpha).toBeCloseTo(0.5, 5); + }); + + it('repeat с gap: второй проход начинается с from после паузы', () => { + const s = { ...spec, repeat: 3, gap: 0.1 }; + // конец первого прохода + пауза — радиус снова from (начало второго) + const second = sampleRing(0.4 + 0.1 + 0.01, s); + expect(second.radius).toBeCloseTo(8 + 88 * 0.025, 1); + // после всех проходов (3*0.4 + 2*0.1 = 1.4) — конец + expect(ringDuration(s)).toBeCloseTo(1.4, 5); + expect(sampleRing(1.4, s).alpha).toBeCloseTo(0, 5); + }); + + it('repeat: Infinity — бесконечная длительность, циклы повторяются', () => { + const s = { ...spec, repeat: Infinity, gap: 0.1 }; + expect(ringDuration(s)).toBe(Infinity); + // t = 0.5 (заход во второй цикл) — как t = 0.5 - 0.5 цикла... проверяем цикличность + const a = sampleRing(0.4 + 0.1 + 0.0, s); + const b = sampleRing(0.4 + 0.1 + 0.5 + 0.4 + 0.1, s); + expect(a.radius).toBeCloseTo(b.radius, 5); + expect(a.alpha).toBeCloseTo(b.alpha, 5); + }); + + it('отрицательный t клампится к началу', () => { + expect(sampleRing(-1, spec).radius).toBe(8); + }); +}); + +describe('sampleFloat', () => { + it('границы: t=0 → y=0/alpha=0, конец → y=-rise/alpha=0', () => { + const spec = { duration: 1, rise: 8 }; + expect(sampleFloat(0, spec)).toEqual({ x: 0, y: 0, alpha: 0, scale: 1 }); + const end = sampleFloat(1, spec); + expect(end.y).toBeCloseTo(-8, 5); + expect(end.alpha).toBe(0); + }); + + it('в середине — alpha 1, подъём по quadOut', () => { + const s = sampleFloat(0.5, { duration: 1, rise: 8 }); + expect(s.alpha).toBe(1); + expect(s.y).toBeCloseTo(-8 * 0.75, 5); // quadOut(0.5) + }); + + it('hold: плато позиции и альфы до конца hold', () => { + const spec = { duration: 2, hold: 1, rise: 10 }; + const mid = sampleFloat(0.5, spec); + expect(mid.y).toBe(0); + expect(mid.alpha).toBe(1); + }); + + it('driftX смещает x пропорционально прогрессу', () => { + const s = sampleFloat(1, { duration: 1, driftX: 12 }); + expect(s.x).toBeCloseTo(12, 5); + }); + + it('pop: масштаб растёт от fromScale к 1 в течение pop', () => { + const s = sampleFloat(0.15, { duration: 1, pop: 0.3, fromScale: 0.6 }); + expect(s.scale).toBeCloseTo(0.6 + 0.4 * 0.5, 5); + expect(sampleFloat(0.4, { duration: 1, pop: 0.3, fromScale: 0.6 }).scale).toBe(1); + }); +}); + +describe('readSeconds', () => { + it('база + perChar за символ', () => { + expect(readSeconds('')).toBeCloseTo(1.4, 5); + expect(readSeconds('ab')).toBeCloseTo(1.4 + 2 * 0.045, 5); + }); + + it('не больше max', () => { + expect(readSeconds('x'.repeat(500))).toBe(6); + }); +}); + +describe('sampleFade', () => { + it('границы: from → to', () => { + expect(sampleFade(0, 1, { duration: 0.5 })).toBe(1); + expect(sampleFade(0.5, 1, { duration: 0.5 })).toBeCloseTo(0, 5); + }); + + it('delay сдвигает старт', () => { + const spec = { duration: 0.5, delay: 0.25 }; + expect(sampleFade(0.25, 1, spec)).toBe(1); + expect(sampleFade(0.5, 1, spec)).toBeCloseTo(0.25, 5); // половина пути + expect(sampleFade(0.75, 1, spec)).toBeCloseTo(0, 5); + }); + + it('кастомная цель: проявление до 0.8', () => { + expect(sampleFade(1, 0.2, { duration: 1, to: 0.8 })).toBeCloseTo(0.8, 5); + }); +}); + +describe('samplePop', () => { + it('границы: t=0 → fromScale, конец → ровно 1', () => { + expect(samplePop(0, {})).toBeCloseTo(0.5, 5); + expect(samplePop(0.25, {})).toBe(1); + expect(samplePop(10, {})).toBe(1); + }); + + it('пик у overshoot в районе 70% времени', () => { + const peak = samplePop(0.175, { duration: 0.25 }); // u = 0.7 + expect(peak).toBeCloseTo(1.12, 5); + }); + + it('кастомные параметры honored', () => { + const spec = { duration: 1, fromScale: 0.2, overshoot: 1.5 }; + expect(samplePop(0, spec)).toBeCloseTo(0.2, 5); + expect(samplePop(0.7, spec)).toBeCloseTo(1.5, 5); + }); +}); + +describe('чистота сэмплов', () => { + it('двойной прогон даёт одинаковые значения (нет Math.random)', () => { + const ringSpec = { color: 0xd99a32, from: 8, to: 96, duration: 0.4, repeat: 3, gap: 0.1 }; + const floatSpec = { duration: 2, hold: 0.8, driftX: 5, pop: 0.3 }; + const a = [0, 0.13, 0.5, 1.23, 2].map((t) => JSON.stringify(sampleFloat(t, floatSpec))); + const b = [0, 0.2, 0.5, 0.9, 1.4].map((t) => JSON.stringify(sampleRing(t, ringSpec))); + const a2 = [0, 0.13, 0.5, 1.23, 2].map((t) => JSON.stringify(sampleFloat(t, floatSpec))); + const b2 = [0, 0.2, 0.5, 0.9, 1.4].map((t) => JSON.stringify(sampleRing(t, ringSpec))); + expect(a).toEqual(a2); + expect(b).toEqual(b2); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/render/fx/fxSim.ts b/packages/engine/src/render/fx/fxSim.ts new file mode 100644 index 0000000..09f33fb --- /dev/null +++ b/packages/engine/src/render/fx/fxSim.ts @@ -0,0 +1,187 @@ +/** + * Чистая математика one-shot эффектов: сэмплы состояния (радиус/альфа/смещение) + * в момент t. Без Pixi — тестируется в Vitest headless; адаптеры в этом каталоге + * только применяют сэмплы к вьюхам. + */ + +import { backOut, linear, quadOut, type EaseFn } from '../../core/easing'; + +/** Спецификация кольца/волны; repeat > 1 — телеграф зоны. */ +export interface FxRingSpec { + /** Цвет обводки (0xRRGGBB). */ + color: number; + /** Стартовый радиус, px. */ + from: number; + /** Конечный радиус, px. */ + to: number; + /** Секунд на один проход. */ + duration: number; + /** Альфа в начале/конце прохода (по умолчанию 1 → 0). */ + alphaFrom?: number; + alphaTo?: number; + /** Толщина обводки, px (по умолчанию 1). */ + width?: number; + /** Сглаживание радиуса (по умолчанию linear). */ + ease?: EaseFn; + /** Число проходов (по умолчанию 1; Infinity — зацикленный телеграф). */ + repeat?: number; + /** Пауза между проходами, сек (по умолчанию 0). */ + gap?: number; + /** Форма зоны: круг или «footprint»-ромб (квадрат, повёрнутый на 45°). */ + shape?: 'circle' | 'diamond'; + /** Цвет заливки зоны (телеграф); 0 — без заливки. */ + fill?: number; + /** Альфа заливки (по умолчанию 0.25). */ + fillAlpha?: number; +} + +/** Состояние кольца в момент t. */ +export interface FxRingSample { + radius: number; + alpha: number; +} + +/** Радиус и альфа кольца в момент t; repeat > 1 — проходы с паузой gap. */ +export function sampleRing(t: number, spec: FxRingSpec): FxRingSample { + const dur = Math.max(spec.duration, 1e-6); + const gap = spec.gap ?? 0; + const repeat = spec.repeat ?? 1; + const cycle = dur + gap; + const total = repeat === Infinity ? Infinity : repeat * cycle - gap; + // Прошли все проходы — стоим в конце последнего (mod не должен завернуть границу). + const ended = total !== Infinity && t >= total; + const k = repeat === Infinity ? t % cycle : Math.min(t, total); + const u = ended ? 1 : Math.min(1, Math.max(0, (k % cycle) / dur)); + const ease = spec.ease ?? linear; + const aFrom = spec.alphaFrom ?? 1; + const aTo = spec.alphaTo ?? 0; + return { + radius: spec.from + (spec.to - spec.from) * ease(u), + alpha: aFrom + (aTo - aFrom) * u + }; +} + +/** Полная длительность эффекта, сек (Infinity при repeat: Infinity). */ +export function ringDuration(spec: FxRingSpec): number { + const repeat = spec.repeat ?? 1; + if (repeat === Infinity) return Infinity; + return repeat * (spec.duration + (spec.gap ?? 0)) - (spec.gap ?? 0); +} + +/** Спецификация всплывающего текста (урон, тосты, подборы). */ +export interface FxFloatSpec { + /** Полная длительность, сек. */ + duration: number; + /** Плато после подъёма, сек (тосты — «держать, пока читается»). */ + hold?: number; + /** Подъём вверх, px (по умолчанию 8; отрицательный — вниз). */ + rise?: number; + /** Дрейф по X, px (число урона «уезжает» вбок). */ + driftX?: number; + /** Сек нарастания альфы в начале (по умолчанию 0.06). */ + fadeIn?: number; + /** Сек затухания в конце (по умолчанию 0.4). */ + fadeOut?: number; + /** Ease подъёма (по умолчанию quadOut). */ + ease?: EaseFn; + /** Пик овершута масштаба (0 — без «пружинки»). */ + pop?: number; + /** Стартовый масштаб при pop (по умолчанию 0.6). */ + fromScale?: number; +} + +/** Состояние текста в момент t (x/y — смещение от стартовой точки). */ +export interface FxFloatSample { + x: number; + y: number; + alpha: number; + scale: number; +} + +/** Положение, альфа и масштаб всплывающего текста в момент t. */ +export function sampleFloat(t: number, spec: FxFloatSpec): FxFloatSample { + const rise = spec.rise ?? 8; + const fadeIn = spec.fadeIn ?? 0.06; + const fadeOut = spec.fadeOut ?? 0.4; + const hold = spec.hold ?? 0; + const travel = Math.max(spec.duration - hold, 1e-6); + const u = Math.min(1, Math.max(0, (t - hold) / travel)); + const ease = spec.ease ?? quadOut; + const alphaIn = Math.min(1, t / Math.max(fadeIn, 1e-6)); + const alphaOut = Math.min(1, (spec.duration - t) / Math.max(fadeOut, 1e-6)); + const pop = spec.pop ?? 0; + const fromScale = spec.fromScale ?? 0.6; + const scale = pop > 0 + ? fromScale + (1 - fromScale) * Math.min(1, t / Math.max(pop, 1e-6)) + : 1; + return { + x: (spec.driftX ?? 0) * u, + y: 0 - rise * ease(u), // 0 − (…) вместо -(…): не даём «минус ноль» на старте + alpha: Math.max(0, Math.min(alphaIn, alphaOut)), + scale + }; +} + +/** + * Время чтения строки (для тостов): base + perChar*len, не больше max. + * Дефолты совпадают с историческим тостом демо: 1.4 + 0.045/символ, не дольше 6. + */ +export function readSeconds( + text: string, + opts?: { base?: number; perChar?: number; max?: number } +): number { + const base = opts?.base ?? 1.4; + const perChar = opts?.perChar ?? 0.045; + const max = opts?.max ?? 6; + return Math.min(max, base + text.length * perChar); +} + +/** Спецификация растворения/проявления произвольной вьюхи. */ +export interface FxFadeSpec { + /** Секунд до конца. */ + duration: number; + /** Целевая альфа (по умолчанию 0 — растворение). */ + to?: number; + /** Задержка старта, сек. */ + delay?: number; + /** Ease альфы (по умолчанию quadOut). */ + ease?: EaseFn; +} + +/** Альфа вьюхи в момент t: from → to за duration после delay. */ +export function sampleFade(t: number, from: number, spec: FxFadeSpec): number { + const delay = spec.delay ?? 0; + const u = Math.min(1, Math.max(0, (t - delay) / Math.max(spec.duration, 1e-6))); + const ease = spec.ease ?? quadOut; + const to = spec.to ?? 0; + return from + (to - from) * ease(u); +} + +/** Спецификация появления с овершутом (pop/squash). */ +export interface FxPopSpec { + /** Секунд (по умолчанию 0.25). */ + duration?: number; + /** Стартовый множитель масштаба (по умолчанию 0.5). */ + fromScale?: number; + /** Пик овершута (по умолчанию 1.12; 1 — без пружинки). */ + overshoot?: number; + /** Ease масштаба (по умолчанию backOut). */ + ease?: EaseFn; +} + +/** Множитель масштаба в момент t (1 в покое): from → overshoot → 1. */ +export function samplePop(t: number, spec: FxPopSpec = {}): number { + const dur = spec.duration ?? 0.25; + const from = spec.fromScale ?? 0.5; + const over = spec.overshoot ?? 1.12; + const ease = spec.ease ?? backOut; + const u = Math.min(1, Math.max(0, t / Math.max(dur, 1e-6))); + if (u >= 1) return 1; + // Две фазы: разгон from → overshoot (70% времени), затем плавный возврат к 1. + const peakT = 0.7; + if (u < peakT) { + return from + (over - from) * ease(u / peakT); + } + const settle = (u - peakT) / (1 - peakT); + return over + (1 - over) * settle; +} \ No newline at end of file diff --git a/packages/engine/src/render/fx/particlePresets.ts b/packages/engine/src/render/fx/particlePresets.ts new file mode 100644 index 0000000..88522f3 --- /dev/null +++ b/packages/engine/src/render/fx/particlePresets.ts @@ -0,0 +1,61 @@ +import type { EmitterOptions } from '../Particles'; +import { mergeEmitterOptions } from '../particleSim'; + +/** + * Готовые наборы EmitterOptions для частых one-shot эффектов. Каждая фабрика — + * детерминированные дефолты + переопределения вызывающего (mergeEmitterOptions): + * `oneShot(10, sparkPreset(0xf2b45a, { seed }))`. Цвет — обязательный первый + * аргумент там, где он определяет смысл эффекта (искры, угольки). + */ + +/** Искры удара: быстрые разлетающиеся, тормозят, светятся (add-блендинг). */ +export function sparkPreset(color: number, over?: Partial): EmitterOptions { + return mergeEmitterOptions({ + color, + rate: 0, + lifetime: [0.2, 0.45], + radialSpeed: [70, 170], + drag: 2.5, + size: [1, 2], + blend: 'add' + }, over); +} + +/** Дым/пепел: медленный подъём, рост и растворение, без блендинга. */ +export function smokePreset(color: number, over?: Partial): EmitterOptions { + return mergeEmitterOptions({ + color, + rate: 0, + lifetime: [0.6, 1.2], + velocity: { x: [-8, 8], y: [-24, -10] }, + acceleration: { y: 6 }, + size: [2, 4], + scaleOverLife: 'grow' + }, over); +} + +/** Угольки/мотыльки: медленный дрейф вверх с синус-качанием, тёплый свет. */ +export function emberPreset(color: number, over?: Partial): EmitterOptions { + return mergeEmitterOptions({ + color, + rate: 0, + lifetime: [1.0, 2.0], + velocity: { x: [-10, 10], y: [-18, -6] }, + wobble: 6, + size: 1, + fadeIn: 0.3, + blend: 'add' + }, over); +} + +/** Пыль от шага/приземления: короткая, низкая, быстро тормозит. */ +export function dustPreset(color: number, over?: Partial): EmitterOptions { + return mergeEmitterOptions({ + color, + rate: 0, + lifetime: [0.25, 0.5], + velocity: { x: [-14, 14], y: [-6, -2] }, + drag: 2, + size: [1, 2] + }, over); +} \ No newline at end of file diff --git a/packages/engine/src/render/spriteFx.ts b/packages/engine/src/render/spriteFx.ts index c719e62..f316004 100644 --- a/packages/engine/src/render/spriteFx.ts +++ b/packages/engine/src/render/spriteFx.ts @@ -32,4 +32,9 @@ this.flash = null; } } + + /** Самозачистка из реестра: спрайт разрушен — запись вычистится сама. */ + get destroyed(): boolean { + return this.sprite.destroyed; + } } \ No newline at end of file