diff --git a/docs/engine/core.md b/docs/engine/core.md index d87f055..4f4ea11 100644 --- a/docs/engine/core.md +++ b/docs/engine/core.md @@ -90,6 +90,27 @@ sm.update(dt); // тикает текущее состояние; sm.time — время в состоянии ``` +Переход «из любого состояния» — для событий вроде hurt/die, чтобы не регистрировать +их из каждого состояния (обычные переходы имеют приоритет): + +```ts +sm.transitionAny('die', 'dead'); +``` + +## Cooldown + +Кулдаун на фиксированном шаге (секунды, без `performance.now()`): + +```ts +const cd = new Cooldown(0.45); +cd.trigger(); // запустить (false, если ещё не готов) +cd.triggerForce(); // перезапустить принудительно +cd.update(dt); // тикать каждый шаг +cd.ready; // готов? +cd.timeRemaining; // секунд до готовности +cd.progress; // 1..0 — удобно для полоски перезарядки +``` + ## Settings Настройки игрока (громкости, язык, прочее) в отдельном слоте хранилища — не попадают diff --git a/docs/engine/input.md b/docs/engine/input.md index 59eac4d..e2e41ad 100644 --- a/docs/engine/input.md +++ b/docs/engine/input.md @@ -17,8 +17,13 @@ engine.input.isActionJustPressed('advance'); // нажато в этом тике engine.input.isActionActive('up'); // зажато +engine.input.isActionJustReleased('attack'); // отпущено в этом тике (клавиши+геймпад) ``` +Отпускание и удержание нужны для заряжаемых действий (замах, резонанс). Для точных +кодов есть `wasKeyPressed(code)` / `wasKeyReleased(code)`; у указателя — +`pointer.downTicks` (сколько тиков удержана кнопка, 0 — только нажата). + ## Геймпад Стандартная карта кнопок (0=A, 1=B, 8=Select, 9=Start, 12–15 — крестовина). diff --git a/docs/engine/render.md b/docs/engine/render.md index cde9525..5e49df6 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -40,6 +40,18 @@ Позиция округляется до целого пикселя (`apply`), поэтому арт не дрожит при движении. +Тряска — для ударов, взрывов и урона. Движок сам тикает камеру (`camera.update(dt)` +внутри фиксированного шага), игре достаточно запустить толчок: + +```ts +camera.addShake(3, 0.25); // амплитуда 3 px, 0.25 сек +camera.shaking; // true, пока трясёт +camera.clearShake(); // сбросить +``` + +Смещение детерминировано (внутри `Shake` — движковый `Rng`) и округляется до целых +пикселей, так что пиксель-арт не размывается. + ## IsoDepthLayer Сортировка глубины для сущностей на изометрической карте: глубина = `tx + ty` @@ -87,6 +99,26 @@ moths.clear(); moths.destroy(); ``` +Для боевых эффектов (удар, смерть, попадание) есть разовые всплески: + +```ts +// burst: мгновенно выпустить частицы из существующего эмиттера +hitFx.burst(12, { color: 0x999988, radialSpeed: [40, 90], lifetime: [0.2, 0.5] }); + +// oneShot: самостоятельный эмиттер-взрыв, уничтожается сам, когда все частицы умерли +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 +}); +boom.position.set(x, y); +worldRoot.addChild(boom); +// обновлять в update сцены: boom.update(dt) +``` + +Интеграция частицы (`stepParticle`, `sampleSpawn`, `sampleBurst`) вынесена в чистый +модуль `render/particleSim` — её можно тикать и без Pixi (например, в тестах боя). + ## Порядок отрисовки Стек сцен рисуется снизу вверх; внутри сцены порядок задают дочерние контейнеры diff --git a/packages/engine/src/anim/__tests__/flash.test.ts b/packages/engine/src/anim/__tests__/flash.test.ts new file mode 100644 index 0000000..0fe5f2d --- /dev/null +++ b/packages/engine/src/anim/__tests__/flash.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { TintFlash, lerpColor } from '../flash'; + +describe('TintFlash', () => { + it('не активен до start', () => { + const f = new TintFlash(0xffffff, 0xff0000, 0.2); + expect(f.active).toBe(false); + expect(f.update(0.1)).toBeNull(); + }); + + it('в начале — исходный цвет, в середине — ближе к вспышке', () => { + const f = new TintFlash(0xffffff, 0xff0000, 0.2); + f.start(); + expect(f.update(0)).toBe(0xffffff); + const mid = f.update(0.1); // середина: максимум вспышки + expect(mid).toBe(0xff0000); + }); + + it('к концу возвращается к исходному, затем null', () => { + const f = new TintFlash(0xffffff, 0xff0000, 0.2); + f.start(); + f.update(0.19); + const end = f.update(0.05); // пересекли границу + expect(end).toBe(0xffffff); + expect(f.active).toBe(false); + expect(f.update(0.05)).toBeNull(); + }); + + it('повторный запуск работает', () => { + const f = new TintFlash(0xffffff, 0x00ff00, 0.1); + f.start(); + f.update(0.2); // завершён + f.start(); + expect(f.active).toBe(true); + expect(f.update(0)).toBe(0xffffff); + }); +}); + +describe('lerpColor', () => { + it('края и середина', () => { + expect(lerpColor(0x000000, 0xffffff, 0)).toBe(0x000000); + expect(lerpColor(0x000000, 0xffffff, 1)).toBe(0xffffff); + expect(lerpColor(0x000000, 0xffffff, 0.5)).toBe(0x808080); + }); + + it('по каналам, не по всему числу', () => { + // от красного к синему: середина — фиолетовый (0x800080), а не «серый мусор» + expect(lerpColor(0xff0000, 0x0000ff, 0.5)).toBe(0x800080); + expect(lerpColor(0x102030, 0x102030, 0.7)).toBe(0x102030); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/anim/flash.ts b/packages/engine/src/anim/flash.ts new file mode 100644 index 0000000..d0e9a9a --- /dev/null +++ b/packages/engine/src/anim/flash.ts @@ -0,0 +1,62 @@ +/** + * Хит-флэш: лерп цвета tint от исходного к «вспышке» и обратно. + * Чистая логика (без Pixi) — результат применимо к любому числовому свойству. + */ + +export class TintFlash { + private elapsed: number | null = null; + /** tint на момент запуска (возвращается к нему по окончании). */ + private base: number; + + /** + * @param from исходный tint (например 0xffffff) + * @param to цвет вспышки + * @param seconds полная длительность (туда-обратно) + */ + constructor( + from: number, + private readonly to: number, + private readonly seconds: number + ) { + this.base = from; + } + + /** Запустить (перезапускается с нуля). */ + start(): void { + this.elapsed = 0; + } + + /** + * Обновить; возвращает текущий tint или null, если флэш не активен. + * При окончании возвращает исходный цвет. + */ + update(dt: number): number | null { + if (this.elapsed === null) return null; + this.elapsed += dt; + if (this.elapsed >= this.seconds) { + this.elapsed = null; + return this.base; + } + // треугольная волна: 0..1..0 + const t = 1 - Math.abs(this.elapsed / this.seconds - 0.5) * 2; + return lerpColor(this.base, this.to, t); + } + + get active(): boolean { + return this.elapsed !== null; + } +} + +/** Лерп двух цветов 0xRRGGBB по каналам. */ +export function lerpColor(a: number, b: number, t: number): number { + const ar = (a >> 16) & 0xff; + const ag = (a >> 8) & 0xff; + const ab = a & 0xff; + const br = (b >> 16) & 0xff; + const bg = (b >> 8) & 0xff; + const bb = b & 0xff; + const r = Math.round(ar + (br - ar) * t); + const g = Math.round(ag + (bg - ag) * t); + const bl = Math.round(ab + (bb - ab) * t); + return (r << 16) | (g << 8) | bl; +} \ No newline at end of file diff --git a/packages/engine/src/core/Cooldown.ts b/packages/engine/src/core/Cooldown.ts new file mode 100644 index 0000000..c1624fa --- /dev/null +++ b/packages/engine/src/core/Cooldown.ts @@ -0,0 +1,45 @@ +/** + * Кулдаун на фиксированном шаге: триггер -> отсчёт -> готовность. + * Чистая логика без Pixi — тестируется в Vitest. + */ +export class Cooldown { + private remaining = 0; + + constructor(private readonly seconds: number) {} + + /** Запустить отсчёт заново (только если готов — см. triggerForce для перебива). */ + trigger(): boolean { + if (!this.ready) return false; + this.remaining = this.seconds; + return true; + } + + /** Перезапустить отсчёт даже если не готов. */ + triggerForce(): void { + this.remaining = this.seconds; + } + + update(dt: number): void { + if (this.remaining > 0) { + this.remaining = Math.max(0, this.remaining - dt); + } + } + + get ready(): boolean { + return this.remaining <= 0; + } + + /** Секунд до готовности (0 — уже готов). */ + get timeRemaining(): number { + return this.remaining; + } + + /** 1 — только запущен, 0 — готов. */ + get progress(): number { + return this.seconds > 0 ? this.remaining / this.seconds : 0; + } + + reset(): void { + this.remaining = 0; + } +} \ No newline at end of file diff --git a/packages/engine/src/core/Engine.ts b/packages/engine/src/core/Engine.ts index fe1eaf9..27a111e 100644 --- a/packages/engine/src/core/Engine.ts +++ b/packages/engine/src/core/Engine.ts @@ -90,6 +90,7 @@ /** Один фиксированный шаг. */ private tick(dt: number): void { this.input?.update(); // опрос геймпадов до логики + this.camera.update(dt); // тряска и прочее «живое» поведение камеры this.tweens.update(dt); this.scenes.update(dt); this.input?.endTick(); diff --git a/packages/engine/src/core/StateMachine.ts b/packages/engine/src/core/StateMachine.ts index e9e48e5..45afa6c 100644 --- a/packages/engine/src/core/StateMachine.ts +++ b/packages/engine/src/core/StateMachine.ts @@ -15,6 +15,8 @@ private states = new Map(); /** (состояние, событие) -> состояние-цель. */ private transitions = new Map(); + /** Переходы «из любого состояния» (кроме self-loop): событие -> цель. */ + private anyTransitions = new Map(); private currentName: string | null = null; private timeInState = 0; @@ -49,10 +51,23 @@ this.states.get(name)!.enter?.(); } - /** Обработать событие: если из текущего состояния есть переход — выполнить его. */ + /** + * Переход по событию из ЛЮБОГО состояния (кроме повторного входа в цель). + * Удобно для hurt/die в бою: не регистрировать переход из каждого состояния. + * Обычные переходы имеют приоритет над transitionAny. + */ + transitionAny(event: string, to: string): this { + if (!this.states.has(to)) { + throw new Error(`Переход в неизвестное состояние: ${to}`); + } + this.anyTransitions.set(event, to); + return this; + } + + /** Обработать событие: обычный переход текущего состояния, затем «из любого». */ handleEvent(event: string): void { if (!this.currentName) return; - const to = this.transitions.get(`${this.currentName}::${event}`); + const to = this.transitions.get(`${this.currentName}::${event}`) ?? this.anyTransitions.get(event); if (to) { this.change(to); } else { diff --git a/packages/engine/src/core/__tests__/Cooldown.test.ts b/packages/engine/src/core/__tests__/Cooldown.test.ts new file mode 100644 index 0000000..3ac915f --- /dev/null +++ b/packages/engine/src/core/__tests__/Cooldown.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { Cooldown } from '../Cooldown'; + +describe('Cooldown', () => { + it('изначально готов', () => { + const cd = new Cooldown(1); + expect(cd.ready).toBe(true); + expect(cd.timeRemaining).toBe(0); + expect(cd.progress).toBe(0); + }); + + it('trigger запускает отсчёт, update уменьшает', () => { + const cd = new Cooldown(0.5); + expect(cd.trigger()).toBe(true); + expect(cd.ready).toBe(false); + cd.update(0.2); + expect(cd.timeRemaining).toBeCloseTo(0.3); + cd.update(0.3); + expect(cd.ready).toBe(true); + }); + + it('trigger не срабатывает, пока не готов', () => { + const cd = new Cooldown(1); + cd.trigger(); + expect(cd.trigger()).toBe(false); + cd.update(1); + expect(cd.trigger()).toBe(true); + }); + + it('triggerForce перебивает', () => { + const cd = new Cooldown(2); + cd.trigger(); + cd.update(1.5); + cd.triggerForce(); + expect(cd.timeRemaining).toBeCloseTo(2); + }); + + it('progress: 1 сразу после trigger, 0 к концу', () => { + const cd = new Cooldown(1); + cd.trigger(); + expect(cd.progress).toBeCloseTo(1); + cd.update(0.5); + expect(cd.progress).toBeCloseTo(0.5); + cd.update(0.5); + expect(cd.progress).toBe(0); + }); + + it('обновление больше остатка не уводит в минус', () => { + const cd = new Cooldown(0.1); + cd.trigger(); + cd.update(5); + expect(cd.ready).toBe(true); + expect(cd.timeRemaining).toBe(0); + }); + + it('нулевой кулдаун всегда готов', () => { + const cd = new Cooldown(0); + cd.triggerForce(); + expect(cd.ready).toBe(true); + expect(cd.progress).toBe(0); + }); + + it('reset возвращает готовность', () => { + const cd = new Cooldown(3); + cd.trigger(); + cd.reset(); + expect(cd.ready).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/StateMachine.test.ts b/packages/engine/src/core/__tests__/StateMachine.test.ts index dba4cd4..e2f7bea 100644 --- a/packages/engine/src/core/__tests__/StateMachine.test.ts +++ b/packages/engine/src/core/__tests__/StateMachine.test.ts @@ -70,4 +70,54 @@ const sm = new StateMachine(); expect(() => sm.change('nope')).toThrow(); }); + + it('transitionAny срабатывает из любого состояния', () => { + const sm = new StateMachine(); + sm.add('idle', {}); + sm.add('chase', {}); + sm.add('windup', {}); + sm.add('dead', { enter: () => deadEntered++ }); + sm.transition('idle', 'wake', 'chase'); + sm.transitionAny('die', 'dead'); + let deadEntered = 0; + + sm.change('idle'); + sm.handleEvent('die'); + expect(sm.current).toBe('dead'); + expect(deadEntered).toBe(1); + + // и после смены состояния переход «из любого» продолжает работать + sm.change('windup'); + sm.handleEvent('die'); + expect(sm.current).toBe('dead'); + expect(deadEntered).toBe(2); + }); + + it('обычный переход приоритетнее transitionAny', () => { + const sm = new StateMachine(); + sm.add('a', {}); + sm.add('b', {}); + sm.add('c', {}); + sm.transitionAny('go', 'b'); + sm.transition('a', 'go', 'c'); + sm.change('a'); + sm.handleEvent('go'); + expect(sm.current).toBe('c'); + }); + + it('transitionAny в неизвестное состояние бросает', () => { + const sm = new StateMachine(); + sm.add('a', {}); + expect(() => sm.transitionAny('go', 'nope')).toThrow(); + }); + + it('transitionAny не даёт повторного входа в то же состояние', () => { + let enters = 0; + const sm = new StateMachine(); + sm.add('loop', { enter: () => enters++ }); + sm.transitionAny('again', 'loop'); + sm.change('loop'); + sm.handleEvent('again'); + expect(enters).toBe(1); + }); }); \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 5c02b06..c8b8016 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -23,6 +23,7 @@ // state (см. core) export { GameState, type GameStateData } from './core/GameState'; export { StateMachine, type StateDef } from './core/StateMachine'; +export { Cooldown } from './core/Cooldown'; export { Settings, type SettingsData } from './core/Settings'; // scene @@ -41,6 +42,12 @@ type IsoLayout } from './math/iso'; export { createRng, type Rng } from './math/rng'; +export { + inCircle, + inCone, + angleBetween, + nearest +} from './math/shapes'; // map export { IsometricTileMap, type TileMapData, type TallSpec } from './map/IsometricTileMap'; @@ -59,6 +66,14 @@ export { IsoDepthLayer } from './render/IsoDepthLayer'; export { computeScale } from './render/scale'; export { ParticleEmitter, type EmitterOptions } from './render/Particles'; +export { Shake } from './render/shake'; +export { SpriteFlash } from './render/spriteFx'; +export { + stepParticle, + sampleSpawn, + sampleBurst, + type ParticleState +} from './render/particleSim'; // input export { InputManager, type PointerState } from './input/InputManager'; @@ -66,6 +81,7 @@ // anim export { FrameAnimation } from './anim/FrameAnimation'; +export { TintFlash, lerpColor } from './anim/flash'; // dialogue export { diff --git a/packages/engine/src/input/InputManager.ts b/packages/engine/src/input/InputManager.ts index 22e0ef8..fdcd797 100644 --- a/packages/engine/src/input/InputManager.ts +++ b/packages/engine/src/input/InputManager.ts @@ -14,6 +14,8 @@ justReleased: boolean; /** true, если это событие тача (для мобильного управления). */ isTouch: boolean; + /** Сколько фиксированных шагов удержана (0 — только нажата). */ + downTicks: number; } export interface PointerEventHandlers { @@ -25,6 +27,7 @@ export class InputManager { private keysDown = new Set(); private keysPressed = new Set(); + private keysReleased = new Set(); private keyActions = new Map(); // --- геймпад (стандартная карта кнопок) --- @@ -32,6 +35,7 @@ private padActions = new Map(); private padDown = new Set(); private padPressed = new Set(); + private padReleased = new Set(); private pointer: PointerState = { x: 0, @@ -39,7 +43,8 @@ down: false, justPressed: false, justReleased: false, - isTouch: false + isTouch: false, + downTicks: 0 }; private canvas: HTMLCanvasElement; @@ -91,6 +96,7 @@ */ update(): void { this.padPressed.clear(); + this.padReleased.clear(); if (typeof navigator === 'undefined' || !navigator.getGamepads) return; for (const pad of navigator.getGamepads()) { if (!pad) continue; @@ -99,6 +105,9 @@ if (pressed && !this.padDown.has(i)) { this.padPressed.add(i); } + if (!pressed && this.padDown.has(i)) { + this.padReleased.add(i); + } if (pressed) { this.padDown.add(i); } else { @@ -151,6 +160,20 @@ return false; } + /** Действие отпущено именно в этом тике (клавиатура или геймпад). */ + isActionJustReleased(action: string): boolean { + for (const code of this.keysReleased) { + if (this.keyActions.get(code) === action) return true; + } + const padButtons = this.padActions.get(action); + if (padButtons) { + for (const b of padButtons) { + if (this.padReleased.has(b)) return true; + } + } + return false; + } + isKeyDown(code: string): boolean { return this.keysDown.has(code); } @@ -159,16 +182,27 @@ return this.keysPressed.has(code); } + wasKeyReleased(code: string): boolean { + return this.keysReleased.has(code); + } + getPointer(): Readonly { return this.pointer; } - /** Вызывается в конце каждого тика: очищает «just pressed/released». */ + /** Вызывается в конце каждого тика: очищает «just pressed/released», копит downTicks. */ endTick(): void { this.keysPressed.clear(); + this.keysReleased.clear(); this.padPressed.clear(); + this.padReleased.clear(); this.pointer.justPressed = false; this.pointer.justReleased = false; + if (this.pointer.down) { + this.pointer.downTicks += 1; + } else { + this.pointer.downTicks = 0; + } } dispose(): void { @@ -188,6 +222,7 @@ private onKeyUp = (e: KeyboardEvent): void => { this.keysDown.delete(e.code); + this.keysReleased.add(e.code); }; private toVirtual(e: PointerEvent): { x: number; y: number } { @@ -204,6 +239,7 @@ this.pointer.y = y; this.pointer.down = true; this.pointer.justPressed = true; + this.pointer.downTicks = 0; this.pointer.isTouch = e.pointerType === 'touch'; this.handlers.onPointerDown?.(this.pointer); }; diff --git a/packages/engine/src/input/__tests__/InputManager.test.ts b/packages/engine/src/input/__tests__/InputManager.test.ts new file mode 100644 index 0000000..a5ae251 --- /dev/null +++ b/packages/engine/src/input/__tests__/InputManager.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { InputManager, type PointerState } from '../InputManager'; + +/** + * Фейковые canvas/window: достаточно, чтобы InputManager работал без браузера. + * События диспатчим напрямую в сохранённые обработчики. + */ +type Handler = (e: unknown) => void; + +function makeFakeEnv() { + const canvasListeners = new Map(); + const windowListeners = new Map(); + const canvas = { + addEventListener: (type: string, fn: Handler) => canvasListeners.set(type, fn), + removeEventListener: (type: string) => canvasListeners.delete(type), + getBoundingClientRect: () => ({ left: 0, top: 0, width: 480, height: 270 }) + }; + (globalThis as Record).window = { + addEventListener: (type: string, fn: Handler) => windowListeners.set(type, fn), + removeEventListener: (type: string) => windowListeners.delete(type) + }; + return { canvas, canvasListeners, windowListeners } as const; +} + +function keyEvent(code: string) { + return { code, pointerType: 'mouse', clientX: 0, clientY: 0 }; +} + +function pointerEvent(x: number, y: number, type = 'mouse') { + return { clientX: x, clientY: y, pointerType: type }; +} + +describe('InputManager (фейковый canvas/window)', () => { + let input: InputManager; + let env: ReturnType; + + beforeEach(() => { + env = makeFakeEnv(); + input = new InputManager(env.canvas as unknown as HTMLCanvasElement, 480, 270); + input.bindActions({ attack: ['Space'], advance: ['Enter'] }); + }); + + it('justPressed/justReleased по клавиатуре, endTick очищает', () => { + const kd = env.windowListeners.get('keydown')!; + const ku = env.windowListeners.get('keyup')!; + + kd(keyEvent('Space')); + expect(input.isActionJustPressed('attack')).toBe(true); + expect(input.isActionActive('attack')).toBe(true); + input.endTick(); + + expect(input.isActionJustPressed('attack')).toBe(false); + expect(input.isActionActive('attack')).toBe(true); + + ku(keyEvent('Space')); + expect(input.wasKeyReleased('Space')).toBe(true); + expect(input.isActionJustReleased('attack')).toBe(true); + expect(input.isActionActive('attack')).toBe(false); + input.endTick(); + expect(input.isActionJustReleased('attack')).toBe(false); + }); + + it('isActionJustReleased не путает действия', () => { + const kd = env.windowListeners.get('keydown')!; + const ku = env.windowListeners.get('keyup')!; + kd(keyEvent('Space')); + input.endTick(); + ku(keyEvent('Space')); + expect(input.isActionJustReleased('attack')).toBe(true); + expect(input.isActionJustReleased('advance')).toBe(false); + }); + + it('указатель: downTicks копится, justPressed сбрасывается в endTick', () => { + const pd = env.canvasListeners.get('pointerdown')!; + const pu = env.canvasListeners.get('pointerup')!; + + pd(pointerEvent(240, 135)); + const p = input.getPointer() as PointerState; + expect(p.justPressed).toBe(true); + expect(p.downTicks).toBe(0); + expect(p.x).toBe(240); + input.endTick(); + + expect(p.justPressed).toBe(false); + expect(p.down).toBe(true); + input.endTick(); + expect(p.downTicks).toBe(2); // накопился за два тика удержания + + pu(pointerEvent(240, 135)); + expect(p.justReleased).toBe(true); + expect(p.down).toBe(false); + input.endTick(); + expect(p.downTicks).toBe(0); + }); + + it('координаты переводятся в виртуальное разрешение', () => { + const pm = env.canvasListeners.get('pointermove')!; + // фейковый rect: 0..480x270 1:1, проверяем смещение + pm(pointerEvent(96, 54)); + const p = input.getPointer(); + expect(p.x).toBe(96); + expect(p.y).toBe(54); + }); + + it('isTouch помечает тач-события', () => { + const pd = env.canvasListeners.get('pointerdown')!; + pd(pointerEvent(0, 0, 'touch')); + expect(input.getPointer().isTouch).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/math/__tests__/shapes.test.ts b/packages/engine/src/math/__tests__/shapes.test.ts new file mode 100644 index 0000000..b188f2c --- /dev/null +++ b/packages/engine/src/math/__tests__/shapes.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { inCircle, inCone, angleBetween, nearest } from '../shapes'; + +describe('inCircle', () => { + it('точка внутри и на границе', () => { + const c = { x: 0, y: 0 }; + expect(inCircle(c, 10, { x: 3, y: 4 })).toBe(true); + expect(inCircle(c, 5, { x: 3, y: 4 })).toBe(true); // ровно 5 + expect(inCircle(c, 5, { x: 3, y: 5 })).toBe(false); + expect(inCircle(c, 0, { x: 0, y: 0 })).toBe(true); + }); + + it('точка вне центра', () => { + expect(inCircle({ x: 100, y: 50 }, 10, { x: 0, y: 0 })).toBe(false); + // расстояние sqrt(50² + 30²) ≈ 58.3 + expect(inCircle({ x: 100, y: 50 }, 60, { x: 50, y: 20 })).toBe(true); + }); +}); + +describe('inCone', () => { + const from = { x: 0, y: 0 }; + const dir = { x: 1, y: 0 }; // смотрит вправо + const halfAngle = Math.PI / 4; // 45° + + it('внутри конуса', () => { + expect(inCone(from, dir, 50, halfAngle, { x: 30, y: 10 })).toBe(true); + expect(inCone(from, dir, 50, halfAngle, { x: 30, y: -10 })).toBe(true); + }); + + it('за пределами дальности', () => { + expect(inCone(from, dir, 50, halfAngle, { x: 60, y: 0 })).toBe(false); + }); + + it('за границей угла («слепая зона» за спиной)', () => { + expect(inCone(from, dir, 50, halfAngle, { x: 30, y: 40 })).toBe(false); + expect(inCone(from, dir, 50, halfAngle, { x: -30, y: 0 })).toBe(false); + }); + + it('вблизи границы угла и радиуса', () => { + // чуть внутри границы угла 45° (tan 45°=1) + expect(inCone(from, dir, 50, halfAngle, { x: 10, y: 9.9 })).toBe(true); + // чуть снаружи + expect(inCone(from, dir, 50, halfAngle, { x: 10, y: 10.1 })).toBe(false); + // расстояние ровно 50 — граница радиуса включена + expect(inCone(from, dir, 50, halfAngle, { x: 50, y: 0 })).toBe(true); + expect(inCone(from, dir, 50, halfAngle, { x: 50.1, y: 0 })).toBe(false); + }); + + it('нулевое направление — только дальность', () => { + expect(inCone(from, { x: 0, y: 0 }, 50, halfAngle, { x: 0, y: -40 })).toBe(true); + expect(inCone(from, { x: 0, y: 0 }, 50, halfAngle, { x: 60, y: 0 })).toBe(false); + }); +}); + +describe('angleBetween', () => { + it('известные углы', () => { + expect(angleBetween({ x: 1, y: 0 }, { x: 0, y: 1 })).toBeCloseTo(Math.PI / 2); + expect(angleBetween({ x: 1, y: 0 }, { x: -1, y: 0 })).toBeCloseTo(Math.PI); + expect(angleBetween({ x: 2, y: 0 }, { x: 5, y: 0 })).toBeCloseTo(0); + }); + + it('нулевой вектор даёт 0 (без NaN)', () => { + expect(angleBetween({ x: 0, y: 0 }, { x: 1, y: 0 })).toBe(0); + expect(angleBetween({ x: 1, y: 0 }, { x: 0, y: 0 })).toBe(0); + }); +}); + +describe('nearest', () => { + it('выбирает ближайшую', () => { + const list = [ + { x: 50, y: 0 }, + { x: 10, y: 0 }, + { x: 30, y: 0 } + ]; + expect(nearest(list, { x: 0, y: 0 })).toEqual({ x: 10, y: 0 }); + }); + + it('учитывает maxRange', () => { + const list = [{ x: 100, y: 0 }]; + expect(nearest(list, { x: 0, y: 0 }, 50)).toBeNull(); + expect(nearest(list, { x: 0, y: 0 }, 100)).toEqual({ x: 100, y: 0 }); + }); + + it('пустой список — null', () => { + expect(nearest([], { x: 0, y: 0 })).toBeNull(); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/math/shapes.ts b/packages/engine/src/math/shapes.ts new file mode 100644 index 0000000..8ccc78f --- /dev/null +++ b/packages/engine/src/math/shapes.ts @@ -0,0 +1,57 @@ +/** + * Простейшие проверки фигур и выбора целей — чистая математика без Pixi. + * Используется для боёв (конус удара, радиус взрыва), триггеров и подбора ближайшего. + */ + +import type { Vec2 } from './Vec2'; + +/** Точка внутри круга (включая границу). */ +export function inCircle(center: Vec2, radius: number, point: Vec2): boolean { + const dx = point.x - center.x; + const dy = point.y - center.y; + return dx * dx + dy * dy <= radius * radius; +} + +/** Угол между векторами a и b (радианы, 0..PI). */ +export function angleBetween(a: Vec2, b: Vec2): number { + const la = Math.hypot(a.x, a.y); + const lb = Math.hypot(b.x, b.y); + if (la === 0 || lb === 0) return 0; + let cos = (a.x * b.x + a.y * b.y) / (la * lb); + cos = Math.min(1, Math.max(-1, cos)); + return Math.acos(cos); +} + +/** + * Точка внутри конуса: в пределах range от from и угол до dir не больше halfAngle. + * Нулевое направление — конус «схлопывается» в отрезок по range (угол не проверяется). + */ +export function inCone( + from: Vec2, + dir: Vec2, + range: number, + halfAngle: number, + point: Vec2 +): boolean { + const dx = point.x - from.x; + const dy = point.y - from.y; + if (dx * dx + dy * dy > range * range) return false; + if (dir.x === 0 && dir.y === 0) return true; + return angleBetween(dir, { x: dx, y: dy }) <= halfAngle; +} + +/** Ближайшая к from точка из списка (в пределах maxRange включительно), или null. */ +export function nearest(list: T[], from: Vec2, maxRange = Infinity): T | null { + let best: T | null = null; + let bestDist = maxRange * maxRange; + for (const p of list) { + const dx = p.x - from.x; + const dy = p.y - from.y; + const d = dx * dx + dy * dy; + if (d <= bestDist) { + bestDist = d; + best = p; + } + } + return best; +} \ No newline at end of file diff --git a/packages/engine/src/render/Camera.ts b/packages/engine/src/render/Camera.ts index 3f427ca..39c4fc4 100644 --- a/packages/engine/src/render/Camera.ts +++ b/packages/engine/src/render/Camera.ts @@ -1,10 +1,12 @@ import { Container } from 'pixi.js'; +import { Shake } from './shake'; /** * Камера виртуального разрешения: позиция — центр взгляда в мировых (виртуальных) пикселях. * Округляет смещение до целого пикселя, чтобы пиксель-арт не «дрожал». * Границы прокрутки — прямоугольник мира (может начинаться с отрицательных координат, * например у изометрической карты, «ромб» которой уходит в минус по X). + * Поддерживает тряску (addShake) — движок вызывает update(dt) каждый тик. */ export interface CameraBounds { x: number; @@ -17,6 +19,7 @@ x = 0; y = 0; bounds: CameraBounds | null = null; + private readonly shake = new Shake(); constructor( private readonly viewWidth: number, @@ -29,10 +32,32 @@ this.clamp(); } + /** Запустить толчок: амплитуда в пикселях, длительность в секундах. */ + addShake(strength: number, duration: number): void { + this.shake.add(strength, duration); + } + + clearShake(): void { + this.shake.clear(); + } + + get shaking(): boolean { + return this.shake.active; + } + + /** Обновление тряски — движок вызывает каждый фиксированный шаг. */ + update(dt: number): void { + this.shake.update(dt); + } + apply(container: Container): void { + const o = this.shake.offset; const halfW = Math.floor(this.viewWidth / 2); const halfH = Math.floor(this.viewHeight / 2); - container.position.set(halfW - Math.round(this.x), halfH - Math.round(this.y)); + container.position.set( + halfW - Math.round(this.x) + o.x, + halfH - Math.round(this.y) + o.y + ); } private clamp(): void { diff --git a/packages/engine/src/render/Particles.ts b/packages/engine/src/render/Particles.ts index e6b6e6b..fd40da2 100644 --- a/packages/engine/src/render/Particles.ts +++ b/packages/engine/src/render/Particles.ts @@ -1,10 +1,11 @@ import { Container, Sprite, Texture } from 'pixi.js'; -import { Rng, createRng } from '../math/rng'; +import { createRng, type Rng } from '../math/rng'; +import { stepParticle, sampleSpawn, sampleBurst, type ParticleState } from './particleSim'; /** * Простая система частиц: эмиттер спавнит точки (Texture.WHITE, подкрашенные) * с заданной скоростью, временем жизни и затуханием. - * Используется для атмосферы: пепел, мотыльки, пыль, искры. + * Используется для атмосферы (пепел, мотыльки, пыль) и эффектов (взрывы — burst/oneShot). */ export interface EmitterOptions { /** Цвет частиц (0xRRGGBB). */ @@ -25,20 +26,23 @@ fadeOut?: boolean; /** Seed для повторяемого движения. */ seed?: number; + /** + * Радиальный разброс вместо velocity: скорость частиц (min..max) по кругу + * (для burst-взрывов). Если задан — velocity игнорируется. + */ + radialSpeed?: [number, number]; } interface Particle { + state: ParticleState; sprite: Sprite; - vx: number; - vy: number; - age: number; - lifetime: number; } export class ParticleEmitter extends Container { private particles: Particle[] = []; private spawnTimer = 0; private rng: Rng; + private selfDestruct = false; constructor(private options: EmitterOptions) { super(); @@ -46,34 +50,32 @@ } update(dt: number): void { - // Спавн + // Спавн по rate (для burst-эмиттеров rate = 0) this.spawnTimer += dt * this.options.rate; while (this.spawnTimer >= 1) { this.spawnTimer -= 1; this.spawn(); } - // Обновление - const accX = this.options.acceleration?.x ?? 0; - const accY = this.options.acceleration?.y ?? 0; + // Обновление через чистую симуляцию for (const p of this.particles) { - p.age += dt; - p.vx += accX * dt; - p.vy += accY * dt; - p.sprite.x += p.vx * dt; - p.sprite.y += p.vy * dt; - if (this.options.fadeOut ?? true) { - p.sprite.alpha = Math.max(0, 1 - p.age / p.lifetime); - } + const alpha = stepParticle(p.state, dt, this.options.acceleration, this.options.fadeOut ?? true); + p.sprite.x = p.state.x; + p.sprite.y = p.state.y; + p.sprite.alpha = alpha; } // Сбор «умерших» - const dead = this.particles.filter((p) => p.age >= p.lifetime); + const dead = this.particles.filter((p) => p.state.age >= p.state.lifetime); for (const p of dead) { p.sprite.destroy(); } if (dead.length > 0) { - this.particles = this.particles.filter((p) => p.age < p.lifetime); + 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(); } } @@ -87,26 +89,44 @@ return this.particles.length; } - private spawn(): void { + /** + * Мгновенно выпустить count частиц (взрыв/удар): игнорирует rate. + * Опции можно переопределить (цвет, radialSpeed, lifetime...). + */ + 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); + } + } else { + for (let i = 0; i < count; i++) this.spawn(); + } + } + + /** + * Одноразовый эмиттер-взрыв: spawнит count частиц сразу и уничтожает себя, + * когда все умерли. Добавить на сцену и обновлять как обычный эмиттер. + */ + static oneShot(count: number, opts: EmitterOptions): ParticleEmitter { + const e = new ParticleEmitter({ ...opts, rate: 0 }); + e.selfDestruct = true; + e.burst(count); + 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; - - const area = this.options.spawnArea; - sprite.x = area ? this.rng.int(0, area.width) - area.width / 2 : 0; - sprite.y = area ? this.rng.int(0, area.height) - area.height / 2 : 0; sprite.alpha = 0; // появится в следующем кадре без «вспышки» - - const v = this.options.velocity; - this.particles.push({ - sprite, - vx: v.x[0] + this.rng.next() * (v.x[1] - v.x[0]), - vy: v.y[0] + this.rng.next() * (v.y[1] - v.y[0]), - age: 0, - lifetime: this.options.lifetime[0] + this.rng.next() * (this.options.lifetime[1] - this.options.lifetime[0]) - }); + this.particles.push({ state, sprite }); this.addChild(sprite); } + + private spawn(): void { + this.addParticle(sampleSpawn(this.options, this.rng)); + } } \ 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 new file mode 100644 index 0000000..c94d06b --- /dev/null +++ b/packages/engine/src/render/__tests__/particleSim.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { stepParticle, sampleSpawn, sampleBurst } from '../particleSim'; +import { createRng } from '../../math/rng'; +import type { EmitterOptions } from '../Particles'; + +const opts: EmitterOptions = { + color: 0xffffff, + rate: 10, + lifetime: [1, 2], + velocity: { x: [-10, 10], y: [-10, 10] } +}; + +describe('stepParticle', () => { + it('позиция после шагов (полу-неявный Эйлер)', () => { + const p = { x: 0, y: 0, vx: 10, vy: -5, age: 0, lifetime: 10 }; + stepParticle(p, 0.5, undefined, false); + 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); + 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); + }); +}); + +describe('sampleSpawn', () => { + it('учитывает spawnArea и диапазоны', () => { + const rng = createRng(1); + for (let i = 0; i < 50; i++) { + const s = sampleSpawn({ ...opts, spawnArea: { width: 20, height: 10 } }, rng); + expect(s.x).toBeGreaterThanOrEqual(-10); + expect(s.x).toBeLessThanOrEqual(10); + expect(s.y).toBeGreaterThanOrEqual(-5); + expect(s.y).toBeLessThanOrEqual(5); + expect(s.vx).toBeGreaterThanOrEqual(-10); + expect(s.vx).toBeLessThanOrEqual(10); + expect(s.lifetime).toBeGreaterThanOrEqual(1); + expect(s.lifetime).toBeLessThanOrEqual(2); + expect(s.age).toBe(0); + } + }); + + it('без области — старт из точки', () => { + const s = sampleSpawn(opts, createRng(1)); + expect(s.x).toBe(0); + expect(s.y).toBe(0); + }); +}); + +describe('sampleBurst', () => { + it('нужное количество, скорость в диапазоне', () => { + const rng = createRng(5); + const parts = sampleBurst(12, [30, 80], [0.2, 0.5], rng); + expect(parts).toHaveLength(12); + for (const p of parts) { + const v = Math.hypot(p.vx, p.vy); + expect(v).toBeGreaterThanOrEqual(30 - 1e-9); + expect(v).toBeLessThanOrEqual(80 + 1e-9); + expect(p.lifetime).toBeGreaterThanOrEqual(0.2); + expect(p.lifetime).toBeLessThanOrEqual(0.5); + } + }); + + it('детерминирован по seed', () => { + const a = sampleBurst(5, [10, 20], [0.1, 0.2], createRng(99)); + const b = sampleBurst(5, [10, 20], [0.1, 0.2], createRng(99)); + expect(a).toEqual(b); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/shake.test.ts b/packages/engine/src/render/__tests__/shake.test.ts new file mode 100644 index 0000000..c4e5ff4 --- /dev/null +++ b/packages/engine/src/render/__tests__/shake.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { Shake } from '../shake'; + +describe('Shake', () => { + it('неактивен без толчков, offset нулевой', () => { + const s = new Shake(); + expect(s.active).toBe(false); + expect(s.offset).toEqual({ x: 0, y: 0 }); + }); + + it('толчок с нулевой/отрицательной силой игнорируется', () => { + const s = new Shake(); + s.add(0, 1); + s.add(5, 0); + s.add(-3, 1); + expect(s.active).toBe(false); + }); + + it('активен во время действия и выключается по окончании', () => { + const s = new Shake(); + s.add(4, 0.5); + expect(s.active).toBe(true); + s.update(0.3); + expect(s.active).toBe(true); + s.update(0.3); + expect(s.active).toBe(false); + expect(s.offset).toEqual({ x: 0, y: 0 }); + }); + + it('амплитуда не превышает силу толчка и затухает', () => { + const s = new Shake(42); + s.add(6, 1); + let maxAbs = 0; + for (let i = 0; i < 60; i++) { + s.update(1 / 60); + const { x, y } = s.offset; + expect(Math.abs(x)).toBeLessThanOrEqual(6); + expect(Math.abs(y)).toBeLessThanOrEqual(6); + maxAbs = Math.max(maxAbs, Math.abs(x)); + } + // в какой-то из ранних кадров смещение ощутимо + expect(maxAbs).toBeGreaterThan(0); + }); + + it('одинаковый seed — одинаковые смещения', () => { + const a = new Shake(7); + const b = new Shake(7); + a.add(5, 0.3); + b.add(5, 0.3); + for (let i = 0; i < 20; i++) { + a.update(1 / 60); + b.update(1 / 60); + expect(a.offset).toEqual(b.offset); + } + }); + + it('offset округляется до целых пикселей', () => { + const s = new Shake(1); + s.add(0.5, 0.1); + s.update(1 / 120); + const { x, y } = s.offset; + expect(Number.isInteger(x)).toBe(true); + expect(Number.isInteger(y)).toBe(true); + }); + + it('два толчка складываются, clear сбрасывает', () => { + const s = new Shake(9); + s.add(3, 1); + s.add(3, 1); + s.update(0.1); + expect(s.active).toBe(true); + s.clear(); + expect(s.active).toBe(false); + expect(s.offset).toEqual({ x: 0, y: 0 }); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/render/particleSim.ts b/packages/engine/src/render/particleSim.ts new file mode 100644 index 0000000..825f85a --- /dev/null +++ b/packages/engine/src/render/particleSim.ts @@ -0,0 +1,74 @@ +/** + * Чистая симуляция частицы — без Pixi, тестируется в Vitest. + * ParticleEmitter использует эти функции для интеграции и спавна. + */ + +import type { Rng } from '../math/rng'; +import type { EmitterOptions } from './Particles'; + +/** Состояние частицы без привязки к отображению. */ +export interface ParticleState { + x: number; + y: number; + vx: number; + vy: number; + age: number; + /** Секунд до смерти. */ + lifetime: number; +} + +/** Один шаг интеграции (полу-неявный Эйлер). Возвращает alpha с учётом fadeOut. */ +export function stepParticle( + p: ParticleState, + dt: number, + acceleration: { x?: number; y?: number } | undefined, + fadeOut: boolean +): number { + p.age += dt; + p.vx += (acceleration?.x ?? 0) * dt; + p.vy += (acceleration?.y ?? 0) * dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + return fadeOut ? Math.max(0, 1 - p.age / p.lifetime) : 1; +} + +/** Случайная начальная частица по опциям эмиттера (позиция относительно центра). */ +export function sampleSpawn(options: EmitterOptions, rng: Rng): ParticleState { + const area = options.spawnArea; + const v = options.velocity; + return { + x: area ? rng.int(0, area.width) - area.width / 2 : 0, + y: area ? rng.int(0, area.height) - area.height / 2 : 0, + vx: v.x[0] + rng.next() * (v.x[1] - v.x[0]), + 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]) + }; +} + +/** + * Случайный «взрыв»: частицы разлетаются из точки по кругу. + * speed — (min..max) пикселей/сек; возвращает состояния для эмиттера. + */ +export function sampleBurst( + count: number, + speed: [number, number], + lifetime: [number, number], + rng: Rng +): ParticleState[] { + const out: ParticleState[] = []; + for (let i = 0; i < count; i++) { + const angle = rng.next() * Math.PI * 2; + const v = speed[0] + rng.next() * (speed[1] - speed[0]); + out.push({ + x: 0, + y: 0, + vx: Math.cos(angle) * v, + vy: Math.sin(angle) * v, + age: 0, + lifetime: lifetime[0] + rng.next() * (lifetime[1] - lifetime[0]) + }); + } + return out; +} \ No newline at end of file diff --git a/packages/engine/src/render/shake.ts b/packages/engine/src/render/shake.ts new file mode 100644 index 0000000..7ac829b --- /dev/null +++ b/packages/engine/src/render/shake.ts @@ -0,0 +1,56 @@ +/** + * Тряска камеры: несколько одновременных толчков, затухание линейно по времени. + * Чистая логика (без Pixi) — смещение детерминировано seed'ом, тестируется в Vitest. + */ + +import { createRng, type Rng } from '../math/rng'; + +interface ShakeEntry { + strength: number; + duration: number; + elapsed: number; +} + +export class Shake { + private entries: ShakeEntry[] = []; + private rng: Rng; + private offsetX = 0; + private offsetY = 0; + + constructor(seed = 0x5eed1234) { + this.rng = createRng(seed); + } + + /** Добавить толчок: амплитуда в пикселях, длительность в секундах. */ + add(strength: number, duration: number): void { + if (strength <= 0 || duration <= 0) return; + this.entries.push({ strength, duration, elapsed: 0 }); + } + + update(dt: number): void { + this.offsetX = 0; + this.offsetY = 0; + for (const e of this.entries) { + e.elapsed += dt; + const k = Math.max(0, 1 - e.elapsed / e.duration); // линейное затухание + this.offsetX += (this.rng.next() * 2 - 1) * e.strength * k; + this.offsetY += (this.rng.next() * 2 - 1) * e.strength * k; + } + this.entries = this.entries.filter((e) => e.elapsed < e.duration); + } + + /** Текущее смещение в пикселях (0 вне активной тряски). */ + get offset(): { x: number; y: number } { + return { x: Math.round(this.offsetX), y: Math.round(this.offsetY) }; + } + + get active(): boolean { + return this.entries.length > 0; + } + + clear(): void { + this.entries = []; + this.offsetX = 0; + this.offsetY = 0; + } +} \ No newline at end of file diff --git a/packages/engine/src/render/spriteFx.ts b/packages/engine/src/render/spriteFx.ts new file mode 100644 index 0000000..c719e62 --- /dev/null +++ b/packages/engine/src/render/spriteFx.ts @@ -0,0 +1,35 @@ +import { Sprite } from 'pixi.js'; +import { TintFlash } from '../anim/flash'; + +/** + * Тонкий Pixi-адаптер над anim/flash: хит-флэш спрайта. + * Логика лерпа — в TintFlash (тестируется), здесь только применение tint. + */ +export class SpriteFlash { + private flash: TintFlash | null = null; + + constructor( + private readonly sprite: Sprite, + private readonly flashColor: number, + private readonly seconds = 0.15 + ) {} + + /** Запустить вспышку (перезапускается с нуля). */ + start(): void { + if (!this.flash) { + this.flash = new TintFlash(this.sprite.tint, this.flashColor, this.seconds); + } + this.flash.start(); + } + + /** Обновлять каждый тик владельцем (например, боевой системой). */ + update(dt: number): void { + if (!this.flash) return; + const tint = this.flash.update(dt); + if (tint !== null) { + this.sprite.tint = tint; + } else { + this.flash = null; + } + } +} \ No newline at end of file