diff --git a/docs/engine/anim.md b/docs/engine/anim.md index fd35be0..9ca30ec 100644 --- a/docs/engine/anim.md +++ b/docs/engine/anim.md @@ -10,6 +10,8 @@ ``` anim/clip.ts чистый степпер клипов (loop/once/pingpong) anim/SpriteAnimator.ts кадры → Sprite, именованные клипы, события +anim/motion.ts чистые «оживители» статики (bob/sway/pulse/blink) +anim/SpriteMotion.ts Pixi-адаптер оживителей (view → колебания) anim/FrameAnimation.ts @deprecated-обёртка совместимости над SpriteAnimator ``` @@ -60,4 +62,54 @@ `FrameAnimation(sprite, frames, fps, loop)` — прежний API одного клипа, оставлен как тонкая обёртка (`@deprecated`): `setFrames(f, reset)` -продолжает с текущего шага без reset и стартует с нуля с ним. \ No newline at end of file +продолжает с текущего шага без reset и стартует с нуля с ним. + +## Оживители статики: SpriteMotion + +Процедурное движение без кадров — для всего, что не умеет ходить: парящие +сгустки, качающиеся колокольчики, пульсирующие искры, мигающие огни. Математика +— чистая `sampleMotion(params, t)` (тестируется без Pixi), Pixi-адаптер — +`SpriteMotion`: + +```ts +import { SpriteMotion } from '@rpg/engine'; + +const motion = new SpriteMotion(bellView, { + sway: { amplitude: 0.06, period: 2.2 } // рад вокруг основания +}, { seed: tileY * 64 + tileX }); // рассинхрон кустов — детерминирован +engine.fx.add(motion); +``` + +Параметры (комбинируются, эффекты перемножаются): + +| Параметр | Движение | Для чего | +| --- | --- | --- | +| `bob { amplitude, period }` | парение по Y (px, **округлено до целого**) | сгустки, духи, всё «невесомое» | +| `sway { amplitude, period }` | покачивание рад вокруг основания | колокольчики, цветы, трава — **укоренённое** | +| `pulse { min, max, period }` | пульс alpha и scale треугольной волной | свечение искр, магические предметы | +| `blink { period, duty? }` | строб 0/1 (duty — доля «включённого») | огни, маяки, акценты | + +Правила выбора: `bob` — только для объектов, не имеющих контакта с землёй +(парящие), иначе рвётся «тень-контакт»; `sway` — для укоренённых (поворот вокруг +основания сохраняет контакт); `pulse`/`blink` — для свечения и акцентов. + +`SpriteMotion` снимает базовые `position/rotation/alpha/scale` в конструкторе — +позиционируйте view **до** его создания. `params` живые: меняйте на лету +(колокол качается только пока звонят). `detach()` возвращает исходные +значения. Фаза из `seed` — детерминированная (никакого `Math.random`). + +## Анимированные тайлы: setTileAnimation + +Вода и прочие «живые» ground-тайлы — не спрайтовые аниматоры, а один общий +таймлайн на все клетки с данным id (дёшево при сотнях клеток воды): + +```ts +map.setTileAnimation(TILES.WATER, [assets.texture('tiles/water_1'), + assets.texture('tiles/water_2')], 2); // fps +// в update сцены (no-op, если анимаций нет): +map.update(dt); +``` + +Кадры — обычные текстуры (например, из `tiles/water_1.png`/`water_2.png`); +клетки, нарисованные до вызова, регистрируются автоматически, `setTile` +перерегистрирует при перерисовке. \ No newline at end of file diff --git a/docs/engine/maps.md b/docs/engine/maps.md index 7b0a768..95f8060 100644 --- a/docs/engine/maps.md +++ b/docs/engine/maps.md @@ -52,8 +52,12 @@ map.isWalkable(x, y); // Grid для A* map.worldBounds; // { x: 0, y: 0, width, height } — границы камеры в юнитах map.setTile(x, y, id); // изменить тайл (сбор предметов, посадка) — данные + перерисовка ячейки +map.setTileAnimation(id, frames, fps); // живой тайл (вода): один таймлайн на все клетки +map.update(dt); // тик тайл-анимаций (no-op, если их нет) — из update сцены ``` +Подробнее про анимированные тайлы — [anim.md](anim.md). + ## Крупные объекты: props (footprint w×h) Зданиям и крупным деревьям одного тайла мало. `props` — объекты с footprint'ом diff --git a/docs/engine/recipes.md b/docs/engine/recipes.md index 9a50dba..9f47579 100644 --- a/docs/engine/recipes.md +++ b/docs/engine/recipes.md @@ -100,21 +100,31 @@ ## Покадровая анимация ходьбы -Смотри `apps/game/src/systems/PlayerController.ts`: - ```ts -type HeroTextures = { down: Texture[]; up: Texture[]; side: Texture[] }; +import { SpriteAnimator } from '@rpg/engine'; -const sprite = new Sprite(frames.down[0]); +const sprite = new Sprite(walkFrames.down[0]); sprite.anchor.set(0.5, 1); // ноги в центре тайла -const anim = new FrameAnimation(sprite, frames.down, 6); -// смена направления: -anim.setFrames(frames.up, true); -// для right — зеркалим: +// именованные клипы; кадры можно взять и из атласа с animations: +// assets.animation('chars/hero_sheet.json', 'hero_walk_down') +const animator = new SpriteAnimator(sprite, { + walk_down: { frames: walkFrames.down, fps: 6 }, + walk_up: { frames: walkFrames.up, fps: 6 }, + walk_side: { frames: walkFrames.side, fps: 6 }, + idle: { frames: [walkFrames.down[0]], loop: 'once' } +}, 'idle'); + +// при движении — каждый тик (тот же клип без restart просто продолжается): +animator.play(moving ? `walk_${dir}` : 'idle'); +// при остановке — явный 'idle': уходит «застыл на произвольном кадре». + +// направление right — зеркалим (флип — зона владельца спрайта): sprite.scale.x = -1; ``` +Аниматор добавьте в `engine.fx` — тикать вручную не нужно. + ## Атмосферные частицы (пепел, мотыльки) ```ts @@ -125,12 +135,60 @@ }); ash.position.set(240, 135); engine.renderer.worldRoot.addChild(ash); +engine.fx.add(ash); // тик — движок, ручной update не нужен -update(dt) { ash.update(dt); } -exit() { ash.clear(); } +exit() { ash.destroy({ children: true }); } // сам выпадает из engine.fx ``` -Мотыльки — теплый цвет (`0xf0d878`), меньше скорость, `acceleration: { y: 1.5 }`. +Мотыльки — тёплые цвета (`colors: [0xf0d878, 0xd8b050]`), `blend: 'add'`, +`wobble: 8` (дрейф по синусу), `fadeIn: 0.5`, `acceleration: { y: 1.5 }`. + +## Оживить статику (без кадров) + +Колышущиеся цветы, парящие сгустки, пульсирующие искры — процедурные +оживители `SpriteMotion` (см. `anim.md`): + +```ts +// Колокольчик качается; seed по координатам — кусты не качаются синхронно +const sway = new SpriteMotion(flowerView, + { sway: { amplitude: 0.06, period: 2.2 } }, { seed: tx * 31 + ty }); +engine.fx.add(sway); + +// Сгусток парит (bob — только для «невесомых», не укоренённых!) +const bob = new SpriteMotion(wispView, { bob: { amplitude: 2, period: 1.6 } }); +engine.fx.add(bob); + +// Колокол качается только при звоне — params живые: +bellMotion.params = { sway: { amplitude: 0.1, period: 0.8 } }; // во время звона +bellMotion.params = { sway: { amplitude: 0.02, period: 3 } }; // после +``` + +Правило: `bob` — парящее (сгустки, духи), `sway` — укоренённое (цветы, трава), +`pulse`/`blink` — свечение и акценты. Позиционируйте view до создания +`SpriteMotion` (база снимается в конструкторе). + +## Живая вода (анимированные тайлы) + +```ts +map.setTileAnimation(TILES.WATER, [ + assets.texture('tiles/water_1'), + assets.texture('tiles/water_2') +], 2); // 2 кадра/сек — спокойная вода + +update(dt) { map.update(dt); } // один таймлайн на все клетки воды +``` + +## Эффект удара в depth-сортировке + +```ts +const boom = ParticleEmitter.oneShot(14, { + color: 0x999988, lifetime: [0.2, 0.5], + radialSpeed: [40, 90], size: 2, drag: 3, scaleOverLife: 'shrink', seed: n +}); +boom.position.set(...worldToScreen(tx, ty)); +depthLayer.addFx(boom, tx, ty); // +0.5: поверх актёра на этом тайле +engine.fx.add(boom); // тик и самоуничтожение — сами +``` ## Меню с клавиатурой и мышью diff --git a/docs/engine/render.md b/docs/engine/render.md index 35e3a34..004d4ad 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -100,56 +100,90 @@ Для корректного перекрытия герой/дерево должны быть в одном слое с деревьями — либо используйте `tall`-объекты карты и держите сущности поверх (см. recipes). Крупные объекты (`props`, footprint w×h) сортируются через `addRect` — глубина -по прямоугольнику, а не по точке якоря (см. maps.md). +по прямоугольнику, а не по точке якоря (см. maps.md). Эффекты (искры, кольца) — +через `addFx(view, tx, ty, bias = 0.5)`: глубина тайла с надбавкой, чтобы +искры удара были поверх актёра на том же тайле. ## Particles -Эмиттер частиц для атмосферы (пепел, мотыльки, пыль, искры). Частицы — подкрашенные -квадратики 1–4 виртуальных пикселя, детерминированные по seed: +Эмиттер частиц для атмосферы (пепел, мотыльки, пыль, искры) и боевых эффектов +(удары, взрывы). Частицы — подкрашенные квадратики 1–4 виртуальных пикселя +или текстуры; всё детерминировано по seed. Тикается через `engine.fx` либо +вручную из сцены. ```ts import { ParticleEmitter } from '@rpg/engine'; +// Атмосферные мотыльки: тёплый свет, дрейф, нарастание и затухание альфы const moths = new ParticleEmitter({ - color: 0xf0d878, + colors: [0xf0d878, 0xd8b050], // случайный цвет из списка при спавне rate: 3, // частиц в секунду lifetime: [2, 5], // сек velocity: { x: [-6, 6], y: [-9, -3] }, acceleration: { y: 1.5 }, // «тяжёлый» полёт мотылька + wobble: 8, // синус-дрейф по X (фаза — у каждой своя) + fadeIn: 0.5, // сек нарастания альфы size: 2, + blend: 'add', // светящиеся: огонь, искры, мотыльки spawnArea: { width: 480, height: 200 }, seed: 42 }); moths.position.set(240, 135); engine.renderer.worldRoot.addChild(moths); - -// в update сцены: -moths.update(dt); -// при выходе со сцены: -moths.clear(); moths.destroy(); +engine.fx.add(moths); // тик и зачистка (по destroyed) — сами ``` -Для боевых эффектов (удар, смерть, попадание) есть разовые всплески: +Основные опции (все, кроме `rate`/`lifetime`/`velocity`, опциональны): + +| Опция | Что делает | +| --- | --- | +| `color` / `colors` / `colorOverLife` | цвет, случайный из списка, лерп по жизни | +| `texture`, `scale` | текстурная частица вместо квадрата (anchor 0.5) | +| `size` | размер квадрата: число или диапазон | +| `rotation` / `spin` | начальный угол и скорость вращения (рад) | +| `scaleOverLife` | `[a, b]` или `'shrink'`/`'grow'` | +| `fadeIn` / `fadeOut` | нарастание (сек); затухание: `true` — вся жизнь, число — хвост в сек, `false` — без | +| `drag` | торможение, 1/сек (искры, пух) | +| `wobble` | амплитуда синус-дрейфа по X, px/сек | +| `max` | лимит живых частиц (пул спрайтов), по умолчанию 256 | +| `blend` | `'add'` — светящиеся частицы | + +Для боевых эффектов есть разовые всплески: ```ts -// burst: мгновенно выпустить частицы из существующего эмиттера +// burst: мгновенно выпустить частицы из существующего эмиттера. +// opts — разовые переопределения; базовые опции НЕ мутируются. hitFx.burst(12, { color: 0x999988, radialSpeed: [40, 90], lifetime: [0.2, 0.5] }); -// oneShot: самостоятельный эмиттер-взрыв, уничтожается сам, когда все частицы умерли +// oneShot: самостоятельный эмиттер-взрыв; уничтожает себя, когда все умерли +// (сам выпадает из engine.fx), onFinish — для цепочек эффектов. const boom = ParticleEmitter.oneShot(16, { - color: 0x777766, rate: 0, lifetime: [0.3, 0.6], - velocity: { x: [0, 0], y: [0, 0] }, // не используется при radialSpeed - radialSpeed: [50, 120], size: 2, seed: 7 + color: 0x777766, lifetime: [0.3, 0.6], + radialSpeed: [50, 120], size: 2, drag: 3, spin: [-4, 4], + scaleOverLife: 'shrink', seed: 7 }); boom.position.set(x, y); worldRoot.addChild(boom); -// обновлять в update сцены: boom.update(dt) +engine.fx.add(boom); // тикается сам, ручной update не нужен ``` -Интеграция частицы (`stepParticle`, `sampleSpawn`, `sampleBurst`) вынесена в чистый -модуль `render/particleSim` — её можно тикать и без Pixi (например, в тестах боя). -Частицы — display-layer: все внутренние величины эмиттера — экранные px относительно -его origin; в мире эмиттер позиционируется через `worldToScreen`. +**Depth**: чтобы искры удара корректно перекрывались актёрами, добавляйте эмиттер +в `IsoDepthLayer` через `addFx(view, tx, ty)` (надбавка +0.5 — поверх актёра на +том же тайле); у движущихся эффектов обновляйте `setDepth` там же, где глубину вью. + +Интеграция частицы — чистый модуль `render/particleSim`, тикается и без Pixi +(например, в тестах боя): + +```ts +// breaking (v2): stepParticle возвращает видимые кривые, а не мутирует альфу +const visual = stepParticle(state, dt, { drag: 2, scaleOverLife: 'shrink' }); +// visual: { alpha, scale, rotation, tint } +``` + +`sampleSpawn`/`sampleBurst` — спавн по опциям эмиттера и радиальный «взрыв»; +`mergeEmitterOptions(base, over)` — чистое слияние (используется внутри `burst`). +Частицы — display-layer: все внутренние величины эмиттера — экранные px +относительно его origin; в мире эмиттер позиционируется через `worldToScreen`. ## Порядок отрисовки diff --git a/packages/engine/src/anim/SpriteMotion.ts b/packages/engine/src/anim/SpriteMotion.ts new file mode 100644 index 0000000..80835b1 --- /dev/null +++ b/packages/engine/src/anim/SpriteMotion.ts @@ -0,0 +1,62 @@ +/** + * Pixi-адаптер оживителей: применяет выборку sampleMotion к view (position + * относительно базовой, rotation, alpha, scale). База снимается в конструкторе + * — не позиционируйте view после создания. Тикается через engine.fx. + */ +import type { Container } from 'pixi.js'; +import type { Updatable } from '../core/Updatables'; +import { createRng } from '../math/rng'; +import { sampleMotion, type MotionParams } from './motion'; + +export interface SpriteMotionOptions { + /** Seed — детерминированная случайная фаза (рассинхрон кустов/искр). */ + seed?: number; +} + +export class SpriteMotion implements Updatable { + /** Живые параметры: меняйте на лету (колокол качается только при звоне). */ + params: MotionParams; + + private readonly view: Container; + private readonly baseX: number; + private readonly baseY: number; + private readonly baseAlpha: number; + private readonly baseScaleX: number; + private readonly baseScaleY: number; + private t = 0; + + constructor(view: Container, params: MotionParams, opts: SpriteMotionOptions = {}) { + this.view = view; + this.params = params; + this.baseX = view.x; + this.baseY = view.y; + this.baseAlpha = view.alpha; + this.baseScaleX = view.scale.x; + this.baseScaleY = view.scale.y; + if (opts.seed !== undefined) { + // Фаза из seed: доля периода, детерминированная (без Math.random). + const rng = createRng(opts.seed); + const phase = (rng.int(0, 1000) / 1000); + this.t = phase * (params.bob?.period ?? params.sway?.period ?? params.pulse?.period ?? params.blink?.period ?? 1); + } + } + + update(dt: number): void { + this.t += dt; + const s = sampleMotion(this.params, this.t); + this.view.x = this.baseX; + this.view.y = this.baseY + s.y; + this.view.rotation = s.angle; + this.view.alpha = this.baseAlpha * s.alpha; + this.view.scale.set(this.baseScaleX * s.scale, this.baseScaleY * s.scale); + } + + /** Вернуть исходные position/rotation/alpha/scale (при выходе из сцены). */ + detach(): void { + this.view.x = this.baseX; + this.view.y = this.baseY; + this.view.rotation = 0; + this.view.alpha = this.baseAlpha; + this.view.scale.set(this.baseScaleX, this.baseScaleY); + } +} \ No newline at end of file diff --git a/packages/engine/src/anim/__tests__/motion.test.ts b/packages/engine/src/anim/__tests__/motion.test.ts new file mode 100644 index 0000000..c757e8f --- /dev/null +++ b/packages/engine/src/anim/__tests__/motion.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { sampleMotion } from '../motion'; + +describe('sampleMotion', () => { + it('bob: синус по Y, округлён до целого px', () => { + const p = { bob: { amplitude: 2, period: 1 } }; + expect(sampleMotion(p, 0).y).toBe(0); // 0 + expect(sampleMotion(p, 0.25).y).toBe(2); // пик + expect(sampleMotion(p, 0.75).y).toBe(-2); // низ + // Между узлами — целые: субпиксельного дрожания нет + const mid = sampleMotion(p, 0.05).y; + expect(Number.isInteger(mid)).toBe(true); + }); + + it('sway: угол в рад, амплитуда соблюдена', () => { + const p = { sway: { amplitude: 0.1, period: 2 } }; + expect(sampleMotion(p, 0.5).angle).toBeCloseTo(0.1, 6); // четверть периода = пик + expect(sampleMotion(p, 1.5).angle).toBeCloseTo(-0.1, 6); + expect(sampleMotion(p, 0).angle).toBeCloseTo(0, 6); + }); + + it('pulse: границы min/max, треугольная волна', () => { + const p = { pulse: { min: 0.4, max: 1, period: 2 } }; + expect(sampleMotion(p, 0).alpha).toBeCloseTo(0.4); // старт с минимума + expect(sampleMotion(p, 1).alpha).toBeCloseTo(1); // середина = max + expect(sampleMotion(p, 2).alpha).toBeCloseTo(0.4); // период замкнулся + expect(sampleMotion(p, 1).scale).toBeCloseTo(1); + }); + + it('blink: строб 0/1 с duty', () => { + const p = { blink: { period: 1, duty: 0.25 } }; + expect(sampleMotion(p, 0).alpha).toBe(1); + expect(sampleMotion(p, 0.2).alpha).toBe(1); // внутри duty + expect(sampleMotion(p, 0.5).alpha).toBe(0); // вне duty + expect(sampleMotion(p, 0.95).alpha).toBe(0); + }); + + it('композиция: bob+sway+pulse+blink независимы', () => { + const p = { + bob: { amplitude: 2, period: 1 }, + sway: { amplitude: 0.1, period: 1 }, + pulse: { min: 0.5, max: 1, period: 1 }, + blink: { period: 1, duty: 1 } // всегда включён + }; + const s = sampleMotion(p, 0.25); + expect(s.y).toBe(2); + expect(s.angle).toBeCloseTo(0.1, 6); + expect(s.alpha).toBeCloseTo(0.75, 6); // pulse: tri(0.25)=0.5 → k=0.75, blink=1 + }); + + it('phase сдвигает волну (доля периода)', () => { + const p = { bob: { amplitude: 2, period: 1, phase: 0.25 } }; + expect(sampleMotion(p, 0).y).toBe(2); // при t=0 уже пик + }); + + it('пустые параметры — нейтральная выборка', () => { + const s = sampleMotion({}, 5); + expect(s).toEqual({ y: 0, angle: 0, alpha: 1, scale: 1 }); + }); + + it('волна замкнута на границе периода (без скачка)', () => { + const p = { bob: { amplitude: 3, period: 2 } }; + expect(sampleMotion(p, 0).y).toBe(sampleMotion(p, 2).y); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/anim/motion.ts b/packages/engine/src/anim/motion.ts new file mode 100644 index 0000000..085e6ad --- /dev/null +++ b/packages/engine/src/anim/motion.ts @@ -0,0 +1,92 @@ +/** + * Процедурные «оживители» статичных объектов — чистая математика без Pixi: + * синусные волны, композиция до четырёх одновременно. Фазы — в долях + * периода; случайная фаза берётся из seed снаружи (детерминизм). + */ + +export interface BobParams { + /** Амплитуда, px (округляется до целого — пиксель-арт не дрожит). */ + amplitude: number; + /** Полный период, сек. */ + period: number; + /** Фаза, доли периода (0..1). */ + phase?: number; +} + +export interface SwayParams { + /** Амплитуда, рад (вокруг основания view). */ + amplitude: number; + period: number; + phase?: number; +} + +export interface PulseParams { + /** Нижняя и верхняя граница пульса (alpha или scale). */ + min: number; + max: number; + period: number; + phase?: number; +} + +export interface BlinkParams { + period: number; + /** Доля периода в «включённом» состоянии (0..1), по умолчанию 0.5. */ + duty?: number; + phase?: number; +} + +export interface MotionParams { + bob?: BobParams; + sway?: SwayParams; + pulse?: PulseParams; + blink?: BlinkParams; +} + +/** Выборка оживителя в момент t (сек) относительно базового состояния. */ +export interface MotionSample { + /** Смещение по Y, px (целое). */ + y: number; + /** Поворот, рад. */ + angle: number; + /** Множитель альфы (произведение пульса и строба). */ + alpha: number; + /** Множитель масштаба. */ + scale: number; +} + +const TAU = Math.PI * 2; + +/** Фаза волны 0..1 в момент t для периода period и стартовой фазы phase. */ +function wave(t: number, period: number, phase = 0): number { + return ((t / period + phase) % 1 + 1) % 1; +} + +/** Выборка всех оживителей в момент t; параметры независимы, эффекты перемножаются. */ +export function sampleMotion(p: MotionParams, t: number): MotionSample { + let y = 0; + let angle = 0; + let alpha = 1; + let scale = 1; + if (p.bob) { + const w = wave(t, p.bob.period, p.bob.phase); + // Синус фазы 0..1 → -1..1; округление до целого px. + y = Math.round(Math.sin(w * TAU) * p.bob.amplitude); + } + if (p.sway) { + const w = wave(t, p.sway.period, p.sway.phase); + angle = Math.sin(w * TAU) * p.sway.amplitude; + } + if (p.pulse) { + const w = wave(t, p.pulse.period, p.pulse.phase); + // Треугольная волна 0..1..0 — пульс без резких скачков фазы. + const tri = 1 - Math.abs(w * 2 - 1); + const k = p.pulse.min + (p.pulse.max - p.pulse.min) * tri; + alpha *= k; + scale *= k; + } + if (p.blink) { + const w = wave(t, p.blink.period, p.blink.phase); + alpha *= w < (p.blink.duty ?? 0.5) ? 1 : 0; + } + return { y, angle, alpha, scale }; +} \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index df741f4..e2da04d 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -117,7 +117,10 @@ stepParticle, sampleSpawn, sampleBurst, - type ParticleState + mergeEmitterOptions, + type ParticleState, + type ParticleSimOptions, + type ParticleVisual } from './render/particleSim'; // input @@ -131,6 +134,10 @@ export type { SpriteClip, SpriteAnimatorOptions } from './anim/SpriteAnimator'; export { stepClip } from './anim/clip'; export type { ClipLoop, ClipDef, ClipTick } from './anim/clip'; +export { sampleMotion } from './anim/motion'; +export type { MotionParams, MotionSample, BobParams, SwayParams, PulseParams, BlinkParams } from './anim/motion'; +export { SpriteMotion } from './anim/SpriteMotion'; +export type { SpriteMotionOptions } from './anim/SpriteMotion'; // dialogue export { diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index 9d6e70b..8db326d 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -69,6 +69,17 @@ interface CellSprites { ground?: Sprite; object?: Sprite; + /** id тайла, по которому создан спрайт (для регистрации в анимациях). */ + groundId?: number; + objectId?: number; +} + +/** Анимация тайлов одного id: кадры, fps и общий таймлайн на все клетки. */ +interface TileAnim { + frames: Texture[]; + fps: number; + t: number; + sprites: Set; } export class IsometricTileMap implements Grid { @@ -87,6 +98,8 @@ private groundLayer: Container; private objectsLayer: Container; private cells = new Map(); + private anims = new Map(); + private spriteAnims = new Map(); get width(): number { return this.data.width; @@ -204,14 +217,49 @@ this.drawCell(x, y); } + /** + * Анимированный тайл: клетки с этим id переключают кадры по одному общему + * таймлайну (дёшево при сотнях клеток воды). Регистрирует и уже + * нарисованные клетки. frames — текстуры кадров (вода_1, вода_2, ...). + */ + setTileAnimation(id: number, frames: Texture[], fps: number): void { + if (frames.length === 0 || fps <= 0) return; + this.anims.set(id, { frames, fps, t: 0, sprites: new Set() }); + for (const cell of this.cells.values()) { + if (cell.ground && cell.groundId === id) this.registerAnim(cell.ground, id); + if (cell.object && cell.objectId === id) this.registerAnim(cell.object, id); + } + } + + /** Тик анимаций тайлов; из update сцены (no-op, если анимаций нет). */ + update(dt: number): void { + for (const anim of this.anims.values()) { + anim.t += dt; + const tex = anim.frames[Math.floor(anim.t * anim.fps) % anim.frames.length]; + for (const s of anim.sprites) s.texture = tex; + } + } + + private registerAnim(sprite: Sprite, id: number): void { + this.anims.get(id)?.sprites.add(sprite); + this.spriteAnims.set(sprite, id); + } + + private unregisterAnim(sprite: Sprite): void { + const id = this.spriteAnims.get(sprite); + if (id === undefined) return; + this.anims.get(id)?.sprites.delete(sprite); + this.spriteAnims.delete(sprite); + } + /** Нарисовать (или перерисовать после setTile) одну ячейку карты. */ private drawCell(tx: number, ty: number): void { const iso = this.iso; const id = this.data.tiles[ty * this.data.width + tx]; const key = ty * this.data.width + tx; const old = this.cells.get(key); - if (old?.ground) old.ground.destroy(); - if (old?.object) old.object.destroy(); + if (old?.ground) { this.unregisterAnim(old.ground); old.ground.destroy(); } + if (old?.object) { this.unregisterAnim(old.object); old.object.destroy(); } const fresh: CellSprites = {}; this.cells.set(key, fresh); @@ -220,6 +268,7 @@ // Земля: под высоким объектом рисуем его ground-тайл (или плейсхолдер). const groundId = tall?.ground ?? id; + fresh.groundId = groundId; const groundTex = this.textures.get(groundId); if (groundTex) { const s = new Sprite(groundTex); @@ -227,6 +276,7 @@ s.position.set(p.x, p.y); this.groundLayer.addChild(s); fresh.ground = s; + this.registerAnim(s, groundId); } else { // Нет текстуры — плейсхолдер-ромб: зелёный (проходимо) или коричневый (блок). const g = new Graphics(); @@ -239,6 +289,7 @@ // Высокий объект: спрайт с якорем в центре ромба, иначе колонна. if (tall) { + fresh.objectId = id; const objTex = this.textures.get(id); if (objTex) { const s = new Sprite(objTex); @@ -246,6 +297,7 @@ s.position.set(p.x, p.y + iso.tileH / 2); this.objectsLayer.addChild(s); fresh.object = s; + this.registerAnim(s, id); } else { this.objectsLayer.addChild(this.drawPlaceholder(p.x, p.y + iso.tileH / 2, 1, 1, tall.height)); } diff --git a/packages/engine/src/render/IsoDepthLayer.ts b/packages/engine/src/render/IsoDepthLayer.ts index 60a018d..5ed363a 100644 --- a/packages/engine/src/render/IsoDepthLayer.ts +++ b/packages/engine/src/render/IsoDepthLayer.ts @@ -41,4 +41,15 @@ setDepthRect(view: Container, x: number, y: number, w: number, h: number): void { view.zIndex = x + w - 1 + (y + h - 1); } + + /** + * Эффект на тайле (искры удара, кольца): как add, но с надбавкой bias — + * искры поверх актора на том же тайле. Движущийся эффект — setDepth + * в том же месте, где обновляется глубина вью. + */ + addFx(view: Container, tx: number, ty: number, bias = 0.5): Container { + view.zIndex = tx + ty + bias; + this.addChild(view); + return view; + } } \ No newline at end of file diff --git a/packages/engine/src/render/Particles.ts b/packages/engine/src/render/Particles.ts index fd40da2..0dcaa8e 100644 --- a/packages/engine/src/render/Particles.ts +++ b/packages/engine/src/render/Particles.ts @@ -1,15 +1,21 @@ import { Container, Sprite, Texture } from 'pixi.js'; import { createRng, type Rng } from '../math/rng'; -import { stepParticle, sampleSpawn, sampleBurst, type ParticleState } from './particleSim'; +import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState } from './particleSim'; /** - * Простая система частиц: эмиттер спавнит точки (Texture.WHITE, подкрашенные) - * с заданной скоростью, временем жизни и затуханием. - * Используется для атмосферы (пепел, мотыльки, пыль) и эффектов (взрывы — burst/oneShot). + * Система частиц: квадраты (Texture.WHITE) или текстуры, цвета/кривые по + * жизни, пул спрайтов с лимитом. Используется для атмосферы (пепел, мотыльки) + * и эффектов (взрывы — burst/oneShot). Тикается через engine.fx либо вручную. */ export interface EmitterOptions { - /** Цвет частиц (0xRRGGBB). */ - color: number; + /** Цвет частиц (0xRRGGBB); по умолчанию белый (для текстур — без подкраски). */ + color?: number; + /** Вариация: случайный цвет из списка при спавне (вместо color). */ + colors?: number[]; + /** Цвет по жизни: лерп 0xRRGGBB от начала к концу. */ + colorOverLife?: [number, number]; + /** Текстура частицы вместо подкрашенного квадрата. */ + texture?: Texture; /** Частиц в секунду. */ rate: number; /** Время жизни частицы, секунды (min..max). */ @@ -18,12 +24,26 @@ velocity: { x: [number, number]; y: [number, number] }; /** Ускорение (гравитация/ветер), пикселей/сек². */ acceleration?: { x?: number; y?: number }; - /** Размер частицы в пикселях (1..4). */ - size?: number; + /** Размер квадрата в px (1..4) или диапазон размеров. */ + size?: number | [number, number]; + /** Множитель масштаба текстурных частиц. */ + scale?: number; + /** Начальный угол, рад (min..max). */ + rotation?: [number, number]; + /** Скорость вращения, рад/сек (min..max). */ + spin?: [number, number]; + /** Масштаб по жизни: диапазон или предустановка ('shrink'/'grow'). */ + scaleOverLife?: [number, number] | 'shrink' | 'grow'; + /** Сек нарастания альфы в начале жизни. */ + fadeIn?: number; + /** Затухание альфы: true (по умолчанию) — вся жизнь; число — хвост в сек. */ + fadeOut?: boolean | number; + /** Торможение, 1/сек (искры, пух). */ + drag?: number; + /** Амплитуда синус-дрейфа по X, px/сек (мотыльки). */ + wobble?: number; /** Спавн в прямоугольнике вокруг позиции эмиттера (по умолчанию точка). */ spawnArea?: { width: number; height: number }; - /** Затухание альфы к концу жизни (по умолчанию true). */ - fadeOut?: boolean; /** Seed для повторяемого движения. */ seed?: number; /** @@ -31,6 +51,10 @@ * (для burst-взрывов). Если задан — velocity игнорируется. */ radialSpeed?: [number, number]; + /** Лимит живых частиц (пул переиспользуется), по умолчанию 256. */ + max?: number; + /** Режим смешивания: 'add' — светящиеся (огонь, искры, мотыльки). */ + blend?: 'normal' | 'add'; } interface Particle { @@ -40,6 +64,7 @@ export class ParticleEmitter extends Container { private particles: Particle[] = []; + private readonly free: Sprite[] = []; private spawnTimer = 0; private rng: Rng; private selfDestruct = false; @@ -59,54 +84,76 @@ // Обновление через чистую симуляцию for (const p of this.particles) { - const alpha = stepParticle(p.state, dt, this.options.acceleration, this.options.fadeOut ?? true); + const v = stepParticle(p.state, dt, this.options); p.sprite.x = p.state.x; p.sprite.y = p.state.y; - p.sprite.alpha = alpha; + p.sprite.alpha = v.alpha; + p.sprite.tint = v.tint; + p.sprite.rotation = v.rotation; + if (this.options.texture) { + p.sprite.scale.set(v.scale); + } else { + p.sprite.width = v.scale; + p.sprite.height = v.scale; + } } - // Сбор «умерших» + // «Умершие» — спрайт в пул, не destroy const dead = this.particles.filter((p) => p.state.age >= p.state.lifetime); - for (const p of dead) { - p.sprite.destroy(); - } if (dead.length > 0) { + for (const p of dead) { + p.sprite.visible = false; + p.sprite.alpha = 0; + this.free.push(p.sprite); + } this.particles = this.particles.filter((p) => p.state.age < p.state.lifetime); } // oneShot: все частицы умерли и новые не рождаются — уничтожиться if (this.selfDestruct && this.particles.length === 0 && this.options.rate <= 0) { - this.destroy(); + this.onFinish?.(); + // children: pooled-спрайты — дети контейнера, с ними же и уничтожаются + this.destroy({ children: true }); } } + /** Вызывается один раз при самоуничтожении oneShot (цепочки эффектов). */ + onFinish?: () => void; + /** Убрать все частицы (например, при выходе со сцены). */ clear(): void { for (const p of this.particles) p.sprite.destroy(); + for (const s of this.free) s.destroy(); this.particles = []; + this.free.length = 0; } get count(): number { return this.particles.length; } + /** Заменить опции эмиттера (цвет/скорость/кривые) на лету. */ + setOptions(opts: Partial): void { + this.options = { ...this.options, ...opts }; + } + /** * Мгновенно выпустить count частиц (взрыв/удар): игнорирует rate. - * Опции можно переопределить (цвет, radialSpeed, lifetime...). + * opts — разовые переопределения; базовые опции эмиттера не мутируют. */ burst(count: number, opts?: Partial): void { - if (opts) this.options = { ...this.options, ...opts }; - if (this.options.radialSpeed) { - for (const s of sampleBurst(count, this.options.radialSpeed, this.options.lifetime, this.rng)) { - this.addParticle(s); + const o = mergeEmitterOptions(this.options, opts); + if (o.radialSpeed) { + for (const s of sampleBurst(count, o.radialSpeed, o.lifetime, this.rng, o)) { + this.addParticle(s, o); } } else { - for (let i = 0; i < count; i++) this.spawn(); + for (let i = 0; i < count; i++) this.spawnFrom(o); } } /** - * Одноразовый эмиттер-взрыв: spawнит count частиц сразу и уничтожает себя, - * когда все умерли. Добавить на сцену и обновлять как обычный эмиттер. + * Одноразовый эмиттер-взрыв: спавнит count частиц сразу и уничтожает себя, + * когда все умерли. Добавить в world и в engine.fx — тик и зачистка сами. */ static oneShot(count: number, opts: EmitterOptions): ParticleEmitter { const e = new ParticleEmitter({ ...opts, rate: 0 }); @@ -115,18 +162,35 @@ return e; } - private addParticle(state: ParticleState): void { - const size = this.options.size ?? 2; - const sprite = new Sprite(Texture.WHITE); - sprite.width = size; - sprite.height = size; - sprite.tint = this.options.color; - sprite.alpha = 0; // появится в следующем кадре без «вспышки» - this.particles.push({ state, sprite }); - this.addChild(sprite); + private spawn(): void { + this.spawnFrom(this.options); } - private spawn(): void { - this.addParticle(sampleSpawn(this.options, this.rng)); + private spawnFrom(options: EmitterOptions): void { + this.addParticle(sampleSpawn(options, this.rng), options); + } + + private addParticle(state: ParticleState, options: EmitterOptions): void { + const max = options.max ?? 256; + if (this.particles.length >= max) return; + const sprite = this.free.pop() ?? this.makeSprite(options); + const size = typeof options.size === 'number' ? options.size + : options.size ? options.size[0] + this.rng.next() * (options.size[1] - options.size[0]) + : 2; + // Базовый размер частицы — в state.scale (кривые по жизни умножают его). + state.scale = options.texture ? options.scale ?? 1 : size; + sprite.visible = true; + sprite.alpha = 0; // появится в следующем кадре без «вспышки» + sprite.tint = state.tint; + sprite.rotation = state.rot; + this.particles.push({ state, sprite }); + if (sprite.parent !== this) this.addChild(sprite); + } + + private makeSprite(options: EmitterOptions): Sprite { + const sprite = new Sprite(options.texture ?? Texture.WHITE); + if (options.texture) sprite.anchor.set(0.5, 0.5); + sprite.blendMode = options.blend === 'add' ? 'add' : 'normal'; + return sprite; } } \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/particleSim.test.ts b/packages/engine/src/render/__tests__/particleSim.test.ts index c94d06b..a43b6a2 100644 --- a/packages/engine/src/render/__tests__/particleSim.test.ts +++ b/packages/engine/src/render/__tests__/particleSim.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { stepParticle, sampleSpawn, sampleBurst } from '../particleSim'; +import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState } from '../particleSim'; import { createRng } from '../../math/rng'; import type { EmitterOptions } from '../Particles'; @@ -10,27 +10,68 @@ velocity: { x: [-10, 10], y: [-10, 10] } }; +function state(over: Partial = {}): ParticleState { + return { x: 0, y: 0, vx: 0, vy: 0, age: 0, lifetime: 2, rot: 0, spin: 0, scale: 1, tint: 0xffffff, k: 0, ...over }; +} + describe('stepParticle', () => { it('позиция после шагов (полу-неявный Эйлер)', () => { - const p = { x: 0, y: 0, vx: 10, vy: -5, age: 0, lifetime: 10 }; - stepParticle(p, 0.5, undefined, false); + const p = state({ vx: 10, vy: -5, lifetime: 10 }); + stepParticle(p, 0.5, {}); expect(p.x).toBeCloseTo(5); expect(p.y).toBeCloseTo(-2.5); expect(p.age).toBeCloseTo(0.5); }); it('ускорение влияет на скорость', () => { - const p = { x: 0, y: 0, vx: 0, vy: 0, age: 0, lifetime: 10 }; - stepParticle(p, 1, { y: 100 }, false); + const p = state({ lifetime: 10 }); + stepParticle(p, 1, { acceleration: { y: 100 } }); expect(p.vy).toBeCloseTo(100); expect(p.y).toBeCloseTo(100); // скорость применилась в том же шаге }); - it('fadeOut уменьшает alpha, без fadeOut alpha = 1', () => { - const a = { x: 0, y: 0, vx: 0, vy: 0, age: 0, lifetime: 2 }; - expect(stepParticle(a, 1, undefined, true)).toBeCloseTo(0.5); - const b = { x: 0, y: 0, vx: 0, vy: 0, age: 0, lifetime: 2 }; - expect(stepParticle(b, 1, undefined, false)).toBe(1); + it('fadeOut: true — вся жизнь; число — хвост в сек; false — без затухания', () => { + const a = state(); + expect(stepParticle(a, 1, { fadeOut: true }).alpha).toBeCloseTo(0.5); // 1 из 2 сек + const b = state({ lifetime: 2 }); + expect(stepParticle(b, 1, { fadeOut: 0.5 }).alpha).toBe(1); // хвост ещё далеко + const c = state({ age: 0.75 }); + expect(stepParticle(c, 1, { fadeOut: 0.5 }).alpha).toBeCloseTo(0.5); // после шага age=1.75, хвост 0.5 + const d = state(); + expect(stepParticle(d, 1, { fadeOut: false }).alpha).toBe(1); + }); + + it('fadeIn наращивает альфу в начале', () => { + const p = state(); + expect(stepParticle(p, 0.25, { fadeIn: 1 }).alpha).toBeCloseTo(0.25); + }); + + it('drag тормозит', () => { + const p = state({ vx: 100 }); + stepParticle(p, 0.5, { drag: 2 }); // v *= 1 - 1.0 + expect(p.vx).toBeCloseTo(0); + }); + + it('spin вращает, scaleOverLife меняет масштаб', () => { + const a = state({ spin: Math.PI }); + expect(stepParticle(a, 0.5, {}).rotation).toBeCloseTo(Math.PI / 2); + const b = state({ scale: 4 }); + expect(stepParticle(b, 1, { scaleOverLife: [1, 2] }).scale).toBeCloseTo(6); // 4 × 1.5 + const c = state({ scale: 4 }); + expect(stepParticle(c, 1, { scaleOverLife: 'shrink' }).scale).toBeCloseTo(2); // 4 × 0.5 + }); + + it('colorOverLife лерпит цвет по жизни', () => { + const p = state({ lifetime: 2 }); + const v = stepParticle(p, 1, { colorOverLife: [0xff0000, 0x0000ff] }); + expect(v.tint).toBe(0x800080); + }); + + it('wobble дрейфует по синусу с фазой из k', () => { + const p = state({ k: 0, lifetime: 10 }); + const before = p.x; + stepParticle(p, 0.1, { wobble: 10 }); // sin(0.3) > 0 → дрейф вправо + expect(p.x).toBeGreaterThan(before); }); }); @@ -56,12 +97,30 @@ expect(s.x).toBe(0); expect(s.y).toBe(0); }); + + it('colors выбираются из списка, spin/rotation из диапазонов', () => { + const rng = createRng(3); + const o: EmitterOptions = { + ...opts, + colors: [0xff0000, 0x00ff00], + rotation: [0, 1], + spin: [-2, 2] + }; + for (let i = 0; i < 30; i++) { + const s = sampleSpawn(o, rng); + expect([0xff0000, 0x00ff00]).toContain(s.tint); + expect(s.rot).toBeGreaterThanOrEqual(0); + expect(s.rot).toBeLessThanOrEqual(1); + expect(s.spin).toBeGreaterThanOrEqual(-2); + expect(s.spin).toBeLessThanOrEqual(2); + } + }); }); describe('sampleBurst', () => { it('нужное количество, скорость в диапазоне', () => { const rng = createRng(5); - const parts = sampleBurst(12, [30, 80], [0.2, 0.5], rng); + const parts = sampleBurst(12, [30, 80], [0.2, 0.5], rng, { color: 0x52525c }); expect(parts).toHaveLength(12); for (const p of parts) { const v = Math.hypot(p.vx, p.vy); @@ -69,6 +128,7 @@ expect(v).toBeLessThanOrEqual(80 + 1e-9); expect(p.lifetime).toBeGreaterThanOrEqual(0.2); expect(p.lifetime).toBeLessThanOrEqual(0.5); + expect(p.tint).toBe(0x52525c); } }); @@ -77,4 +137,20 @@ const b = sampleBurst(5, [10, 20], [0.1, 0.2], createRng(99)); expect(a).toEqual(b); }); +}); + +describe('mergeEmitterOptions', () => { + it('слияет чисто: база и переопределение не мутируются', () => { + const base: EmitterOptions = { ...opts }; + const over = { color: 0xff0000, lifetime: [0.1, 0.2] as [number, number] }; + const merged = mergeEmitterOptions(base, over); + expect(merged.color).toBe(0xff0000); + expect(merged.lifetime).toEqual([0.1, 0.2]); + expect(base.color).toBe(0xffffff); // база цела + expect(base.lifetime).toEqual([1, 2]); + expect(merged.velocity).toBe(base.velocity); // непереопределённые — те же ссылки + const back = mergeEmitterOptions(base); + expect(back).not.toBe(base); + expect(back).toEqual(base); + }); }); \ No newline at end of file diff --git a/packages/engine/src/render/particleSim.ts b/packages/engine/src/render/particleSim.ts index 825f85a..3be064f 100644 --- a/packages/engine/src/render/particleSim.ts +++ b/packages/engine/src/render/particleSim.ts @@ -4,6 +4,7 @@ */ import type { Rng } from '../math/rng'; +import { lerpColor } from '../anim/flash'; import type { EmitterOptions } from './Particles'; /** Состояние частицы без привязки к отображению. */ @@ -15,21 +16,82 @@ age: number; /** Секунд до смерти. */ lifetime: number; + /** Угол (рад) и скорость вращения (рад/сек). */ + rot: number; + spin: number; + /** Базовый размер: px для квадратов, множитель для текстурных частиц. */ + scale: number; + /** Цвет, выбранный при спавне (из color/colors). */ + tint: number; + /** Персональное «зерно» 0..1: фаза wobble и прочая вариация. */ + k: number; } -/** Один шаг интеграции (полу-неявный Эйлер). Возвращает alpha с учётом fadeOut. */ -export function stepParticle( - p: ParticleState, - dt: number, - acceleration: { x?: number; y?: number } | undefined, - fadeOut: boolean -): number { +/** Опции симуляции — общие для всех частиц эмиттера. */ +export interface ParticleSimOptions { + acceleration?: { x?: number; y?: number }; + /** Затухание альфы: true — вся жизнь; число — хвост в секундах. */ + fadeOut?: boolean | number; + /** Сек нарастания альфы в начале жизни. */ + fadeIn?: number; + /** Торможение, 1/сек: v *= max(0, 1 - drag*dt). */ + drag?: number; + /** Масштаб по жизни: явный диапазон или предустановка. */ + scaleOverLife?: [number, number] | 'shrink' | 'grow'; + /** Цвет по жизни: лерп 0xRRGGBB от начала к концу. */ + colorOverLife?: [number, number]; + /** Амплитуда синус-дрейфа по X, px/сек (мотыльки, планктон). */ + wobble?: number; +} + +/** Видимая часть частицы на шаге (базовый размер уже учтён в scale). */ +export interface ParticleVisual { + alpha: number; + scale: number; + rotation: number; + tint: number; +} + +/** + * Один шаг интеграции (полу-неявный Эйлер) + видимые кривые по жизни. + * Мутирует p (позиция/скорость/угол/возраст), возвращает визуал. + */ +export function stepParticle(p: ParticleState, dt: number, opts: ParticleSimOptions): ParticleVisual { p.age += dt; - p.vx += (acceleration?.x ?? 0) * dt; - p.vy += (acceleration?.y ?? 0) * dt; - p.x += p.vx * dt; + const drag = Math.max(0, 1 - (opts.drag ?? 0) * dt); + p.vx *= drag; + p.vy *= drag; + p.vx += (opts.acceleration?.x ?? 0) * dt; + p.vy += (opts.acceleration?.y ?? 0) * dt; + const wobble = opts.wobble ? Math.sin(p.age * 3 + p.k * Math.PI * 2) * opts.wobble : 0; + p.x += (p.vx + wobble) * dt; p.y += p.vy * dt; - return fadeOut ? Math.max(0, 1 - p.age / p.lifetime) : 1; + p.rot += p.spin * dt; + + // Альфа: нарастание в начале × затухание в конце. + const fadeIn = opts.fadeIn ? Math.min(1, p.age / opts.fadeIn) : 1; + const fadeTail = opts.fadeOut === false ? 1 + : opts.fadeOut === true || opts.fadeOut === undefined + ? 1 - p.age / p.lifetime + : Math.min(1, (p.lifetime - p.age) / opts.fadeOut); + const alpha = Math.max(0, Math.min(fadeIn, fadeTail)); + + // Масштаб: кривая по жизни, умноженная на базовый размер частицы. + const lifeK = Math.min(1, p.age / p.lifetime); + const curve = opts.scaleOverLife === 'shrink' + ? 1 - lifeK + : opts.scaleOverLife === 'grow' + ? lifeK + : opts.scaleOverLife + ? opts.scaleOverLife[0] + (opts.scaleOverLife[1] - opts.scaleOverLife[0]) * lifeK + : 1; + + return { + alpha, + scale: Math.max(0, p.scale * curve), + rotation: p.rot, + tint: opts.colorOverLife ? lerpColor(opts.colorOverLife[0], opts.colorOverLife[1], lifeK) : p.tint + }; } /** Случайная начальная частица по опциям эмиттера (позиция относительно центра). */ @@ -43,19 +105,25 @@ vy: v.y[0] + rng.next() * (v.y[1] - v.y[0]), age: 0, lifetime: - options.lifetime[0] + rng.next() * (options.lifetime[1] - options.lifetime[0]) + options.lifetime[0] + rng.next() * (options.lifetime[1] - options.lifetime[0]), + rot: sampleRange(options.rotation, rng), + spin: sampleRange(options.spin, rng), + scale: options.scale ?? 1, + tint: options.colors ? options.colors[rng.int(0, options.colors.length - 1)] : options.color ?? 0xffffff, + k: rng.next() }; } /** * Случайный «взрыв»: частицы разлетаются из точки по кругу. - * speed — (min..max) пикселей/сек; возвращает состояния для эмиттера. + * speed — (min..max) пикселей/сек; spin/scale — опциональная вариация. */ export function sampleBurst( count: number, speed: [number, number], lifetime: [number, number], - rng: Rng + rng: Rng, + opts: { spin?: [number, number]; scale?: number; colors?: number[]; color?: number } = {} ): ParticleState[] { const out: ParticleState[] = []; for (let i = 0; i < count; i++) { @@ -67,8 +135,22 @@ vx: Math.cos(angle) * v, vy: Math.sin(angle) * v, age: 0, - lifetime: lifetime[0] + rng.next() * (lifetime[1] - lifetime[0]) + lifetime: lifetime[0] + rng.next() * (lifetime[1] - lifetime[0]), + rot: sampleRange(opts.spin, rng) + angle, + spin: 0, + scale: opts.scale ?? 1, + tint: opts.colors ? opts.colors[rng.int(0, opts.colors.length - 1)] : opts.color ?? 0xffffff, + k: rng.next() }); } return out; +} + +/** Слияние базовых опций с переопределением burst-а — чистое, без мутации. */ +export function mergeEmitterOptions(base: EmitterOptions, over?: Partial): EmitterOptions { + return over ? { ...base, ...over } : { ...base }; +} + +function sampleRange(range: [number, number] | undefined, rng: Rng): number { + return range ? range[0] + rng.next() * (range[1] - range[0]) : 0; } \ No newline at end of file