diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index 9776e29..b694cd2 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -54,7 +54,7 @@ // Sfx на спек-синтезе не грузим — их компилирует движок. .then(async () => { for (const key of [...AMBIENCE_KEYS, ...AMBIENCE_LAYER_KEYS]) { - await this.game.audio.load(key); + await this.game.audio.register(key); await this.game.audio.preload(key); } }) diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index a33b121..26992f6 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -536,7 +536,7 @@ this.game.renderer.uiRoot.addChild(this.joystick); // Дебаг-оверлей (F3). - this.debug = new DebugOverlay(false); + this.debug = new DebugOverlay(this.game.engine.fpsMeter, false); this.game.renderer.uiRoot.addChild(this.debug.view); // Дебаг спрайта героя (P): текущий кадр, увеличенный с пиксельной сеткой. diff --git a/docs/engine/README.md b/docs/engine/README.md index 7e87807..a429c98 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -72,7 +72,7 @@ dialogue/ DialogueRunner (графы диалогов, view-агностик) cutscene/ CutsceneRunner (кат-сцены из шагов, view-агностик) ui/ DialogueBox, Panel, Button, MenuList, PixelText - anim/ FrameAnimation + anim/ SpriteAnimator, SpriteMotion audio/ AudioManager (шины master/music/sfx/ambience, лупы, pan/rate) assets/ AssetLoader (текстуры, атласы) save/ SaveManager (JSON-слоты) diff --git a/docs/engine/anim.md b/docs/engine/anim.md index b5c0343..c6e14ed 100644 --- a/docs/engine/anim.md +++ b/docs/engine/anim.md @@ -12,7 +12,6 @@ anim/SpriteAnimator.ts кадры → Sprite, именованные клипы, события anim/motion.ts чистые «оживители» статики (bob/sway/pulse/blink) anim/SpriteMotion.ts Pixi-адаптер оживителей (view → колебания) -anim/FrameAnimation.ts @deprecated-обёртка совместимости над SpriteAnimator ``` ## Реестр тика: `engine.fx` @@ -62,10 +61,6 @@ `sprite.texture` пишется только при смене шага. Флип и anchor — зона владельца спрайта (см. рецепт ходьбы в `recipes.md`). -`FrameAnimation(sprite, frames, fps, loop)` — прежний API одного клипа, -оставлен как тонкая обёртка (`@deprecated`): `setFrames(f, reset)` -продолжает с текущего шага без reset и стартует с нуля с ним. - ## Оживители статики: SpriteMotion Процедурное движение без кадров — для всего, что не умеет ходить: парящие diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 5f8fdc4..6ecf1c4 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -43,17 +43,6 @@ а не от захардкоженной высоты. Источник номиналов для генераторов — `apps/game/tools/pixelart/sizes.mjs` (см. [art-pipeline.md](art-pipeline.md)). -## FrameAnimation - -```ts -import { FrameAnimation } from '@rpg/engine'; - -const sprite = new Sprite(frames[0]); -const anim = new FrameAnimation(sprite, frames, 8 /* fps */, true /* loop */); -anim.update(dt); // каждый тик -anim.setFrames(otherFrames, true); // сменить кадры (поворот, состояние) -``` - ## AudioManager Четыре шины (master/music/sfx/ambience), кроссфейд лупов, опции sfx diff --git a/docs/engine/core.md b/docs/engine/core.md index f185d81..47e1c82 100644 --- a/docs/engine/core.md +++ b/docs/engine/core.md @@ -17,6 +17,11 @@ `autoResize` (по умолчанию true) — пересчитывать целочисленный масштаб при ресайзе окна. +**Зачистка при выгрузке**: `engine.destroy()` — полная (exit текущей сцены, +эффекты/твины/события, ввод, FPS-метр, слушатели окна, `app.destroy`). +Для HMR-пересборок и тестов; после destroy движок не годится. Лёгкий +`engine.stop()` останавливает только цикл. + ## GameLoop (fixed timestep) Логика — фиксированный шаг 60 Гц; рендер после каждого шага. `dt` в `Scene.update(dt)` @@ -67,6 +72,24 @@ Гарантии: значения не выходят за цель (зажим на последнем кадре), `onDone` вызывается один раз, тик, пересекающий конец `delay`, не «съедает» часть движения. +## Плавные значения: `core/fade.ts` (FadeState) + +Твины двигают **вьюхи**; для «плавных значений» без вьюхи (ambient-цвет, альфа +виньетки, оверлей переходов) есть `FadeState` — линейный переход значения к +цели за заданное время, headless-тестируемый: + +```ts +const fade = new FadeState(lerpAmbient, 0xffffff); +fade.to(0x54586a, 1.5); // цель + секунды +fade.update(dt); // каждый тик +fade.value; // интерполированное (по завершении — точно цель) +fade.active; fade.target; fade.origin; +``` + +Повторный `to()` в середине фейда стартует от текущего интерполированного +значения — без прыжка к исходной точке. Не заводите свои пары +`fadeLeft/fadeTotal` — используйте этот класс. + ## Реестр эффектов: `engine.fx` Объекты, тикаемые движком сами между твин-ами и логикой сцены — эффекты, diff --git a/docs/engine/maps.md b/docs/engine/maps.md index fb38d45..85981ad 100644 --- a/docs/engine/maps.md +++ b/docs/engine/maps.md @@ -8,17 +8,15 @@ диагонали: экранное «вниз» = мировой `(1, 1)`, «вправо» = `(1, −1)`. ```ts -import { worldToScreen, screenToWorld, unitsToPx, pxToUnits } from '@rpg/engine'; +import { worldToScreen, screenToWorld, unitsToPx } from '@rpg/engine'; worldToScreen(wx, wy); // проекция точки в px (float, БЕЗ округления — округляет вью) screenToWorld(px, py); // обратно: экранные px -> юниты (для кликов) unitsToPx(units); // скаляр (дистанция, радиус, скорость): px = units·tileW (32 px/юнит) -pxToUnits(px); // обратная линейка ``` -Для «точных» тайловых задач есть мосты `tileToWorld`/`worldToTile` и экранный -пикинг по ромбу (`isoToScreen`/`screenToIso`/`screenToIsoExact`) — для прямых -кликов по спрайтам без юнитов. +Для «точных» тайловых задач есть мосты `tileToWorld`/`worldToTile`; экранный +пикинг — `screenToIso` (floor-семантика, клик по миру идёт через юниты). Клик по миру: из виртуальных px указателя вычтите позицию `worldRoot` (камера), затем `screenToWorld` → юниты → `worldToTile`: @@ -155,12 +153,11 @@ Движковые коллизии — чистая математика в `map/collision.ts`, без Pixi: ```ts -import { gridOf, circleFits, moveCircle, pushOutOfWalls, separateCircles } from '@rpg/engine'; +import { gridOf, circleFits, moveCircle, separateCircles } from '@rpg/engine'; const grid = gridOf(data); // Grid из TileMapData: blocked-иды + footprint пропов circleFits(grid, pos, 0.35); // помещается ли круг в точку (точная проверка) moveCircle(grid, pos, { x: dx, y: dy }, 0.35); // сдвиг со скольжением вдоль стен (оси X -> Y) -pushOutOfWalls(grid, pos, 0.35); // вытолкнуть из стены в центр ближайшего тайла separateCircles(a, ra, b, rb); // выталкивает только a из b (каждый сам) ``` diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 1602cb6..b938444 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -429,4 +429,11 @@ tweens / scenes` — их `update` в try/catch с `console.error(e.stack)` — и продёрнуть переход; настоящий стектрейс всплывёт. Причина однажды была в тике разрушенного Pixi-объекта из `engine.fx` (см. core.md: - destroyed-записи не тикаются). \ No newline at end of file + destroyed-записи не тикаются).- **Тест ядра без WebGL**: `Engine`/`Renderer` тестируются в Vitest через + `vi.mock('pixi.js')` (стабы `Container`/`Graphics`/`Application` с + `renderer.render`/`canvas.style`) — см. `core/__tests__/Engine.test.ts`. + `init()` в тесте не зовётся (он и не нужен: шаги — через `stepTicks`). +- **Класс-поле не может называться как метод**: поле `private to: T` в классе + с методом `to()` создаёт own-свойство и метод «пропадает» + (`f.to is not a function`, TS не предупреждает) — переименовывать поле + (`toVal`), см. `core/fade.ts`. diff --git a/docs/engine/render.md b/docs/engine/render.md index bab57a6..afadadc 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -92,7 +92,8 @@ `0xffffff` — свет не трогает сцену, тёмный — затемнение, цветной — тон. Фундамент смены времени дня: день = `0xffffff`, закат = тёплый, ночь = тёмно-синий. 2. **Источники** — аддитивные спрайты со ступенчатой пиксельной текстурой - свечения (генерируется один раз `makeGlowTexture`; радиус нормируется на + свечения (генерируется `makeGlowTexture`, кэшируется на инстансе Lighting — + модульный кэш переживал `destroy` рендерера; радиус нормируется на `GLOW_BASE_PX`). Тонкий Pixi-адаптер — `Lighting`, чистая математика — `lightSim` (`lightFrame`, `flickerFactor`, `lerpAmbient`, `dimColor`). @@ -293,8 +294,11 @@ изометрию знает сцена (мировые эффекты — через `worldToScreen`/`camera.toScreen`, как у Lighting). Для depth-сортировки передайте `{ parent: depthLayer }` и сами вызовите `addFx(view, tx, ty)`. -- **Пресет владеет своей вьюхой**: создаёт, тикает и уничтожает сам; вызывающий - получает хэндл с `cancel()`. Повторный `flash()` по тому же спрайту перезапускает +- **Единый контракт эффектов**: эффект владеет своей вьюхой (создаёт, тикает, + уничтожает); `cancel()` = остановить, вернуть базовое состояние, погасить свою + вьюху (у эффектов над чужой вьюхой — `FxPop`, `FxFade` с `keep: true` — вьюха + не своя, гасится только влияние); конец жизни — `destroyed` (`done` — алиас у + кольца и текста). Повторный `flash()` по тому же спрайту перезапускает вспышку (не создаёт новую). - **`clear()` гасит всё активное** (смена тоста, exit сцены). Если нужны независимые группы эффектов — заведите по слою на группу (мир / UI). diff --git a/packages/engine/src/anim/FrameAnimation.ts b/packages/engine/src/anim/FrameAnimation.ts deleted file mode 100644 index a9e00c3..0000000 --- a/packages/engine/src/anim/FrameAnimation.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @deprecated Тонкая обёртка совместимости над SpriteAnimator (один клип). - * Используйте SpriteAnimator: именованные клипы, pause/play, события, - * pingpong. Публичный API прежний — позиционный конструктор, setFrames, - * update; поведение сохранено байт-в-байт (loop=false → 'once'). - */ -import type { Sprite, Texture } from 'pixi.js'; -import { SpriteAnimator } from './SpriteAnimator'; - -export class FrameAnimation { - private readonly animator: SpriteAnimator; - private clipName: string; - - constructor(sprite: Sprite, frames: Texture[], fps = 8, loop = true) { - this.clipName = 'frames'; - this.animator = new SpriteAnimator( - sprite, - { [this.clipName]: { frames, fps, loop: loop ? 'loop' : 'once' } }, - 'frames' - ); - } - setFrames(frames: Texture[], reset = false): void { - this.animator.setClipFrames(this.clipName, frames); - if (reset) this.animator.play(this.clipName, { restart: true }); - } - - update(dt: number): void { - this.animator.update(dt); - } -} \ No newline at end of file diff --git a/packages/engine/src/anim/SpriteAnimator.ts b/packages/engine/src/anim/SpriteAnimator.ts index 814be92..41e85c2 100644 --- a/packages/engine/src/anim/SpriteAnimator.ts +++ b/packages/engine/src/anim/SpriteAnimator.ts @@ -7,6 +7,7 @@ */ import type { Sprite, Texture } from 'pixi.js'; import type { Updatable } from '../core/Updatables'; +import { createRng } from '../math/rng'; import { stepClip, type ClipLoop } from './clip'; export interface SpriteClip { @@ -148,12 +149,8 @@ /** Случайная фаза из seed: доля периода клипа, детерминированная. */ private applyRandomPhase(seed: number): void { - // xorshift-простое: [0,1) из целого seed без Math.random. - let s = seed | 0 || 0x9e3779b9; - s ^= s << 13; - s ^= s >>> 17; - s ^= s << 5; - this.phase = ((s >>> 0) % 1000) / 1000; + // Единый ГПСЧ движка (mulberry32): [0,1) из целого seed без Math.random. + this.phase = createRng(seed | 0 || 0x9e3779b9).next(); } private setStep(name: string | null, step: number): void { diff --git a/packages/engine/src/anim/__tests__/FrameAnimation.test.ts b/packages/engine/src/anim/__tests__/FrameAnimation.test.ts deleted file mode 100644 index 85cefc7..0000000 --- a/packages/engine/src/anim/__tests__/FrameAnimation.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { FrameAnimation } from '../FrameAnimation'; - -/** Подмена Sprite: аниматору нужен только writeable `texture`. */ -function fakeSprite() { - return { texture: 'initial' as unknown }; -} - -const t1 = { id: 1 } as never; -const t2 = { id: 2 } as never; -const t3 = { id: 3 } as never; - -describe('FrameAnimation (обёртка SpriteAnimator, совместимость)', () => { - it('цикл: кадры сменяются по fps, texture пишется', () => { - const sprite = fakeSprite(); - const anim = new FrameAnimation(sprite as never, [t1, t2, t3], 10, true); - expect(sprite.texture).toBe(t1); // стартовый кадр отрисован сразу - anim.update(0.1); - expect(sprite.texture).toBe(t2); - anim.update(0.1); - expect(sprite.texture).toBe(t3); - anim.update(0.1); - expect(sprite.texture).toBe(t1); // зациклилось - }); - - it('loop=false: стоп на последнем кадре', () => { - const sprite = fakeSprite(); - const anim = new FrameAnimation(sprite as never, [t1, t2], 10, false); - anim.update(0.1); - anim.update(0.1); - expect(sprite.texture).toBe(t2); - for (let i = 0; i < 5; i++) anim.update(0.1); - expect(sprite.texture).toBe(t2); // не уходит в минус индексы - }); - - it('setFrames без reset продолжает с текущего шага, с reset — с нуля', () => { - const sprite = fakeSprite(); - const anim = new FrameAnimation(sprite as never, [t1, t2], 10, true); - anim.update(0.1); // шаг 1 (t2) - anim.setFrames([t3, t1]); - expect(sprite.texture).toBe(t1); // шаг 1 нового клипа - anim.setFrames([t1, t2], true); - expect(sprite.texture).toBe(t1); // reset → шаг 0 - anim.update(0.1); - expect(sprite.texture).toBe(t2); - }); - - it('пустой список кадров не падает', () => { - const sprite = fakeSprite(); - const anim = new FrameAnimation(sprite as never, [], 10, true); - expect(() => anim.update(0.1)).not.toThrow(); - expect(sprite.texture).toBe('initial'); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/anim/__tests__/SpriteAnimator.test.ts b/packages/engine/src/anim/__tests__/SpriteAnimator.test.ts new file mode 100644 index 0000000..066658b --- /dev/null +++ b/packages/engine/src/anim/__tests__/SpriteAnimator.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest'; + +// SpriteAnimator пишет texture в Sprite — минимальный стаб. +function makeSprite() { + return { texture: null as unknown, destroyed: false } as unknown as import('pixi.js').Sprite; +} + +function frames(n: number): import('pixi.js').Texture[] { + return Array.from({ length: n }, (_, i) => ({ idx: i }) as unknown as import('pixi.js').Texture); +} + +import { SpriteAnimator } from '../SpriteAnimator'; + +describe('SpriteAnimator', () => { + it('конструктор без клипа: показывает стартовый шаг, время стоит', () => { + const sprite = makeSprite(); + const a = new SpriteAnimator(sprite, { idle: { frames: frames(4) } }, undefined, { startStep: 2 }); + expect(a.clip).toBe('idle'); + expect(a.step).toBe(2); + expect(a.paused).toBe(true); + a.update(1); + expect(a.step).toBe(2); // заморожен + }); + + it('loop: шаги идут по fps, по кругу', () => { + const sprite = makeSprite(); + const a = new SpriteAnimator(sprite, { walk: { frames: frames(3), fps: 10 } }, 'walk'); + expect(a.step).toBe(0); + a.update(0.1); // 1 шаг при 10 fps + expect(a.step).toBe(1); + a.update(0.2); // ещё 2 — на конец + expect(a.step).toBe(0); // 3 % 3 + }); + + it('тот же клип без restart продолжается; restart начинает с нуля', () => { + const sprite = makeSprite(); + const a = new SpriteAnimator(sprite, { walk: { frames: frames(4), fps: 10 } }, 'walk'); + a.update(0.15); + expect(a.step).toBe(1); + a.play('walk'); + expect(a.step).toBe(1); // продолжение + a.play('walk', { restart: true }); + expect(a.step).toBe(0); + }); + + it('once: доигрывает до последнего шага, paused, onFinish один раз', () => { + const sprite = makeSprite(); + const finished: string[] = []; + const a = new SpriteAnimator(sprite, { hit: { frames: frames(3), fps: 10, loop: 'once' } }, 'hit'); + a.onFinish = (c) => finished.push(c); + for (let i = 0; i < 10; i++) a.update(0.1); // 1 с — клип 0.3 с + expect(a.step).toBe(2); + expect(a.paused).toBe(true); + expect(finished).toEqual(['hit']); + }); + + it('большой dt проматывает промежуточные шаги (сообщается финальный)', () => { + const sprite = makeSprite(); + const seen: number[] = []; + const a = new SpriteAnimator(sprite, { walk: { frames: frames(8), fps: 10 } }, 'walk'); + a.onFrame = (s) => seen.push(s); + a.update(1); // 10 шагов за тик + expect(a.step).toBe(2); // 10 % 8 + expect(seen).toEqual([2]); // промежуточные не долетели + }); + + it('randomPhase: одинаковый seed — одинаковая фаза (шаги совпадают)', () => { + const mk = (seed: number) => + new SpriteAnimator(makeSprite(), { walk: { frames: frames(3) } }, undefined, { randomPhase: seed }); + const a = mk(1234); + const b = mk(1234); + // Фаза приватна, но детерминизм виден через play + апдейты: + // time = phase, поэтому при одинаковых апдейтах шаги совпадают. + a.play('walk'); + b.play('walk'); + a.update(0.05); + b.update(0.05); + expect(a.step).toBe(b.step); + a.update(0.3); + b.update(0.3); + expect(a.step).toBe(b.step); + }); + + it('setClipFrames: кадры меняются на лету, шаг клэмпится', () => { + const sprite = makeSprite(); + const a = new SpriteAnimator(sprite, { walk: { frames: frames(4), fps: 10 } }, 'walk'); + a.update(0.3); // шаг 3 + expect(a.step).toBe(3); + a.setClipFrames('walk', frames(2)); + expect(a.step).toBe(1); // клэмп к 2-1 + }); + + it('несуществующий клип — ошибка', () => { + const a = new SpriteAnimator(makeSprite(), { walk: { frames: frames(2) } }); + expect(() => a.play('fly')).toThrow('fly'); + }); + + it('destroyed спрайта пробрасывается (самозачистка из engine.fx)', () => { + const sprite = makeSprite(); + const a = new SpriteAnimator(sprite, { walk: { frames: frames(2) } }); + expect(a.destroyed).toBe(false); + sprite.destroyed = true; + expect(a.destroyed).toBe(true); + }); + + it('speedScale замедляет/ускоряет клип', () => { + const a = new SpriteAnimator(makeSprite(), { walk: { frames: frames(10), fps: 10 } }, 'walk', { speedScale: 0.5 }); + a.update(0.1); + expect(a.step).toBe(0); // полшага — не дошёл + a.update(0.1); + expect(a.step).toBe(1); + }); + + it('пустой набор клипов допустим (без падений)', () => { + const a = new SpriteAnimator(makeSprite(), {}); + expect(a.clip).toBeNull(); + expect(() => a.update(0.1)).not.toThrow(); + }); + + it('onFrame получает номер шага', () => { + const onFrame = vi.fn(); + const a = new SpriteAnimator(makeSprite(), { walk: { frames: frames(2), fps: 10 } }, 'walk'); + a.onFrame = onFrame; + a.update(0.1); + expect(onFrame).toHaveBeenCalledWith(1); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/anim/flash.ts b/packages/engine/src/anim/flash.ts index d0e9a9a..ac67052 100644 --- a/packages/engine/src/anim/flash.ts +++ b/packages/engine/src/anim/flash.ts @@ -45,6 +45,12 @@ get active(): boolean { return this.elapsed !== null; } + + /** Немедленно завершить; возвращает базовый цвет (вызывающий применяет сам). */ + finish(): number { + this.elapsed = null; + return this.base; + } } /** Лерп двух цветов 0xRRGGBB по каналам. */ diff --git a/packages/engine/src/audio/AudioManager.ts b/packages/engine/src/audio/AudioManager.ts index 1ac22fe..6577058 100644 --- a/packages/engine/src/audio/AudioManager.ts +++ b/packages/engine/src/audio/AudioManager.ts @@ -1,89 +1,34 @@ /** - * WebAudio с четырьмя шинами (master/music/sfx/ambience): громкости по шинам, - * кроссфейд лупов (музыка и амбиент — независимые слоты), опции sfx - * (громкость/скорость/панорама), кэш сэмплов. Игры подключают Settings.onChange - * и вызывают setBusVolume. + * WebAudio с четырьмя шинами (master/music/sfx/ambience): публичный фасад. + * Полифония sfx — Voices, лупы/кроссфейд — Loops, кэш буферов — BufferCache. + * Игры подключают Settings.onChange и вызывают setBusVolume. */ +import { BufferCache } from './decode'; +import { LoopSlots } from './Loops'; import { renderSpec, RATE, type SoundSpec } from './SoundSpec'; +import { SfxVoices } from './Voices'; +import { + DEFAULT_FADE, + type AudioBuses, + type BusName, + type LoopOptions, + type MusicHandle, + type PlayOptions, + type SfxHandle +} from './types'; -export interface AudioBuses { - master: GainNode; - music: GainNode; - sfx: GainNode; - ambience: GainNode; -} - -export type BusName = 'master' | 'music' | 'sfx' | 'ambience'; - -export interface MusicHandle { - stop(fadeSeconds?: number): void; - /** Задать громкость сразу (для медленных тик-фейдов поверх — локальные слои). */ - setVolume(volume: number): void; -} - -/** Хендл играющего разового sfx: параметры в полёте + остановка. */ -export interface SfxHandle { - stop(fadeSeconds?: number): void; - setVolume(volume: number): void; - setPan(pan: number): void; - setRate(rate: number): void; -} - -/** Опции разового sfx. */ -export interface PlayOptions { - /** Относительная громкость в шине (см. bus), 0..1+. */ - volume?: number; - /** Скорость воспроизведения 0.5..2 (джиттер шагов, вариации тона). */ - rate?: number; - /** Панорама -1..1; |pan| < 0.01 — узел StereoPanner не создаётся. */ - pan?: number; - /** Заглушить прежние играющие экземпляры этого же ключа (короткий фейд). */ - restart?: boolean; - /** Шина голоса (по умолчанию sfx) — например UI-звук в master. */ - bus?: BusName; -} - -interface LoopOptions { - loop?: boolean; - fade?: number; - volume?: number; - bus?: BusName; -} - -/** Запущенный луп: источник + его огибающая громкости. */ -interface Slot { - source: AudioBufferSourceNode; - gain: GainNode; -} - -/** Голос разового sfx: узлы + ключ (учёт для лимита полифонии и restart). */ -interface SfxVoice { - key: string; - source: AudioBufferSourceNode; - gain: GainNode; - panner: StereoPannerNode | null; - /** Заглушен принудительно (stop/вор/новый restart) — onEnded не зовётся. */ - killed: boolean; -} - -const DEFAULT_FADE = 1; -const MIN_FADE_VOLUME = 0.0001; - -const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); +export { type AudioBuses, type BusName, type MusicHandle, type PlayOptions, type SfxHandle } from './types'; export class AudioManager { private ctx: AudioContext | null = null; - private buffers = new Map(); + private buses: AudioBuses | null = null; /** Буферы спек-синтеза (playSpec): компиляция один раз на ключ. */ private specBuffers = new Map(); - private urls = new Map(); - private buses: AudioBuses | null = null; - private currentMusic: Slot | null = null; - private currentAmbience: Slot | null = null; - /** Играющие голоса sfx (учёт полифонии и restart). */ - private sfxVoices: SfxVoice[] = []; - /** Лимит одновременных sfx-голосов (0 — без лимита); избыток — воруется. */ - private maxVoices: number; + private cache = new BufferCache(); + private voices: SfxVoices; + private loops = new LoopSlots(); + /** Слушатель visibility (снимается в dispose). */ + private onVisibility: (() => void) | null = null; /** * Шпионский хук: вызывается при каждом фактическом запуске звука — @@ -100,17 +45,19 @@ private ctxFactory: () => AudioContext = () => new AudioContext(), { maxVoices = 24, pauseOnHide = true }: { maxVoices?: number; pauseOnHide?: boolean } = {} ) { - this.maxVoices = maxVoices; + this.voices = new SfxVoices(maxVoices); + this.voices.onEnded = (key) => this.onEnded?.(key); // Свернул таб — тишина, вернулся — звук сам (сверху не нужен жест, мы // уже разблокированы); движку без DOM (тесты) listener не ставится. if (pauseOnHide && typeof document !== 'undefined') { - document.addEventListener('visibilitychange', () => { + this.onVisibility = () => { if (document.hidden) { void this.ctx?.suspend(); } else if (this.ctx?.state === 'suspended') { void this.ctx.resume(); } - }); + }; + document.addEventListener('visibilitychange', this.onVisibility); } } @@ -140,8 +87,9 @@ } } - async load(key: string): Promise { - this.urls.set(key, this.resolveUrl(key)); + /** Зарегистрировать URL ключа (файл читается при play/preload). */ + register(key: string): void { + this.cache.register(key, this.resolveUrl(key)); } /** @@ -151,7 +99,7 @@ async preload(key: string): Promise { const ctx = await this.ensureContext(); if (!ctx) return; - await this.decode(key); + await this.cache.decode(key, ctx); } /** @@ -160,12 +108,11 @@ * opts — число (громкость, обратная совместимость) или PlayOptions. */ async play(key: string, opts?: number | PlayOptions): Promise { - const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {}); const ctx = await this.ensureContext(); - if (!ctx) return null; - const buf = await this.decode(key); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); if (!buf) return null; - return this.startSfx(ctx, key, buf, o); + return this.startSfx(ctx, key, buf, opts); } /** @@ -173,10 +120,9 @@ * логическое имя голоса (для restart, полифонии и шпионов), не файл. */ async playBuffer(key: string, buffer: AudioBuffer, opts?: number | PlayOptions): Promise { - const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {}); const ctx = await this.ensureContext(); - if (!ctx) return null; - return this.startSfx(ctx, key, buffer, o); + if (!ctx || !this.buses) return null; + return this.startSfx(ctx, key, buffer, opts); } /** @@ -212,7 +158,7 @@ */ async playSpec(key: string, spec: SoundSpec, opts?: number | PlayOptions): Promise { const ctx = await this.ensureContext(); - if (!ctx) return null; + if (!ctx || !this.buses) return null; let buf = this.specBuffers.get(key); if (!buf) { const created = this.createBuffer(renderSpec(spec), RATE); @@ -220,41 +166,7 @@ buf = created; this.specBuffers.set(key, buf); } - return this.startSfx(ctx, key, buf, typeof opts === 'number' ? { volume: opts } : (opts ?? {})); - } - - /** Общий запуск голоса sfx (play — после декода, playBuffer — как есть). */ - private startSfx(ctx: AudioContext, key: string, buf: AudioBuffer, o: PlayOptions): SfxHandle | null { - const volume = o.volume ?? 1; - const rate = clamp(o.rate ?? 1, 0.5, 2); - const pan = clamp(o.pan ?? 0, -1, 1); - if (o.restart) this.stopKey(key); - this.stealVoiceIfNeeded(); - - const source = ctx.createBufferSource(); - source.buffer = buf; - source.playbackRate.value = rate; - const gain = ctx.createGain(); - gain.gain.value = volume; - source.connect(gain); - const bus = this.buses![o.bus ?? 'sfx']; - let panner: StereoPannerNode | null = null; - if (Math.abs(pan) >= 0.01 && typeof ctx.createStereoPanner === 'function') { - panner = ctx.createStereoPanner(); - panner.pan.value = pan; - gain.connect(panner).connect(bus); - } else { - gain.connect(bus); - } - source.start(); - const voice: SfxVoice = { key, source, gain, panner, killed: false }; - source.onended = () => { - this.releaseVoice(voice); - if (!voice.killed) this.onEnded?.(key); - }; - this.sfxVoices.push(voice); - this.onPlayed?.(key, { volume, rate, pan }); - return this.makeSfxHandle(voice); + return this.startSfx(ctx, key, buf, opts); } /** @@ -262,134 +174,46 @@ * null. Для катсцен и генераторов, синхронизирующихся со звуком. */ duration(key: string): number | null { - return this.buffers.get(key)?.duration ?? null; - } - - /** Хендл над голосом: после снятия с учёта (stop/конец) — no-op. */ - private makeSfxHandle(voice: SfxVoice): SfxHandle { - const alive = () => this.sfxVoices.includes(voice); - return { - stop: (fadeSeconds = 0.05) => { - if (alive()) this.killVoice(voice, fadeSeconds); - }, - setVolume: (v: number) => { - if (alive()) voice.gain.gain.value = Math.max(0, v); - }, - setPan: (p: number) => { - if (alive()) this.setVoicePan(voice, clamp(p, -1, 1)); - }, - setRate: (r: number) => { - if (alive()) voice.source.playbackRate.value = clamp(r, 0.5, 2); - } - }; - } - - /** Панорама голоса: узел создаётся при первом ненулевом значении. */ - private setVoicePan(voice: SfxVoice, pan: number): void { - if (!this.ctx) return; - if (voice.panner) { - voice.panner.pan.value = pan; - return; - } - if (Math.abs(pan) < 0.01 || typeof this.ctx.createStereoPanner !== 'function') return; - const panner = this.ctx.createStereoPanner(); - panner.pan.value = pan; - voice.gain.disconnect(); - voice.gain.connect(panner).connect(this.buses!.sfx); - voice.panner = panner; - } - - /** Остановить все играющие голоса ключа (короткий фейд). */ - private stopKey(key: string, fadeSeconds = 0.05): void { - for (const voice of [...this.sfxVoices]) { - if (voice.key === key) this.killVoice(voice, fadeSeconds); - } - } - - /** Лимит голосов: при переполнении воруется самый тихий (ничья — самый старый). */ - private stealVoiceIfNeeded(): void { - while (this.maxVoices > 0 && this.sfxVoices.length >= this.maxVoices) { - let victim = this.sfxVoices[0]!; - for (const v of this.sfxVoices) { - if (v.gain.gain.value < victim.gain.gain.value) victim = v; - } - this.killVoice(victim, 0.05); - } - } - - /** Затухание голоса + снятие с учёта (handle сразу «мёртв»). */ - private killVoice(voice: SfxVoice, fadeSeconds: number): void { - voice.killed = true; - this.fadeOut(voice, fadeSeconds); - this.releaseVoice(voice); - } - - /** Снять голос с учёта (idempotent — зовётся и из onended, и из killVoice). */ - private releaseVoice(voice: SfxVoice): void { - const i = this.sfxVoices.indexOf(voice); - if (i >= 0) this.sfxVoices.splice(i, 1); + return this.cache.cached(key)?.duration ?? null; } /** * Играть луп в заданной шине; НЕ занимает ни слот музыки, ни слот амбиента — * база для локальных амбиент-слоёв (несколько параллельных лупов). */ - async playLoop(key: string, { loop = true, fade = DEFAULT_FADE, volume = 1, bus = 'music' }: LoopOptions = {}): Promise { - const slot = await this.startLoop(key, { loop, fade, volume, bus }); - if (!slot) return null; - return this.makeHandle(slot, fade); + async playLoop(key: string, opts: LoopOptions = {}): Promise { + return this.playLoopFrom(key, opts); } /** * Луп из буфера в памяти (процедурная музыка: renderMusic → буфер → стем * на шине). Ключ — логическое имя слота (для шпионов), не файл. */ - async playLoopBuffer( - key: string, - buffer: AudioBuffer, - { loop = true, fade = DEFAULT_FADE, volume = 1, bus = 'music' }: LoopOptions = {} - ): Promise { + async playLoopBuffer(key: string, buffer: AudioBuffer, opts: LoopOptions = {}): Promise { const ctx = await this.ensureContext(); - if (!ctx) return null; - const slot = this.startSlot(key, ctx, buffer, { loop, fade, volume, bus }); - return this.makeHandle(slot, fade); - } - - /** Handle над слотом: затухание + мгновенная громкость (без борьбы с ramp). */ - private makeHandle(slot: Slot, fade: number): MusicHandle { - return { - stop: (fadeSeconds = fade) => this.fadeOut(slot, fadeSeconds), - setVolume: (volume: number) => { - if (!this.ctx) return; - const t = this.ctx.currentTime; - slot.gain.gain.cancelScheduledValues(t); - slot.gain.gain.setValueAtTime(Math.max(0, volume), t); - } - }; + if (!ctx || !this.buses) return null; + const full = this.loopOpts(opts); + const slot = this.loops.startSlot(key, ctx, this.buses, buffer, full, this.played()); + return this.loops.makeHandle(slot, full.fade); } /** * Играть музыку с кроссфейдом: текущий трек затухает, новый нарастает * за fadeSeconds. Слот один — повторный вызов вытесняет прежний трек. */ - async playMusic(key: string, { loop = true, fade = DEFAULT_FADE, volume = 1 }: Omit = {}): Promise { + async playMusic(key: string, opts: Omit = {}): Promise { const ctx = await this.ensureContext(); - if (!ctx) return null; - // Сначала проверяем ключ: негодный не должен заглушить текущий трек. - if (!(await this.decode(key))) return null; - this.stopMusic(fade); - const slot = await this.startLoop(key, { loop, fade, volume, bus: 'music' }); - if (!slot) return null; - this.currentMusic = slot; - return this.makeHandle(slot, fade); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); + if (!buf) return null; // негодный ключ не должен заглушить текущий трек + const full = this.loopOpts(opts); + const slot = this.loops.swapMusic(key, ctx, this.buses, buf, full, this.played()); + return this.loops.makeHandle(slot, full.fade); } /** Остановить музыку (с затуханием). */ stopMusic(fadeSeconds = 0.5): void { - const current = this.currentMusic; - if (!current) return; - this.currentMusic = null; - this.fadeOut(current, fadeSeconds); + this.loops.stopMusic(fadeSeconds); } /** @@ -398,83 +222,58 @@ */ async playAmbience(key: string, { fade = DEFAULT_FADE, volume = 1 }: { fade?: number; volume?: number } = {}): Promise { const ctx = await this.ensureContext(); - if (!ctx) return null; - if (!(await this.decode(key))) return null; - this.stopAmbience(fade); - const slot = await this.startLoop(key, { loop: true, fade, volume, bus: 'ambience' }); - if (!slot) return null; - this.currentAmbience = slot; - return this.makeHandle(slot, fade); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); + if (!buf) return null; // негодный ключ не должен заглушить текущий амбиент + const slot = this.loops.swapAmbience(key, ctx, this.buses, buf, { loop: true, fade, volume, bus: 'ambience' }, this.played()); + return this.loops.makeHandle(slot, fade); } /** Остановить амбиент (с затуханием). */ stopAmbience(fadeSeconds = 0.5): void { - const current = this.currentAmbience; - if (!current) return; - this.currentAmbience = null; - this.fadeOut(current, fadeSeconds); + this.loops.stopAmbience(fadeSeconds); } - /** Общий запуск файлового лупа (после декода). */ - private async startLoop(key: string, opts: Required): Promise { + /** Погасить всё и снять слушатели (пересоздание аудио/HMR — без утечек). */ + dispose(): void { + if (this.onVisibility) { + document.removeEventListener('visibilitychange', this.onVisibility); + this.onVisibility = null; + } + this.voices.killAll(); + this.loops.stopMusic(0.05); + this.loops.stopAmbience(0.05); + } + + /** Общий запуск голоса sfx (нормализация opts-числа — здесь). */ + private startSfx(ctx: AudioContext, key: string, buf: AudioBuffer, opts?: number | PlayOptions): SfxHandle | null { + const o: PlayOptions = typeof opts === 'number' ? { volume: opts } : (opts ?? {}); + return this.voices.start(ctx, this.buses!, key, buf, o, (k, n) => this.onPlayed?.(k, n)); + } + + /** Нормализованные опции лупа. */ + private loopOpts(opts: LoopOptions): Required { + return { loop: opts.loop ?? true, fade: opts.fade ?? DEFAULT_FADE, volume: opts.volume ?? 1, bus: opts.bus ?? 'music' }; + } + + /** Шпионский колбэк для Loops (startSlot/swap* зовут на каждый запуск). */ + private played(): (key: string, opts: PlayOptions) => void { + return (key, opts) => this.onPlayed?.(key, opts); + } + + /** Файловый луп без слота (playLoop): декод + независимый запуск. */ + private async playLoopFrom(key: string, opts: LoopOptions): Promise { const ctx = await this.ensureContext(); - if (!ctx) return null; - const buf = await this.decode(key); + if (!ctx || !this.buses) return null; + const buf = await this.cache.decode(key, ctx); if (!buf) return null; - return this.startSlot(key, ctx, buf, opts); - } - - /** Запуск лупа из готового буфера: источник + линейное нарастание громкости. */ - private startSlot(key: string, ctx: AudioContext, buf: AudioBuffer, { loop, fade, volume, bus }: Required): Slot { - const source = ctx.createBufferSource(); - source.buffer = buf; - source.loop = loop; - const gain = ctx.createGain(); - gain.gain.setValueAtTime(MIN_FADE_VOLUME, ctx.currentTime); - gain.gain.linearRampToValueAtTime(volume, ctx.currentTime + fade); - source.connect(gain).connect(this.buses![bus]); - source.start(); - this.onPlayed?.(key, { volume, rate: 1, pan: 0 }); - return { source, gain }; - } - - /** Затухание слота (экспонента — слуху линейный спад слышен ступенькой). */ - private fadeOut(slot: Slot, fadeSeconds: number): void { - if (!this.ctx) return; - const t = this.ctx.currentTime; - slot.gain.gain.cancelScheduledValues(t); - // Экспоненциальный ramp не определён от нуля — поднимаем до минимума. - const from = Math.max(slot.gain.gain.value, MIN_FADE_VOLUME); - slot.gain.gain.setValueAtTime(from, t); - slot.gain.gain.exponentialRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds); - setTimeout(() => { - try { - slot.source.stop(); - } catch { - // уже остановлен - } - }, fadeSeconds * 1000 + 50); + const full = this.loopOpts(opts); + const slot = this.loops.startSlot(key, ctx, this.buses, buf, full, this.played()); + return this.loops.makeHandle(slot, full.fade); } private async ensureContext(): Promise { await this.unlock(); return this.ctx; } - - private async decode(key: string): Promise { - if (!this.ctx) return null; - let buf = this.buffers.get(key); - const url = this.urls.get(key); - if (!buf && url) { - try { - const res = await fetch(url); - buf = await this.ctx.decodeAudioData(await res.arrayBuffer()); - this.buffers.set(key, buf); - } catch (err) { - console.warn(`[audio] не удалось декодировать ${key}:`, err); - return null; - } - } - return buf ?? null; - } } \ No newline at end of file diff --git a/packages/engine/src/audio/Loops.ts b/packages/engine/src/audio/Loops.ts new file mode 100644 index 0000000..5377e20 --- /dev/null +++ b/packages/engine/src/audio/Loops.ts @@ -0,0 +1,119 @@ +/** + * Лупы (музыка/амбиент/локальные слои): запуск из буфера, кроссфейд, слоты + * музыки и амбиента (взаимно не вытесняют). Выбор трека — AudioManager. + */ +import { MIN_FADE_VOLUME, type AudioBuses, type LoopOptions, type MusicHandle, type PlayOptions } from './types'; + +/** Запущенный луп: контекст + источник + огибающая громкости. */ +export interface Slot { + ctx: AudioContext; + source: AudioBufferSourceNode; + gain: GainNode; +} + +export class LoopSlots { + private currentMusic: Slot | null = null; + private currentAmbience: Slot | null = null; + + /** + * Запуск лупа из готового буфера: источник + линейное нарастание громкости. + * Не занимает ни слот музыки, ни слот амбиента — база локальных слоёв. + */ + startSlot( + key: string, + ctx: AudioContext, + buses: AudioBuses, + buf: AudioBuffer, + { loop, fade, volume, bus }: Required, + onPlayed: (key: string, opts: PlayOptions) => void + ): Slot { + const source = ctx.createBufferSource(); + source.buffer = buf; + source.loop = loop; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(MIN_FADE_VOLUME, ctx.currentTime); + gain.gain.linearRampToValueAtTime(volume, ctx.currentTime + fade); + source.connect(gain).connect(buses[bus]); + source.start(); + onPlayed(key, { volume, rate: 1, pan: 0 }); + return { ctx, source, gain }; + } + + /** Handle над слотом: затухание + мгновенная громкость (без борьбы с ramp). */ + makeHandle(slot: Slot, fade: number): MusicHandle { + return { + stop: (fadeSeconds = fade) => this.fadeOut(slot, fadeSeconds), + setVolume: (volume: number) => { + const t = slot.ctx.currentTime; + slot.gain.gain.cancelScheduledValues(t); + slot.gain.gain.setValueAtTime(Math.max(0, volume), t); + } + }; + } + + /** + * Слот музыки: новый трек вытесняет прежний (кроссфейд). Негодный ключ не + * должен заглушить текущий трек — проверка decode до вызова. + */ + swapMusic( + key: string, + ctx: AudioContext, + buses: AudioBuses, + buf: AudioBuffer, + opts: Required, + onPlayed: (key: string, opts: PlayOptions) => void + ): Slot { + this.stopMusic(opts.fade); + const slot = this.startSlot(key, ctx, buses, buf, opts, onPlayed); + this.currentMusic = slot; + return slot; + } + + /** Слот амбиента — отдельный от музыки: playAmbience и playMusic не конфликтуют. */ + swapAmbience( + key: string, + ctx: AudioContext, + buses: AudioBuses, + buf: AudioBuffer, + opts: Required, + onPlayed: (key: string, opts: PlayOptions) => void + ): Slot { + this.stopAmbience(opts.fade); + const slot = this.startSlot(key, ctx, buses, buf, opts, onPlayed); + this.currentAmbience = slot; + return slot; + } + + /** Остановить музыку (с затуханием). */ + stopMusic(fadeSeconds: number): void { + const current = this.currentMusic; + if (!current) return; + this.currentMusic = null; + this.fadeOut(current, fadeSeconds); + } + + /** Остановить амбиент (с затуханием). */ + stopAmbience(fadeSeconds: number): void { + const current = this.currentAmbience; + if (!current) return; + this.currentAmbience = null; + this.fadeOut(current, fadeSeconds); + } + + /** Затухание слота (экспонента — слуху линейный спад слышен ступенькой). */ + fadeOut(slot: Slot, fadeSeconds: number): void { + const t = slot.ctx.currentTime; + slot.gain.gain.cancelScheduledValues(t); + // Экспоненциальный ramp не определён от нуля — поднимаем до минимума. + const from = Math.max(slot.gain.gain.value, MIN_FADE_VOLUME); + slot.gain.gain.setValueAtTime(from, t); + slot.gain.gain.exponentialRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds); + setTimeout(() => { + try { + slot.source.stop(); + } catch { + // уже остановлен + } + }, fadeSeconds * 1000 + 50); + } +} \ No newline at end of file diff --git a/packages/engine/src/audio/Voices.ts b/packages/engine/src/audio/Voices.ts new file mode 100644 index 0000000..bded2ea --- /dev/null +++ b/packages/engine/src/audio/Voices.ts @@ -0,0 +1,153 @@ +/** + * Полифония разовых sfx: учёт играющих голосов, лимит с воровством самого + * тихого, restart по ключу, панорама в полёте. Шины и контекст даёт AudioManager. + */ +import { clamp, MIN_FADE_VOLUME, type AudioBuses, type BusName, type PlayOptions, type SfxHandle } from './types'; + +/** Голос разового sfx: узлы + ключ (учёт для лимита полифонии и restart). */ +interface SfxVoice { + key: string; + /** Контекст и шины запуска (фейки тестов не дают source.context). */ + ctx: AudioContext; + buses: AudioBuses; + source: AudioBufferSourceNode; + gain: GainNode; + panner: StereoPannerNode | null; + /** Шина голоса (pan в полёте переподключается в ней же, не в sfx). */ + bus: BusName; + /** Заглушен принудительно (stop/вор/новый restart) — onEnded не зовётся. */ + killed: boolean; +} + +export class SfxVoices { + private voices: SfxVoice[] = []; + /** Звук доиграл до конца (stop/вор/новый restart — не считаются). */ + onEnded?: (key: string) => void; + + /** @param maxVoices лимит одновременных голосов (0 — без лимита). */ + constructor(private maxVoices: number) {} + + /** + * Общий запуск голоса (play — после декода, playBuffer — как есть). + * onPlayed зовётся после фактического source.start с нормализованными опциями. + */ + start( + ctx: AudioContext, + buses: AudioBuses, + key: string, + buf: AudioBuffer, + o: PlayOptions, + onPlayed: (key: string, opts: PlayOptions) => void + ): SfxHandle | null { + const volume = o.volume ?? 1; + const rate = clamp(o.rate ?? 1, 0.5, 2); + const pan = clamp(o.pan ?? 0, -1, 1); + const bus = o.bus ?? 'sfx'; + if (o.restart) this.stopKey(key); + this.stealIfNeeded(); + + const source = ctx.createBufferSource(); + source.buffer = buf; + source.playbackRate.value = rate; + const gain = ctx.createGain(); + gain.gain.value = volume; + source.connect(gain); + let panner: StereoPannerNode | null = null; + if (Math.abs(pan) >= 0.01 && typeof ctx.createStereoPanner === 'function') { + panner = ctx.createStereoPanner(); + panner.pan.value = pan; + gain.connect(panner).connect(buses[bus]); + } else { + gain.connect(buses[bus]); + } + source.start(); + const voice: SfxVoice = { key, ctx, buses, source, gain, panner, bus, killed: false }; + source.onended = () => { + this.release(voice); + if (!voice.killed) this.onEnded?.(key); + }; + this.voices.push(voice); + onPlayed(key, { volume, rate, pan }); + return this.makeHandle(voice); + } + + /** Остановить все играющие голоса ключа (короткий фейд). */ + stopKey(key: string, fadeSeconds = 0.05): void { + for (const voice of [...this.voices]) { + if (voice.key === key) this.kill(voice, fadeSeconds); + } + } + + /** Погасить все голоса (dispose менеджера). */ + killAll(): void { + for (const voice of [...this.voices]) this.kill(voice, 0.05); + } + + /** Хендл над голосом: после снятия с учёта (stop/конец) — no-op. */ + private makeHandle(voice: SfxVoice): SfxHandle { + const alive = () => this.voices.includes(voice); + return { + stop: (fadeSeconds = 0.05) => { + if (alive()) this.kill(voice, fadeSeconds); + }, + setVolume: (v: number) => { + if (alive()) voice.gain.gain.value = Math.max(0, v); + }, + setPan: (p: number) => { + if (alive()) this.setPan(voice, clamp(p, -1, 1)); + }, + setRate: (r: number) => { + if (alive()) voice.source.playbackRate.value = clamp(r, 0.5, 2); + } + }; + } + + /** Панорама голоса: узел создаётся при первом ненулевом значении — в шине голоса. */ + private setPan(voice: SfxVoice, pan: number): void { + if (voice.panner) { + voice.panner.pan.value = pan; + return; + } + if (Math.abs(pan) < 0.01 || typeof voice.ctx.createStereoPanner !== 'function') return; + const panner = voice.ctx.createStereoPanner(); + panner.pan.value = pan; + voice.gain.disconnect(); + voice.gain.connect(panner).connect(voice.buses[voice.bus]); + voice.panner = panner; + } + + /** Лимит голосов: при переполнении воруется самый тихий (ничья — самый старый). */ + private stealIfNeeded(): void { + while (this.maxVoices > 0 && this.voices.length >= this.maxVoices) { + let victim = this.voices[0]!; + for (const v of this.voices) { + if (v.gain.gain.value < victim.gain.gain.value) victim = v; + } + this.kill(victim, 0.05); + } + } + + /** Затухание голоса + снятие с учёта (handle сразу «мёртв»). */ + private kill(voice: SfxVoice, fadeSeconds: number): void { + voice.killed = true; + const t = voice.ctx.currentTime; + voice.gain.gain.cancelScheduledValues(t); + const from = Math.max(voice.gain.gain.value, MIN_FADE_VOLUME); + voice.gain.gain.setValueAtTime(from, t); + voice.gain.gain.exponentialRampToValueAtTime(MIN_FADE_VOLUME, t + fadeSeconds); + setTimeout(() => { + try { + voice.source.stop(); + } catch { + // уже остановлен + } + }, fadeSeconds * 1000 + 50); + this.release(voice); + } + + /** Снять голос с учёта (idempotent — зовётся и из onended, и из kill). */ + private release(voice: SfxVoice): void { + const i = this.voices.indexOf(voice); + if (i >= 0) this.voices.splice(i, 1); + } +} \ No newline at end of file diff --git a/packages/engine/src/audio/__tests__/AudioManager.test.ts b/packages/engine/src/audio/__tests__/AudioManager.test.ts index 5376b4e..9ea1207 100644 --- a/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ b/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -149,7 +149,7 @@ it('play(key, 0.5) ≡ play(key, {volume: 0.5}) — источник в sfx через gain', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); await audio.play('bell', 0.5); await audio.play('bell', { volume: 0.5 }); @@ -165,7 +165,7 @@ it('rate и pan доезжают до узлов; pan ≈ 0 — StereoPanner не создаётся', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); await audio.play('bell', { rate: 1.5, pan: 0.7 }); expect(graph.sources[0]!.playbackRate.value).toBe(1.5); @@ -191,7 +191,7 @@ const played: { key: string; opts: PlayOptions }[] = []; audio.onPlayed = (key, opts) => played.push({ key, opts }); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); await audio.play('bell', { volume: 0.6, rate: 1.2, pan: -0.4 }); await audio.play('bell', 0.5); @@ -206,7 +206,7 @@ it('play возвращает хендл: setVolume/setRate/setPan меняют узлы, stop глушит', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); const h = await audio.play('bell', { volume: 0.5, pan: 0.3 }); expect(h).not.toBeNull(); @@ -229,7 +229,7 @@ it('setPan создаёт panner, если его не было', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); const h = await audio.play('bell', 0.5); expect(graph.panners).toHaveLength(0); @@ -245,7 +245,7 @@ it('после stop хендл мёртв: setVolume/setRate — no-op', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); const h = await audio.play('bell', 0.5); h!.stop(0); @@ -263,7 +263,7 @@ it('restart: новый экземпляр ключа глушит прежний; без restart — играют оба', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); await audio.play('bell', 0.5); await audio.play('bell', { volume: 0.5, restart: true }); @@ -280,7 +280,7 @@ it('maxVoices: при переполнении воруется самый тихий голос', async () => { const { audio, graph } = makeManager({ maxVoices: 2 }); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); await audio.play('bell', 0.9); await audio.play('bell', 0.1); @@ -297,9 +297,9 @@ const played: { key: string; opts: PlayOptions }[] = []; audio.onPlayed = (key, opts) => played.push({ key, opts }); await audio.unlock(); - await audio.load('m'); - await audio.load('a'); - await audio.load('bell'); + await audio.register('m'); + await audio.register('a'); + await audio.register('bell'); await audio.playMusic('m', { volume: 0.6, fade: 0 }); await audio.playAmbience('a', { fade: 0 }); @@ -317,7 +317,7 @@ const ended: string[] = []; audio.onEnded = (key) => ended.push(key); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); await audio.play('bell'); expect(ended).toHaveLength(0); @@ -332,7 +332,7 @@ const ended: string[] = []; audio.onEnded = (key) => ended.push(key); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); const h = await audio.play('bell'); h!.stop(0); @@ -346,7 +346,7 @@ await audio.unlock(); expect(audio.duration('m')).toBeNull(); - await audio.load('m'); + await audio.register('m'); await audio.preload('m'); expect(audio.duration('m')).toBeCloseTo(1.25); }); @@ -435,7 +435,7 @@ it('setVolume хендла сразу задаёт громкость слота', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('m'); + await audio.register('m'); const handle = await audio.playLoop('m', { fade: 0, volume: 1 }); handle!.setVolume(0.3); @@ -446,8 +446,8 @@ it('playMusic и playAmbience независимы: остановка музыки не глушит амбиент', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('m'); - await audio.load('a'); + await audio.register('m'); + await audio.register('a'); const music = await audio.playMusic('m', { fade: 0 }); const amb = await audio.playAmbience('a', { fade: 0 }); @@ -464,7 +464,7 @@ it('playAmbience дважды — прежний слот остановлен', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('a'); + await audio.register('a'); const first = await audio.playAmbience('a', { fade: 0 }); await audio.playAmbience('a', { fade: 0 }); @@ -478,7 +478,7 @@ it('playMusic с негодным ключом не глушит текущий трек', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('m'); + await audio.register('m'); await audio.playMusic('m', { fade: 0 }); const bad = await audio.playMusic('ghost', { fade: 0 }); @@ -489,7 +489,7 @@ it('playLoop играет в заданную шину и не занимает слот музыки', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('m'); + await audio.register('m'); const layer = await audio.playLoop('m', { bus: 'sfx', fade: 0 }); expect(layer).not.toBeNull(); @@ -511,7 +511,7 @@ it('bus в PlayOptions: разовый голос уходит в заданную шину', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('bell'); + await audio.register('bell'); // graph.gains: master, music, sfx, ambience await audio.play('bell', { volume: 0.5, bus: 'music' }); @@ -527,7 +527,7 @@ it('fadeOut: экспоненциальный спад от нуля поднимается до минимума', async () => { const { audio, graph } = makeManager(); await audio.unlock(); - await audio.load('m'); + await audio.register('m'); const layer = await audio.playLoop('m', { fade: 0, volume: 0 }); layer!.stop(0.5); @@ -569,7 +569,7 @@ const played: { key: string; opts: PlayOptions }[] = []; audio.onPlayed = (key, opts) => played.push({ key, opts }); await audio.unlock(); - await audio.load('m'); // createBuffer фейка требует unlock — берём его буфер + await audio.register('m'); // createBuffer фейка требует unlock — берём его буфер const buf = audio.createBuffer(new Float32Array(22050).fill(0.2))!; const handle = await audio.playLoopBuffer('music/meadows', buf, { fade: 0, volume: 0.7 }); diff --git a/packages/engine/src/audio/decode.ts b/packages/engine/src/audio/decode.ts new file mode 100644 index 0000000..d1df125 --- /dev/null +++ b/packages/engine/src/audio/decode.ts @@ -0,0 +1,46 @@ +/** + * Кэш декодированных аудио-буферов: реестр URL + decodeAudioData. + * Параллельные вызовы decode по одному ключу делят один in-flight промис — + * двойной fetch+decode при старте сцены исключён. + */ +export class BufferCache { + private urls = new Map(); + private buffers = new Map(); + private pending = new Map>(); + + /** Зарегистрировать URL ключа (без загрузки — файл читается в decode). */ + register(key: string, url: string): void { + this.urls.set(key, url); + } + + /** Уже декодированный буфер (без загрузки); не декодирован — null. */ + cached(key: string): AudioBuffer | null { + return this.buffers.get(key) ?? null; + } + + /** Декодировать (fetch + decodeAudioData); сбой — null с warn в консоль. */ + async decode(key: string, ctx: AudioContext): Promise { + const cached = this.buffers.get(key); + if (cached) return cached; + let flight = this.pending.get(key); + if (!flight) { + const url = this.urls.get(key); + if (!url) return null; + flight = this.fetchDecode(key, url, ctx).finally(() => this.pending.delete(key)); + this.pending.set(key, flight); + } + return flight; + } + + private async fetchDecode(key: string, url: string, ctx: AudioContext): Promise { + try { + const res = await fetch(url); + const buf = await ctx.decodeAudioData(await res.arrayBuffer()); + this.buffers.set(key, buf); + return buf; + } catch (err) { + console.warn(`[audio] не удалось декодировать ${key}:`, err); + return null; + } + } +} \ No newline at end of file diff --git a/packages/engine/src/audio/types.ts b/packages/engine/src/audio/types.ts new file mode 100644 index 0000000..056c911 --- /dev/null +++ b/packages/engine/src/audio/types.ts @@ -0,0 +1,53 @@ +/** + * Общие типы и константы аудио-подсистемы (шины, хендлы, опции). + * Реализации — в AudioManager (фасад), Voices (полифония sfx), Loops (лупы). + */ + +export interface AudioBuses { + master: GainNode; + music: GainNode; + sfx: GainNode; + ambience: GainNode; +} + +export type BusName = 'master' | 'music' | 'sfx' | 'ambience'; + +export interface MusicHandle { + stop(fadeSeconds?: number): void; + /** Задать громкость сразу (для медленных тик-фейдов поверх — локальные слои). */ + setVolume(volume: number): void; +} + +/** Хендл играющего разового sfx: параметры в полёте + остановка. */ +export interface SfxHandle { + stop(fadeSeconds?: number): void; + setVolume(volume: number): void; + setPan(pan: number): void; + setRate(rate: number): void; +} + +/** Опции разового sfx. */ +export interface PlayOptions { + /** Относительная громкость в шине (см. bus), 0..1+. */ + volume?: number; + /** Скорость воспроизведения 0.5..2 (джиттер шагов, вариации тона). */ + rate?: number; + /** Панорама -1..1; |pan| < 0.01 — узел StereoPanner не создаётся. */ + pan?: number; + /** Заглушить прежние играющие экземпляры этого же ключа (короткий фейд). */ + restart?: boolean; + /** Шина голоса (по умолчанию sfx) — например UI-звук в master. */ + bus?: BusName; +} + +export interface LoopOptions { + loop?: boolean; + fade?: number; + volume?: number; + bus?: BusName; +} + +export const DEFAULT_FADE = 1; +export const MIN_FADE_VOLUME = 0.0001; + +export const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); \ No newline at end of file diff --git a/packages/engine/src/core/Engine.ts b/packages/engine/src/core/Engine.ts index eea0498..7f84ccd 100644 --- a/packages/engine/src/core/Engine.ts +++ b/packages/engine/src/core/Engine.ts @@ -99,11 +99,23 @@ stop(): void { this.loop.stop(); + } + + /** + * Полная зачистка (HMR/выгрузка страницы): цикл, текущая сцена (exit — + * там сцены снимают свои подписки и вьюхи), эффекты, твины, ввод, + * FPS-метр, слушатели окна и Pixi-приложение. После destroy движок не годится. + */ + destroy(): void { + this.stop(); + void this.scenes.current?.exit(); + this.fx.clear(); + this.tweens.clear(); + this.events.clear(); this.input?.dispose(); this.fpsMeter?.dispose(); window.removeEventListener('resize', this.onWindowResize); - this.fx.clear(); - this.events.clear(); + this.renderer.app.destroy(true, { children: true }); } /** Счётчик фиксированных шагов с запуска (для агента и отладки). */ diff --git a/packages/engine/src/core/FpsMeter.ts b/packages/engine/src/core/FpsMeter.ts index 4d4d71b..9495cd2 100644 --- a/packages/engine/src/core/FpsMeter.ts +++ b/packages/engine/src/core/FpsMeter.ts @@ -14,6 +14,7 @@ private label = new PixelText({ text: 'FPS --', size: 10, color: 0xd7e4ec }); private frames = 0; private windowStart = 0; + private lastFps = 0; private shown = false; private onKey = (e: KeyboardEvent): void => { if (e.code !== 'F3') return; @@ -31,19 +32,25 @@ /** Учёт кадра отрисовки (Engine зовёт из render). */ frame(): void { - if (!this.shown) return; this.frames++; const now = performance.now(); const elapsed = now - this.windowStart; if (elapsed < 500) return; - this.label.text = `FPS ${Math.round((this.frames * 1000) / elapsed)}`; + this.lastFps = Math.round((this.frames * 1000) / elapsed); + this.frames = 0; + this.windowStart = now; + if (!this.shown) return; + this.label.text = `FPS ${this.lastFps}`; this.bg .clear() .rect(0, 0, this.label.width + 8, this.label.height + 4) .fill({ color: 0x101018, alpha: 0.8 }) .stroke({ color: 0x8899aa, width: 1 }); - this.frames = 0; - this.windowStart = now; + } + + /** Средний FPS за последнее окно (~0,5 с); считается всегда, даже скрытым. */ + get fps(): number { + return this.lastFps; } /** Показать/скрыть; после показа окно начинается с нуля. */ diff --git a/packages/engine/src/core/__tests__/Engine.test.ts b/packages/engine/src/core/__tests__/Engine.test.ts new file mode 100644 index 0000000..1569351 --- /dev/null +++ b/packages/engine/src/core/__tests__/Engine.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Pixi-зависимости — минимальные стабы: Engine в Node не инициализирует WebGL +// (init() не зовётся), но конструктор Renderer создаёт Application сразу. +vi.mock('pixi.js', () => { + class Container { + position = { set: () => undefined }; + scale = { set: () => undefined }; + visible = true; + destroyed = false; + addChild(..._c: unknown[]) { + return this; + } + destroy(_opts?: unknown): void {} + } + class Graphics extends Container {} + class Application { + stage = new Container(); + canvas = { style: {} as Record }; + renderer = { render: () => undefined }; + async init(): Promise {} + render(): void {} + destroy(_a?: unknown, _b?: unknown): void {} + } + class Text extends Container {} + return { Container, Graphics, Application, Text }; +}); + +import { Engine } from '../Engine'; +import type { Scene } from '../../scene/SceneManager'; + +function makeEngine(): Engine { + return new Engine({ + virtualWidth: 480, + virtualHeight: 270, + scale: 2, + background: 0x000000, + parent: {} as HTMLElement, + fixedFps: 60 + }); +} + +function makeScene(update: (dt: number) => void = () => {}, render: () => void = () => {}): Scene { + return { enter: () => {}, exit: () => {}, update, render }; +} + +describe('Engine (шаги без цикла)', () => { + it('stepTick: фиксированный шаг, dt = 1/fps в сцену, событие engine:tick', () => { + const engine = makeEngine(); + const dts: number[] = []; + void engine.scenes.push(makeScene((dt) => dts.push(dt))); + const ticks: number[] = []; + engine.events.on<{ tick: number }>('engine:tick', (p) => ticks.push(p!.tick)); + + engine.stepTicks(3); + + expect(engine.tickCount).toBe(3); + expect(dts).toEqual([1 / 60, 1 / 60, 1 / 60]); + expect(ticks).toEqual([1, 2, 3]); + }); + + it('stepTicks(render: true) вызывает render сцены', () => { + const engine = makeEngine(); + let renders = 0; + void engine.scenes.push(makeScene(() => {}, () => renders++)); + + engine.stepTicks(2, true); + expect(renders).toBe(2); + + renders = 0; + engine.stepTicks(2, false); // без рендера — только логика + expect(renders).toBe(0); + }); + + it('tickCount копится между вызовами, fixedStep = 1/fps', () => { + const engine = makeEngine(); + expect(engine.fixedStep).toBeCloseTo(1 / 60, 10); + engine.stepTicks(5); + expect(engine.tickCount).toBe(5); + engine.stepTicks(2); + expect(engine.tickCount).toBe(7); + }); + + it('исключение в шаге не оставляет счётчики вранья: тик не засчитан', () => { + const engine = makeEngine(); + void engine.scenes.push( + makeScene(() => { + throw new Error('бум'); + }) + ); + expect(() => engine.stepTick()).toThrow('бум'); + // Счётчик и событие не продвинулись — шаг не доигран. + expect(engine.tickCount).toBe(0); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/EventBus.test.ts b/packages/engine/src/core/__tests__/EventBus.test.ts new file mode 100644 index 0000000..f946f2a --- /dev/null +++ b/packages/engine/src/core/__tests__/EventBus.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventBus } from '../EventBus'; + +describe('EventBus', () => { + it('emit доставляет payload подписчикам темы', () => { + const bus = new EventBus(); + const got: number[] = []; + bus.on('hit', (n) => got.push(n)); + bus.emit('hit', 5); + bus.emit('hit', 7); + expect(got).toEqual([5, 7]); + }); + + it('чужая тема не получает событий; emit без подписчиков — no-op', () => { + const bus = new EventBus(); + const got: string[] = []; + bus.on('a', (s) => got.push(s)); + expect(() => bus.emit('b', 'x')).not.toThrow(); + bus.emit('a', 'y'); + expect(got).toEqual(['y']); + }); + + it('on возвращает off-функцию: отписка снимает только этот хендлер', () => { + const bus = new EventBus(); + const a = vi.fn(); + const b = vi.fn(); + const offA = bus.on('t', a); + bus.on('t', b); + + offA(); + bus.emit('t'); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('повторная off-отписка не роняет (idempotent)', () => { + const bus = new EventBus(); + const fn = vi.fn(); + const off = bus.on('t', fn); + off(); + expect(() => off()).not.toThrow(); + }); + + it('emit во время обработки: подписка внутри хендлера не роняет обход (снапшот Set)', () => { + const bus = new EventBus(); + const late = vi.fn(); + let added = false; + bus.on('t', () => { + if (!added) { + added = true; + bus.on('t', late); + } + }); + + bus.emit('t'); // снапшот на обходе: late на этом emit ещё не зовётся + expect(late).not.toHaveBeenCalled(); + bus.emit('t'); + expect(late).toHaveBeenCalledTimes(1); + }); + + it('clear снимает все подписки', () => { + const bus = new EventBus(); + const fn = vi.fn(); + bus.on('t', fn); + bus.clear(); + bus.emit('t'); + expect(fn).not.toHaveBeenCalled(); + }); + + it('один хендлер, подписанный дважды, зовётся один раз (Set)', () => { + const bus = new EventBus(); + const fn = vi.fn(); + bus.on('t', fn); + bus.on('t', fn); + bus.emit('t'); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/FpsMeter.test.ts b/packages/engine/src/core/__tests__/FpsMeter.test.ts index a59338a..2bf7708 100644 --- a/packages/engine/src/core/__tests__/FpsMeter.test.ts +++ b/packages/engine/src/core/__tests__/FpsMeter.test.ts @@ -104,7 +104,7 @@ meter.dispose(); }); - it('скрытые кадры не считаются: после нового показа окно чистое', () => { + it('после нового показа окно чистое: счётчики сброшены toggle-ом', () => { const env = installEnv(); const meter = new FpsMeter(); meter.toggle(); diff --git a/packages/engine/src/core/__tests__/fade.test.ts b/packages/engine/src/core/__tests__/fade.test.ts new file mode 100644 index 0000000..22f6b34 --- /dev/null +++ b/packages/engine/src/core/__tests__/fade.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { FadeState } from '../fade'; + +const lerpNum = (a: number, b: number, k: number): number => a + (b - a) * k; + +describe('FadeState', () => { + it('мгновенно задаёт значение при seconds = 0', () => { + const f = new FadeState(lerpNum, 0); + f.to(0.7); + expect(f.value).toBe(0.7); + expect(f.target).toBe(0.7); + expect(f.active).toBe(false); + }); + + it('линейно идёт к цели и точно её достигает', () => { + const f = new FadeState(lerpNum, 0); + f.to(1, 2); + f.update(0.5); + expect(f.value).toBeCloseTo(0.25); + f.update(0.5); + expect(f.value).toBeCloseTo(0.5); + f.update(1); + expect(f.value).toBe(1); + expect(f.active).toBe(false); + }); + + it('тик за пределами остатка не уводит значение дальше цели', () => { + const f = new FadeState(lerpNum, 0); + f.to(1, 1); + f.update(0.25); + f.update(10); // остаток 0.75 — лишнее не интерполируется + expect(f.value).toBe(1); + expect(f.active).toBe(false); + }); + + it('повторный to() в середине фейда стартует от текущего значения', () => { + const f = new FadeState(lerpNum, 0); + f.to(1, 4); + f.update(1); // 0.25 + f.to(0, 2); + expect(f.origin).toBeCloseTo(0.25); + f.update(1); + expect(f.value).toBeCloseTo(0.125); + f.update(1); + expect(f.value).toBe(0); + }); + + it('update без активного фейда — no-op', () => { + const f = new FadeState(lerpNum, 5); + f.update(100); + expect(f.value).toBe(5); + }); + + it('работает с произвольным типом (lerp цветов)', () => { + // Простейший лерп RGB-упакованного цвета по каналам. + const lerpColor = (a: number, b: number, k: number): number => { + const ch = (c: number, s: number) => (c >> s) & 0xff; + let out = 0; + for (let s = 0; s < 24; s += 8) { + const v = Math.round(lerpNum(ch(a, s), ch(b, s), k)); + out |= v << s; + } + return out; + }; + const f = new FadeState(lerpColor, 0xffffff); + f.to(0x000000, 1); + f.update(0.5); + expect(f.value).toBe(0x808080); + f.update(0.5); + expect(f.value).toBe(0x000000); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/core/fade.ts b/packages/engine/src/core/fade.ts new file mode 100644 index 0000000..5b8527d --- /dev/null +++ b/packages/engine/src/core/fade.ts @@ -0,0 +1,58 @@ +/** + * Плавный линейный переход значения к цели за заданное время. + * Чистый класс без Pixi (headless-тестируется): ambient/виньетка Lighting, + * оверлей переходов SceneManager — вместо ручных пар fadeLeft/fadeTotal. + */ +export class FadeState { + private from: T; + /** Поле не может называться `to` — имя занято методом to(). */ + private toVal: T; + private current: T; + private left = 0; + private total = 0; + + constructor( + private readonly lerp: (a: T, b: T, k: number) => T, + initial: T + ) { + this.from = this.toVal = this.current = initial; + } + + /** Текущее значение (в процессе фейда — интерполированное). */ + get value(): T { + return this.current; + } + + /** Цель, заданная последним to(). */ + get target(): T { + return this.toVal; + } + + /** Начальное значение текущего фейда (на момент запуска). */ + get origin(): T { + return this.from; + } + + /** Идёт ли сейчас фейд. */ + get active(): boolean { + return this.left > 0; + } + + /** Задать цель; seconds > 0 — плавный лерп от текущего значения. */ + to(target: T, seconds = 0): void { + this.from = this.current; + this.toVal = target; + this.total = Math.max(0, seconds); + this.left = this.total; + if (this.total === 0) this.current = target; + } + + /** Тик; по завершении значение точно равно цели. */ + update(dt: number): void { + if (this.left <= 0) return; + this.left = Math.max(0, this.left - dt); + const k = this.total > 0 ? 1 - this.left / this.total : 1; + this.current = this.lerp(this.from, this.toVal, k); + if (this.left === 0) this.current = this.toVal; + } +} \ No newline at end of file diff --git a/packages/engine/src/debug/DebugOverlay.ts b/packages/engine/src/debug/DebugOverlay.ts index 516682e..aa5c150 100644 --- a/packages/engine/src/debug/DebugOverlay.ts +++ b/packages/engine/src/debug/DebugOverlay.ts @@ -1,18 +1,21 @@ import { Text, Container } from 'pixi.js'; +import type { FpsMeter } from '../core/FpsMeter'; /** - * Дебаг-оверлей: FPS и произвольные строки статистики. - * Добавьте view в uiRoot и вызывайте update(dt) каждый тик. + * Дебаг-оверлей: FPS (из FpsMeter движка) и произвольные строки статистики. + * Добавьте view в uiRoot и вызывайте update(dt) каждый тик; перерисовка Text + * дорога (растеризация) — не чаще 0,5 с. */ export class DebugOverlay { readonly view: Container; private text: Text; private lines: string[] = []; private acc = 0; - private frames = 0; - private fps = 0; - constructor(enabled = true) { + constructor( + private readonly fpsMeter: FpsMeter, + enabled = true + ) { this.view = new Container(); this.view.visible = enabled; this.text = new Text({ @@ -29,12 +32,8 @@ update(dt: number): void { this.acc += dt; - this.frames++; - if (this.acc >= 0.5) { - this.fps = Math.round(this.frames / this.acc); - this.acc = 0; - this.frames = 0; - } - this.text.text = `fps ${this.fps}\n${this.lines.join('\n')}`; + if (this.acc < 0.5) return; + this.acc = 0; + this.text.text = `fps ${this.fpsMeter.fps}\n${this.lines.join('\n')}`; } } \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 2f7714c..69790a5 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -72,15 +72,13 @@ export { World, type Entity, type System } from './ecs/ecs'; // math -export { vec2, add, sub, len, dist, moveTo, type Vec2 } from './math/Vec2'; +export { add, sub, len, dist, moveTo, type Vec2 } from './math/Vec2'; export { isoToScreen, screenToIso, - screenToIsoExact, worldToScreen, screenToWorld, unitsToPx, - pxToUnits, worldLen, worldNorm, worldDist, @@ -116,7 +114,6 @@ gridOf, circleFits, moveCircle, - pushOutOfWalls, separateCircles } from './map/collision'; export { @@ -195,7 +192,6 @@ export { VirtualJoystick, type VirtualJoystickOptions } from './input/VirtualJoystick'; // anim -export { FrameAnimation } from './anim/FrameAnimation'; export { TintFlash, lerpColor } from './anim/flash'; export { SpriteAnimator } from './anim/SpriteAnimator'; export type { SpriteClip, SpriteAnimatorOptions } from './anim/SpriteAnimator'; diff --git a/packages/engine/src/input/InputManager.ts b/packages/engine/src/input/InputManager.ts index 5e0900c..10936b6 100644 --- a/packages/engine/src/input/InputManager.ts +++ b/packages/engine/src/input/InputManager.ts @@ -223,6 +223,56 @@ /** pointerdown в виртуальных пикселях (без DOM). */ injectPointerDown(x: number, y: number, isTouch = false): void { + this.pressPointer(x, y, isTouch); + } + + /** pointerup в виртуальных пикселях (без DOM). */ + injectPointerUp(x: number, y: number, isTouch = false): void { + this.releasePointer(x, y, isTouch); + } + + /** Действие «нажато» (isActionJustPressed на следующем тике). */ + injectAction(action: string): void { + for (const code of this.codesOf(action)) this.pressCode(code); + } + + /** Действие «отпущено». */ + injectActionRelease(action: string): void { + for (const code of this.codesOf(action)) this.releaseCode(code); + } + + /** Сырой код клавиши (e.code) — нажать и отпустить нельзя по отдельности: см. injectKeyCodeUp. */ + injectKeyCode(code: string): void { + this.pressCode(code); + } + + /** Сырой код клавиши (e.code) — отпустить. */ + injectKeyCodeUp(code: string): void { + this.releaseCode(code); + } + + // --- общие примитивы (DOM-события и инъекции идут через одни и те же) --- + + private pressCode(code: string): void { + if (!this.keysDown.has(code)) this.keysPressed.add(code); + this.keysDown.add(code); + } + + private releaseCode(code: string): void { + this.keysDown.delete(code); + this.keysReleased.add(code); + } + + /** Все коды клавиш, замапленные на действие. */ + private codesOf(action: string): string[] { + const out: string[] = []; + for (const [code, actions] of this.keyActions) { + if (actions.includes(action)) out.push(code); + } + return out; + } + + private pressPointer(x: number, y: number, isTouch: boolean): void { this.pointer.x = x; this.pointer.y = y; this.pointer.down = true; @@ -232,8 +282,7 @@ this.handlers.onPointerDown?.(this.pointer); } - /** pointerup в виртуальных пикселях (без DOM). */ - injectPointerUp(x: number, y: number, isTouch = false): void { + private releasePointer(x: number, y: number, isTouch: boolean): void { this.pointer.x = x; this.pointer.y = y; this.pointer.down = false; @@ -242,48 +291,12 @@ this.handlers.onPointerUp?.(this.pointer); } - /** Действие «нажато» (isActionJustPressed на следующем тике). */ - injectAction(action: string): void { - for (const [code, actions] of this.keyActions) { - if (actions.includes(action)) { - if (!this.keysDown.has(code)) this.keysPressed.add(code); - this.keysDown.add(code); - } - } - } - - /** Действие «отпущено». */ - injectActionRelease(action: string): void { - for (const [code, actions] of this.keyActions) { - if (actions.includes(action)) { - this.keysDown.delete(code); - this.keysReleased.add(code); - } - } - } - - /** Сырой код клавиши (e.code) — нажать и отпустить нельзя по отдельности: см. injectKeyCodeUp. */ - injectKeyCode(code: string): void { - if (!this.keysDown.has(code)) this.keysPressed.add(code); - this.keysDown.add(code); - } - - /** Сырой код клавиши (e.code) — отпустить. */ - injectKeyCodeUp(code: string): void { - this.keysDown.delete(code); - this.keysReleased.add(code); - } - private onKeyDown = (e: KeyboardEvent): void => { - if (!this.keysDown.has(e.code)) { - this.keysPressed.add(e.code); - } - this.keysDown.add(e.code); + this.pressCode(e.code); }; private onKeyUp = (e: KeyboardEvent): void => { - this.keysDown.delete(e.code); - this.keysReleased.add(e.code); + this.releaseCode(e.code); }; private toVirtual(e: PointerEvent): { x: number; y: number } { @@ -296,23 +309,12 @@ private onPointerDown = (e: PointerEvent): void => { const { x, y } = this.toVirtual(e); - this.pointer.x = x; - 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); + this.pressPointer(x, y, e.pointerType === 'touch'); }; private onPointerUp = (e: PointerEvent): void => { const { x, y } = this.toVirtual(e); - this.pointer.x = x; - this.pointer.y = y; - this.pointer.down = false; - this.pointer.justReleased = true; - this.pointer.isTouch = e.pointerType === 'touch'; - this.handlers.onPointerUp?.(this.pointer); + this.releasePointer(x, y, e.pointerType === 'touch'); }; private onPointerMove = (e: PointerEvent): void => { diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index 43a6538..0776ae4 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -1,6 +1,6 @@ import { Container, Graphics, Texture, Sprite } from 'pixi.js'; import { Grid } from './pathfinding'; -import { shadowTilesOf } from './collision'; +import { StaticGrid } from './collision'; import { IsoLayout, DEFAULT_ISO, isoToScreen } from '../math/iso'; import { hashTile } from '../math/rng'; @@ -119,15 +119,13 @@ readonly view: Container; private textures: Map; - private blockedSet: Set; - /** Тайлы «тени» высоких объектов (tall.shadow) — непроходимы (см. collision). */ - private shadowSet: Set; + /** Проходимость (blocked/пропы/тени) — единая с collision.gridOf логика. */ + private grid: StaticGrid; private tallSpecs: Map; /** Пропы после нормализации; их footprint блокирует клетки карты. */ readonly props: PropPlaced[]; /** Спрайты пропов: сцена вставляет их в свой IsoDepthLayer (addRect). */ readonly propViews: PropView[] = []; - private propBlocked = new Set(); private groundLayer: Container; private decorLayer: Container; private objectsLayer: Container; @@ -151,8 +149,7 @@ this.data = data; this.iso = iso; this.textures = textures; - this.blockedSet = new Set(data.blocked); - this.shadowSet = shadowTilesOf(data); + this.grid = new StaticGrid(data); for (const [id, ids] of Object.entries(data.variants ?? {})) { this.variants.set(Number(id), ids); } @@ -186,15 +183,10 @@ this.drawCell(cell.tx, cell.ty); } - // Пропы: footprint блокирует клетки, под ним рисуем ground, спрайт — - // в propViews (сцена вставит его в свой depth-слой с сортировкой addRect). + // Пропы: footprint блокирует клетки (в grid), под ним рисуем ground, + // спрайт — в propViews (сцена вставит его в свой depth-слой с addRect). this.props = (data.props ?? []).map((p) => ({ ...p, w: p.w ?? 1, h: p.h ?? 1 })); for (const prop of this.props) { - for (let ty = prop.y; ty < prop.y + prop.h; ty++) { - for (let tx = prop.x; tx < prop.x + prop.w; tx++) { - this.propBlocked.add(ty * data.width + tx); - } - } this.drawPropGround(prop); this.propViews.push({ view: this.makePropView(prop), prop }); } @@ -380,7 +372,7 @@ const hw = iso.tileW / 2; const hh = iso.tileH / 2; g.poly([p.x, p.y, p.x + hw, p.y + hh, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]); - g.fill(this.blockedSet.has(id) ? 0x4a3b2a : 0x2d5a27); + g.fill(this.grid.isBlockedId(id) ? 0x4a3b2a : 0x2d5a27); this.groundLayer.addChild(g); fresh.groundPlaceholder = g; } @@ -414,16 +406,12 @@ } isWalkable(x: number, y: number): boolean { - if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return false; - if (this.propBlocked.has(y * this.data.width + x)) return false; - if (this.shadowSet.has(y * this.data.width + x)) return false; - const id = this.data.tiles[y * this.data.width + x]; - return !this.blockedSet.has(id); + return this.grid.isWalkable(x, y); } /** Тайл накрыт footprint'ом пропа (для LOS/снарядов: дом — препятствие). */ isPropBlocked(x: number, y: number): boolean { - return this.propBlocked.has(y * this.data.width + x); + return this.grid.isPropBlocked(x, y); } /** Границы карты в мировых юнитах (для ограничения камеры). */ diff --git a/packages/engine/src/map/__tests__/collision.test.ts b/packages/engine/src/map/__tests__/collision.test.ts index 89223bd..7b245cb 100644 --- a/packages/engine/src/map/__tests__/collision.test.ts +++ b/packages/engine/src/map/__tests__/collision.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { circleFits, gridOf, moveCircle, pushOutOfWalls, separateCircles, shadowTilesOf } from '../collision'; +import { circleFits, gridOf, moveCircle, separateCircles, shadowTilesOf } from '../collision'; import type { Grid } from '../pathfinding'; import type { TileMapData } from '../IsometricTileMap'; @@ -150,30 +150,6 @@ }); }); -describe('pushOutOfWalls', () => { - it('уже свободен — позиция не тронута', () => { - const g = gridOf(testMap()); - const pos = { x: 2.31, y: 2.87 }; - pushOutOfWalls(g, pos, 0.35); - expect(pos).toEqual({ x: 2.31, y: 2.87 }); - }); - - it('в стене — выталкивает в центр ближайшего свободного тайла', () => { - const g = gridOf(testMap()); - const pos = { x: 4.5, y: 3.5 }; // внутри стены - pushOutOfWalls(g, pos, 0.35); - expect(g.isWalkable(Math.floor(pos.x), Math.floor(pos.y))).toBe(true); - expect(circleFits(g, pos, 0.35)).toBe(true); - }); - - it('вне карты — вытолкнут обратно', () => { - const g = gridOf(testMap()); - const pos = { x: 9.5, y: 3.5 }; - pushOutOfWalls(g, pos, 0.35); - expect(circleFits(g, pos, 0.35)).toBe(true); - }); -}); - describe('separateCircles', () => { it('перекрытие — a выталкивается ровно на глубину, b не трогается', () => { const a = { x: 1.0, y: 0.0 }; diff --git a/packages/engine/src/map/collision.ts b/packages/engine/src/map/collision.ts index a535ce7..cd0aa7a 100644 --- a/packages/engine/src/map/collision.ts +++ b/packages/engine/src/map/collision.ts @@ -30,27 +30,58 @@ return out; } -/** Grid-адаптер над сырыми данными карты: blocked-иды + footprint пропов. */ -export function gridOf(data: TileMapData): Grid { - const blockedSet = new Set(data.blocked); - const propBlocked = new Set(); - for (const p of data.props ?? []) { - const w = p.w ?? 1; - const h = p.h ?? 1; - for (let ty = p.y; ty < p.y + h; ty++) { - for (let tx = p.x; tx < p.x + w; tx++) propBlocked.add(ty * data.width + tx); +/** + * Grid над сырыми данными карты (без Pixi): blocked-иды + footprint пропов + + * тени высоких объектов. Единый источник проходимости: IsometricTileMap + * делегирует сюда, headless-потребители (валидатор, фауна, снапшот) берут + * через gridOf. data.tiles читается на каждый вызов — setTile виден сразу. + */ +export class StaticGrid implements Grid { + private blockedSet: Set; + private propBlocked = new Set(); + private shadowSet: Set; + + constructor(private data: TileMapData) { + this.blockedSet = new Set(data.blocked); + this.shadowSet = shadowTilesOf(data); + for (const p of data.props ?? []) { + const w = p.w ?? 1; + const h = p.h ?? 1; + for (let ty = p.y; ty < p.y + h; ty++) { + for (let tx = p.x; tx < p.x + w; tx++) this.propBlocked.add(ty * data.width + tx); + } } } - const shadowSet = shadowTilesOf(data); - return { - width: data.width, - height: data.height, - isWalkable: (x, y) => - x >= 0 && y >= 0 && x < data.width && y < data.height && - !propBlocked.has(y * data.width + x) && - !shadowSet.has(y * data.width + x) && - !blockedSet.has(data.tiles[y * data.width + x]!) - }; + + get width(): number { + return this.data.width; + } + + get height(): number { + return this.data.height; + } + + isWalkable(x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return false; + if (this.propBlocked.has(y * this.data.width + x)) return false; + if (this.shadowSet.has(y * this.data.width + x)) return false; + return !this.blockedSet.has(this.data.tiles[y * this.data.width + x]!); + } + + /** Тайл накрыт footprint'ом пропа. */ + isPropBlocked(x: number, y: number): boolean { + return this.propBlocked.has(y * this.data.width + x); + } + + /** id тайла — из набора блокирующих (для плейсхолдеров рендера). */ + isBlockedId(id: number): boolean { + return this.blockedSet.has(id); + } +} + +/** Grid-адаптер над сырыми данными карты. */ +export function gridOf(data: TileMapData): Grid { + return new StaticGrid(data); } /** Тайл блокирует движение (вне карты — тоже стена). */ @@ -103,32 +134,6 @@ } /** - * Вытолкнуть круг из стены (после отброса/телепорта): ищет ближайший по - * спирали тайл, в чей центр круг помещается, и ставит pos в его центр. - * Свободного в радиусе 3 тайлов нет — позиция не меняется. - */ -export function pushOutOfWalls(grid: Grid, pos: Vec2, r: number): void { - if (circleFits(grid, pos, r)) return; - const cx = Math.floor(pos.x); - const cy = Math.floor(pos.y); - for (let ring = 0; ring <= 3; ring++) { - for (let ty = cy - ring; ty <= cy + ring; ty++) { - for (let tx = cx - ring; tx <= cx + ring; tx++) { - // Кольцо: периметр квадрата (внутренние уже проверены). - const onRing = Math.max(Math.abs(tx - cx), Math.abs(ty - cy)) === ring; - if (!onRing) continue; - const center = { x: tx + 0.5, y: ty + 0.5 }; - if (circleFits(grid, center, r)) { - pos.x = center.x; - pos.y = center.y; - return; - } - } - } - } -} - -/** * Растолкать круг a из круга b: a сдвигается на глубину перекрытия * (двигаем только a — каждый актор выталкивает себя сам). Слившиеся * в точку круги расходятся по +X (детерминированный фолбэк). diff --git a/packages/engine/src/math/Vec2.ts b/packages/engine/src/math/Vec2.ts index 0aeefa0..45ed782 100644 --- a/packages/engine/src/math/Vec2.ts +++ b/packages/engine/src/math/Vec2.ts @@ -6,10 +6,6 @@ y: number; } -export function vec2(x: number, y: number): Vec2 { - return { x, y }; -} - export function add(a: Vec2, b: Vec2): Vec2 { return { x: a.x + b.x, y: a.y + b.y }; } diff --git a/packages/engine/src/math/__tests__/iso.test.ts b/packages/engine/src/math/__tests__/iso.test.ts index 0a65c69..8a8ea4a 100644 --- a/packages/engine/src/math/__tests__/iso.test.ts +++ b/packages/engine/src/math/__tests__/iso.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isoToScreen, screenToIso, screenToIsoExact } from '../iso'; +import { isoToScreen, screenToIso } from '../iso'; describe('изометрия', () => { it('нулевая точка — origin', () => { @@ -22,17 +22,4 @@ } } }); - - it('screenToIsoExact попадает ровно в тайл, не в соседний', () => { - // Точка чуть ниже центра ромба (0,0) — остаётся в (0,0). - const center = isoToScreen(0, 0); - expect(screenToIsoExact(center.x, center.y + 5, 4, 4)).toEqual({ x: 0, y: 0 }); - // Точка близко к левой грани, но внутри ромба (0,0). - const edge = isoToScreen(0, 0); - expect(screenToIsoExact(edge.x - 8, edge.y + 4, 4, 4)).toEqual({ x: 0, y: 0 }); - // Точка внутри ромба соседнего тайла (-1, 0) — он вне карты -> null. - expect(screenToIsoExact(edge.x - 16, edge.y, 4, 4)).toBeNull(); - // Вне карты — null. - expect(screenToIsoExact(-100, -100, 4, 4)).toBeNull(); - }); }); \ No newline at end of file diff --git a/packages/engine/src/math/__tests__/world.test.ts b/packages/engine/src/math/__tests__/world.test.ts index dd8e227..8805efd 100644 --- a/packages/engine/src/math/__tests__/world.test.ts +++ b/packages/engine/src/math/__tests__/world.test.ts @@ -4,7 +4,6 @@ worldToScreen, screenToWorld, unitsToPx, - pxToUnits, worldLen, worldNorm, worldDist, @@ -46,9 +45,6 @@ it('линейка проекции — tileW px на юнит', () => { expect(unitsToPx(1)).toBe(32); expect(unitsToPx(1.5)).toBe(48); - expect(pxToUnits(32)).toBe(1); - expect(pxToUnits(48)).toBe(1.5); - expect(pxToUnits(unitsToPx(2.75))).toBeCloseTo(2.75, 10); }); }); diff --git a/packages/engine/src/math/iso.ts b/packages/engine/src/math/iso.ts index 60d345c..17e57e0 100644 --- a/packages/engine/src/math/iso.ts +++ b/packages/engine/src/math/iso.ts @@ -40,37 +40,6 @@ return { x: Math.floor((dx + dy) / 2), y: Math.floor((dy - dx) / 2) }; } -/** - * Точное попадание внутрь ромба тайла (floor может дать соседний тайл на границе). - * Возвращает координаты тайла, ромб которого содержит точку, или null. - */ -export function screenToIsoExact( - sx: number, - sy: number, - mapW: number, - mapH: number, - iso: IsoLayout = DEFAULT_ISO -): { x: number; y: number } | null { - const cand = screenToIso(sx, sy, iso); - for (let dy = 0; dy <= 1; dy++) { - for (let dx = 0; dx <= 1; dx++) { - const tx = cand.x + dx; - const ty = cand.y + dy; - const c = isoToScreen(tx, ty, iso); - const rx = sx - c.x; - const ry = sy - c.y; - // Ромб: |x/halfW| + |y/halfH| <= 1 - if (Math.abs(rx) / (iso.tileW / 2) + Math.abs(ry) / (iso.tileH / 2) <= 1) { - if (tx >= 0 && ty >= 0 && tx < mapW && ty < mapH) { - return { x: tx, y: ty }; - } - return null; - } - } - } - return null; -} - // ---------- мировые юниты ---------- // // Позиции хранятся в мировых юнитах (1 юнит = 1 тайл), float в мировой плоскости. @@ -103,11 +72,6 @@ return units * iso.tileW; } -/** Скаляр из пикселей в юниты. */ -export function pxToUnits(px: number, iso: IsoLayout = DEFAULT_ISO): number { - return px / iso.tileW; -} - /** Длина смещения в юнитах по «метрике проекции»: |M(d)| / tileW. */ export function worldLen(dx: number, dy: number, iso: IsoLayout = DEFAULT_ISO): number { const sx = (dx - dy) * (iso.tileW / 2); diff --git a/packages/engine/src/render/Lighting.ts b/packages/engine/src/render/Lighting.ts index 2351dde..50eb2ca 100644 --- a/packages/engine/src/render/Lighting.ts +++ b/packages/engine/src/render/Lighting.ts @@ -9,6 +9,7 @@ */ import { Container, Graphics, Sprite, Texture } from 'pixi.js'; +import { FadeState } from '../core/fade'; import type { Renderer } from './Renderer'; import { GLOW_BASE_PX, @@ -33,17 +34,13 @@ maxDarkSpots?: number; } -/** Кэш текстур свечения по числу ступеней — генерируется один раз на рендерер. */ -const glowCache = new Map(); - /** * Ступенчатая пиксельная текстура свечения: концентрические круги с дискретной * альфой (никаких гладких градиентов) + дизер-кольцо по внешнему радиусу. - * resolution 1 + nearest — пиксельные ступени. + * resolution 1 + nearest — пиксельные ступени. Кэшируется вызывающим + * (Lighting держит её на инстансе — модульный кэш переживал destroy рендерера). */ export function makeGlowTexture(renderer: Renderer, steps = 4): Texture { - const cached = glowCache.get(steps); - if (cached) return cached; const g = new Graphics(); for (let i = steps; i >= 1; i--) { const r = (GLOW_BASE_PX * i) / steps; @@ -64,21 +61,15 @@ textureSourceOptions: { scaleMode: 'nearest' } }); g.destroy(); - glowCache.set(steps, tex); return tex; } -/** Кэш текстур виньетки по размеру сцены. */ -const vignetteCache = new Map(); - /** * Ступенчатая виньетка: белый центр → серые кольца к тёмным углам (multiply). - * Интенсивность задаётся alpha спрайта, текстура постоянна. + * Интенсивность задаётся alpha спрайта, текстура постоянна. Кэшируется + * вызывающим (см. makeGlowTexture). */ export function makeVignetteTexture(renderer: Renderer, width: number, height: number): Texture { - const key = `${width}x${height}`; - const cached = vignetteCache.get(key); - if (cached) return cached; const g = new Graphics(); const r = Math.hypot(width, height) / 2; // дистанция центра до угла g.rect(0, 0, width, height).fill({ color: 0x6a6a74 }); @@ -91,7 +82,6 @@ textureSourceOptions: { scaleMode: 'nearest' } }); g.destroy(); - vignetteCache.set(key, tex); return tex; } @@ -146,19 +136,15 @@ private readonly darks = new Map(); private readonly pulses = new Map(); private readonly glow: Texture; + /** Сгенерированные (не WHITE) текстуры — уничтожаются в destroy(). */ + private readonly generated: Texture[] = []; private readonly maxLights: number; private readonly maxDarkSpots: number; private pulseSeq = 0; private time = 0; - private ambientFrom = 0xffffff; - private ambientTo = 0xffffff; - private fadeLeft = 0; - private fadeTotal = 0; - private vignetteFrom = 0; - private vignetteTo = 0; - private vignetteLeft = 0; - private vignetteTotal = 0; + private ambientFade: FadeState; + private vignetteFade: FadeState; private ambPulse: AmbientPulse | null = null; constructor(options: LightingOptions) { @@ -171,16 +157,20 @@ this.ambient.height = options.height; this.ambient.blendMode = 'multiply'; this.ambient.tint = 0xffffff; + this.ambientFade = new FadeState(lerpAmbient, 0xffffff); this.addChild(this.ambient); // Виньетка: Texture.WHITE без рендерера (multiply белого = не трогает). - this.vignette = new Sprite( - options.renderer ? makeVignetteTexture(options.renderer, options.width, options.height) : Texture.WHITE - ); + const vignetteTex = options.renderer + ? makeVignetteTexture(options.renderer, options.width, options.height) + : Texture.WHITE; + if (options.renderer) this.generated.push(vignetteTex); + this.vignette = new Sprite(vignetteTex); this.vignette.width = options.width; this.vignette.height = options.height; this.vignette.blendMode = 'multiply'; this.vignette.alpha = 0; + this.vignetteFade = new FadeState((a, b, k) => a + (b - a) * k, 0); this.addChild(this.vignette); // Тёмные пятна — под источниками: свет пробивает локальную тень. @@ -188,6 +178,7 @@ this.addChild(this.darkLayer); this.glow = options.renderer ? makeGlowTexture(options.renderer) : Texture.WHITE; + if (options.renderer) this.generated.push(this.glow); for (let i = 0; i < this.maxLights; i++) this.free.push(this.makeLightSprite()); for (let i = 0; i < this.maxDarkSpots; i++) this.darkFree.push(this.makeDarkSprite()); @@ -223,40 +214,26 @@ /** Задать ambient-цвет; fadeSec > 0 — плавный лерп (день/ночь, закат). */ setAmbient(color: number, fadeSec = 0): void { - this.ambientFrom = this.currentAmbient; - this.ambientTo = color; - this.fadeTotal = Math.max(0, fadeSec); - this.fadeLeft = this.fadeTotal; - if (this.fadeTotal === 0) this.ambient.tint = color; + this.ambientFade.to(color, Math.max(0, fadeSec)); + this.ambient.tint = this.ambientFade.value; } /** Целевой (заданный) ambient-цвет. */ get ambientColor(): number { - return this.ambientTo; - } - - private get currentAmbient(): number { - return this.fadeLeft > 0 ? this.ambientFrom : this.ambient.tint; + return this.ambientFade.target; } // --- виньетка (край экрана темнеет: низкий hp, опасная зона) --- /** Виньетка 0..1 (0 — не трогает); fadeSec > 0 — плавный лерп. */ setVignette(intensity: number, fadeSec = 0): void { - this.vignetteFrom = this.currentVignette; - this.vignetteTo = Math.max(0, Math.min(1, intensity)); - this.vignetteTotal = Math.max(0, fadeSec); - this.vignetteLeft = this.vignetteTotal; - if (this.vignetteTotal === 0) this.vignette.alpha = this.vignetteTo; + this.vignetteFade.to(Math.max(0, Math.min(1, intensity)), Math.max(0, fadeSec)); + this.vignette.alpha = this.vignetteFade.value; } /** Целевая интенсивность виньетки. */ get vignetteLevel(): number { - return this.vignetteTo; - } - - private get currentVignette(): number { - return this.vignetteLeft > 0 ? this.vignetteFrom : this.vignette.alpha; + return this.vignetteFade.target; } // --- источники: runtime add/remove/move/color/enable --- @@ -388,18 +365,10 @@ /** Тик: время, лерп ambient/виньетки, огибающие импульсов, кадры источников. */ update(dt: number): void { this.time += dt; - if (this.fadeLeft > 0) { - this.fadeLeft = Math.max(0, this.fadeLeft - dt); - const k = this.fadeTotal > 0 ? 1 - this.fadeLeft / this.fadeTotal : 1; - this.ambient.tint = lerpAmbient(this.ambientFrom, this.ambientTo, k); - if (this.fadeLeft === 0) this.ambient.tint = this.ambientTo; - } - if (this.vignetteLeft > 0) { - this.vignetteLeft = Math.max(0, this.vignetteLeft - dt); - const k = this.vignetteTotal > 0 ? 1 - this.vignetteLeft / this.vignetteTotal : 1; - this.vignette.alpha = this.vignetteFrom + (this.vignetteTo - this.vignetteFrom) * k; - if (this.vignetteLeft === 0) this.vignette.alpha = this.vignetteTo; - } + this.ambientFade.update(dt); + this.ambient.tint = this.ambientFade.value; + this.vignetteFade.update(dt); + this.vignette.alpha = this.vignetteFade.value; for (const [id, p] of [...this.pulses]) { p.elapsed += dt; if (p.elapsed >= pulseTotal(p.spec)) { @@ -440,14 +409,19 @@ override destroy(options?: Parameters[0]): void { this.pulses.clear(); for (const id of [...this.lights.keys()]) this.removeLight(id); + for (const id of [...this.darks.keys()]) this.removeDarkSpot(id); for (const s of this.free) s.destroy(); this.free.length = 0; this.darks.clear(); + // Тёмные спрайты — дети darkLayer: destroy явно и один раз, слой без children. + for (const s of this.darkFree) s.destroy(); this.darkFree.length = 0; - this.darkLayer.destroy({ children: true }); + this.darkLayer.destroy({ children: false }); this.ambient.destroy(); this.vignette.destroy(); this.pulseAmb.destroy(); + for (const tex of this.generated) tex.destroy(true); + this.generated.length = 0; super.destroy(options); } } \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/scale.test.ts b/packages/engine/src/render/__tests__/scale.test.ts new file mode 100644 index 0000000..867fda6 --- /dev/null +++ b/packages/engine/src/render/__tests__/scale.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { computeScale } from '../scale'; + +describe('computeScale', () => { + it('целый множитель: 480×270 в окно 960×540 — x2', () => { + expect(computeScale(480, 270, 960, 540)).toBe(2); + }); + + it('берёт минимум по осям (вписать целиком)', () => { + expect(computeScale(480, 270, 960, 810)).toBe(2); // по высоте больше — режет ширина + expect(computeScale(480, 270, 1440, 540)).toBe(2); // по ширине больше — режет высота + }); + + it('дробное вмещение округляется вниз до целого (pixel-perfect)', () => { + expect(computeScale(480, 270, 1000, 600)).toBe(2); + expect(computeScale(480, 270, 1200, 675)).toBe(2); + expect(computeScale(480, 270, 1440, 810)).toBe(3); + }); + + it('маленькое окно — минимум 1 (картинка не исчезает)', () => { + expect(computeScale(480, 270, 100, 100)).toBe(1); + expect(computeScale(480, 270, 0, 0)).toBe(1); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/render/fx/FxFade.ts b/packages/engine/src/render/fx/FxFade.ts index 181a146..4aab21c 100644 --- a/packages/engine/src/render/fx/FxFade.ts +++ b/packages/engine/src/render/fx/FxFade.ts @@ -28,10 +28,19 @@ this.from = view.alpha; } - /** Прекратить эффект и вернуть базовую альфу (вьюху не трогаем дальше). */ + /** + * Прекратить эффект: вернуть базовую альфу; вьюху, которую эффект уничтожил + * бы в конце (!keep), гасим сразу — контракт fx: cancel = конец эффекта. + */ cancel(): void { + if (this.finished) return; this.finished = true; - if (!this.view.destroyed) this.view.alpha = this.from; + if (this.view.destroyed) return; + this.view.alpha = this.from; + if (!this.opts.keep) { + this.view.parent?.removeChild(this.view); + this.view.destroy({ children: true }); + } } /** Вьюха разрушена извне или эффект закончился — запись вычистится сама. */ diff --git a/packages/engine/src/render/fx/FxLayer.ts b/packages/engine/src/render/fx/FxLayer.ts index 91165e3..fe2b632 100644 --- a/packages/engine/src/render/fx/FxLayer.ts +++ b/packages/engine/src/render/fx/FxLayer.ts @@ -31,11 +31,16 @@ /** * Слой визуальных эффектов: сам тикается (реализует Updatable — добавить один * раз в engine.fx), спавнит one-shot пресеты и сам вычищает закончившиеся. + * Все тикающиеся эффекты — в одном реестре items; flashes — только индекс для + * перезапуска флэша по спрайту (сами SpriteFlash тикаются из items). + * Контракт эффектов: cancel() = остановить, вернуть базовое состояние, + * уничтожить свою вьюху; конец жизни — destroyed (done — алиас у своих). * Позиции — локальные px контейнера, куда добавлен слой (worldRoot — мировые * эффекты через проекцию сцены, uiRoot — тосты/HUD). Изометрию слой не знает. */ export class FxLayer extends Container implements Updatable { private readonly items = new Updatables(); + /** Индекс флэшей по спрайту (перезапуск вместо дубля; тик — из items). */ private readonly flashes = new Map(); private readonly defaultSeed: number; private seedCounter = 0; @@ -66,13 +71,6 @@ 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 — телеграф зоны. */ @@ -102,9 +100,10 @@ /** Хит-флэш: один SpriteFlash на спрайт, повторный вызов перезапускает. */ flash(sprite: Sprite, color: number, seconds?: number): void { let flash = this.flashes.get(sprite); - if (!flash) { + if (!flash || flash.destroyed) { flash = new SpriteFlash(sprite, color, seconds); this.flashes.set(sprite, flash); + this.items.add(flash); } flash.start(); } diff --git a/packages/engine/src/render/fx/FxTrail.ts b/packages/engine/src/render/fx/FxTrail.ts index c8846c9..9706d3d 100644 --- a/packages/engine/src/render/fx/FxTrail.ts +++ b/packages/engine/src/render/fx/FxTrail.ts @@ -31,6 +31,13 @@ this.view.setOptions({ rate: 0 }); } + /** Погасить немедленно (контракт fx: cancel = конец + уничтожить свою вьюху). */ + cancel(): void { + if (this.finished) return; + this.finished = true; + if (!this.view.destroyed) this.view.destroy({ children: true }); + } + get destroyed(): boolean { return this.finished || this.anchor.destroyed; } diff --git a/packages/engine/src/render/fx/__tests__/FxLayer.test.ts b/packages/engine/src/render/fx/__tests__/FxLayer.test.ts index d3cbb3c..8cebaf9 100644 --- a/packages/engine/src/render/fx/__tests__/FxLayer.test.ts +++ b/packages/engine/src/render/fx/__tests__/FxLayer.test.ts @@ -82,6 +82,28 @@ view.destroy({ children: true }); expect(() => layer.update(TICK)).not.toThrow(); }); + + it('cancel без keep: альфа возвращена, вьюха погашена (контракт fx)', () => { + const layer = new FxLayer(); + const view = new Container(); + view.alpha = 1; + const fx = layer.fade(view, { duration: 1 }); + layer.update(TICK); + fx.cancel(); + expect(view.alpha).toBe(1); + expect(view.destroyed).toBe(true); + }); + + it('cancel с keep: альфа возвращена, вьюха жива (чужая — не наша)', () => { + const layer = new FxLayer(); + const view = new Container(); + view.alpha = 1; + const fx = layer.fade(view, { duration: 1, keep: true }); + layer.update(TICK); + fx.cancel(); + expect(view.alpha).toBe(1); + expect(view.destroyed).toBe(false); + }); }); describe('FxLayer.pop', () => { @@ -146,6 +168,15 @@ for (let i = 0; i < 20; i++) layer.update(TICK); expect(trail.destroyed).toBe(true); }); + + it('cancel() гасит эмиттер немедленно', () => { + const layer = new FxLayer(); + const anchor = new Container(); + const trail = layer.trail(anchor, { rate: 30, lifetime: [0.1, 0.1], seed: 7 }); + trail.cancel(); + expect(trail.destroyed).toBe(true); + expect(trail.view.destroyed).toBe(true); + }); }); describe('FxLayer.clear', () => { diff --git a/packages/engine/src/render/spriteFx.ts b/packages/engine/src/render/spriteFx.ts index f316004..a821bb7 100644 --- a/packages/engine/src/render/spriteFx.ts +++ b/packages/engine/src/render/spriteFx.ts @@ -33,6 +33,14 @@ } } + /** Прекратить вспышку и вернуть исходный tint (контракт fx: cancel = сброс). */ + cancel(): void { + const f = this.flash; + this.flash = null; + if (!f || this.sprite.destroyed) return; + this.sprite.tint = f.finish(); + } + /** Самозачистка из реестра: спрайт разрушен — запись вычистится сама. */ get destroyed(): boolean { return this.sprite.destroyed; diff --git a/packages/engine/src/scene/SceneManager.ts b/packages/engine/src/scene/SceneManager.ts index 4b0e95a..ba282df 100644 --- a/packages/engine/src/scene/SceneManager.ts +++ b/packages/engine/src/scene/SceneManager.ts @@ -1,4 +1,8 @@ import { Graphics } from 'pixi.js'; +import { FadeState } from '../core/fade'; + +/** Линейная интерполяция числа (для фейда оверлея). */ +const lerpNum = (a: number, b: number, k: number): number => a + (b - a) * k; /** * Сцена — экран игры (меню, локация, бой). Менеджер держит стек, @@ -29,7 +33,8 @@ duration: number; color: number; phase: 'out' | 'in'; - t: number; + /** Прозрачность оверлея 0..1 (FadeState вместо ручного t/duration). */ + alpha: FadeState; } export class SceneManager { @@ -104,32 +109,35 @@ if (this.overlay?.parent) { this.overlay.parent.addChild(this.overlay); } + const duration = tr.duration ?? 0.5; + const alpha = new FadeState(lerpNum, 0); + alpha.to(1, duration); this.pending = { kind, scene, - duration: tr.duration ?? 0.5, + duration, color: tr.color ?? 0x000000, phase: 'out', - t: 0 + alpha }; } private stepTransition(dt: number): void { const p = this.pending!; - p.t += dt; + p.alpha.update(dt); + this.drawOverlay(p.color, p.alpha.value); if (p.phase === 'out') { - this.drawOverlay(p.color, Math.min(1, p.t / p.duration)); - if (p.t < p.duration) return; + if (p.alpha.active) return; // экран скрыт — меняем сцену this.performSwap(p); p.phase = 'in'; - p.t -= p.duration; + p.alpha.to(0, p.duration); + return; } // фаза 'in': проявление - this.drawOverlay(p.color, Math.max(0, 1 - p.t / p.duration)); - if (p.t >= p.duration) { + if (!p.alpha.active) { this.pending = null; this.overlay?.clear(); } diff --git a/packages/engine/src/scene/__tests__/SceneManager.test.ts b/packages/engine/src/scene/__tests__/SceneManager.test.ts new file mode 100644 index 0000000..cd503d2 --- /dev/null +++ b/packages/engine/src/scene/__tests__/SceneManager.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SceneManager, type Scene } from '../SceneManager'; + +/** Сцена-болванка с журналом вызовов (для проверки порядка enter/exit/update). */ +function makeScene(name: string) { + const events: string[] = []; + const scene: Scene = { + enter: () => { + events.push(`${name}:enter`); + }, + exit: () => { + events.push(`${name}:exit`); + }, + update: () => { + events.push(`${name}:update`); + }, + render: () => { + events.push(`${name}:render`); + } + }; + return { scene, events }; +} + +describe('SceneManager', () => { + it('push без перехода: enter сразу, сцена в стеке и тикает', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + void sm.push(a.scene); + expect(sm.current).toBe(a.scene); + sm.update(0.016); + expect(a.events).toContain('a:enter'); + expect(a.events).toContain('a:update'); + }); + + it('pop без перехода: exit верхней сцены, стек пуст', async () => { + const sm = new SceneManager(); + const a = makeScene('a'); + void sm.push(a.scene); + await sm.pop(); + expect(a.events).toContain('a:exit'); + expect(sm.current).toBeUndefined(); + }); + + it('replace с переходом: out -> swap (exit/enter) -> in, затем transitioning=false', async () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + await sm.replace(a.scene); // replace асинхронен даже без перехода + a.events.length = 0; + + void sm.replace(b.scene, { duration: 0.2 }); + expect(sm.transitioning).toBe(true); + + sm.update(0.1); // половина out — сцены ещё не менялись + expect(sm.current).toBe(a.scene); + expect(sm.transitioning).toBe(true); + + sm.update(0.1); // out завершён — swap: exit(a), enter(b) + expect(a.events).toContain('a:exit'); + expect(b.events).toContain('b:enter'); + expect(sm.current).toBe(b.scene); + + sm.update(0.1); + sm.update(0.1); // in завершён + expect(sm.transitioning).toBe(false); + }); + + it('push с переходом: новая сцена входит в середине (за чёрным экраном)', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + void sm.push(a.scene); + + void sm.push(b.scene, { duration: 0.2 }); + sm.update(0.2); // out завершён + expect(sm.current).toBe(b.scene); + expect(b.events).toContain('b:enter'); + }); + + it('параллельный переход глотается: новая сцена не входит', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + const c = makeScene('c'); + void sm.push(a.scene); + + void sm.replace(b.scene, { duration: 0.2 }); + void sm.replace(c.scene, { duration: 0.2 }); // глотается + sm.update(0.2); + sm.update(0.2); + sm.update(0.2); + + expect(sm.current).toBe(b.scene); + expect(c.events).toEqual([]); + }); + + it('update во время перехода тикает текущую (старую) сцену', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + void sm.push(a.scene); + a.events.length = 0; + + void sm.replace(makeScene('b').scene, { duration: 0.2 }); + sm.update(0.05); + expect(a.events).toContain('a:update'); + }); + + it('render рисует весь стек (нижние сцены видны под верхними)', () => { + const sm = new SceneManager(); + const a = makeScene('a'); + const b = makeScene('b'); + void sm.push(a.scene); + void sm.push(b.scene); + + sm.render(); + expect(a.events).toContain('a:render'); + expect(b.events).toContain('b:render'); + }); + + it('enter может быть асинхронным — переход его не ждёт', async () => { + const sm = new SceneManager(); + let resolved = false; + const scene: Scene = { + enter: () => + new Promise((res) => { + setTimeout(() => { + resolved = true; + res(); + }, 5); + }), + exit: () => {}, + update: () => {}, + render: () => {} + }; + const spy = vi.spyOn(scene, 'enter'); + await sm.push(scene); + expect(spy).toHaveBeenCalled(); + // Не ждём промис — переход уже вернулся. + await new Promise((r) => setTimeout(r, 10)); + expect(resolved).toBe(true); + }); +}); \ No newline at end of file