diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 278c1d7..25e70eb 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -1,5 +1,6 @@ import { dayNightFactor, + unitsToPx, findPath, findPathToNeighbor, checkFinite, @@ -14,6 +15,9 @@ type SceneAgent, type SceneRegistry, type SnapshotLayer, + type PostFx, + type ShimmerState, + type Camera, type SoundKind, type SoundSpec, type Vec2 @@ -60,6 +64,7 @@ key?: string; volume?: number; spec?: Partial; + effect?: string; } /** Обработчик команды моста: deps сцены + разобранные аргументы. */ @@ -201,6 +206,52 @@ typeof a.volume === 'number' ? a.volume : 0.6 ); return true; + }, + 'scene:postfx': (d, a) => { + // Пост-эффекты для проверок: импульс блюра/аберрации, марево зоной, сброс. + // effect: blur | aberration | shimmer | clear; точка — тайл (x/y), + // level — радиус зоны в юнитах, value — сила (px выборки / px сдвига / amp). + const center = hasXY(a) + ? (() => { + const w = tileToWorld(a.x!, a.y!); + return d.camera.toScreen(w.x, w.y); + })() + : undefined; + const radius = + typeof a.level === 'number' ? unitsToPx(a.level) * d.camera.zoomLevel : undefined; + switch (a.effect) { + case 'blur': + d.postfx.pulseBlur( + { + strength: typeof a.value === 'number' ? a.value : 6, + center, + radius: radius ?? 0 + }, + { attack: 0.05, hold: 0.1, decay: 0.5 } + ); + return true; + case 'aberration': + d.postfx.pulseAberration( + typeof a.value === 'number' ? a.value : 3, + { attack: 0.02, hold: 0.06, decay: 0.4 } + ); + return true; + case 'shimmer': + d.setShimmer({ + amp: typeof a.value === 'number' ? a.value : 1.4, + wavelength: 26, + speed: 1.8, + center, + radius: radius ?? unitsToPx(2.5) * d.camera.zoomLevel + }); + return true; + case 'clear': + d.postfx.clear(); + d.setShimmer(undefined); // вернуть авто-зоны (источники области) + return true; + default: + return null; + } } }; @@ -234,6 +285,15 @@ damagePlayer(damage: number, from?: Vec2): void; /** Игровая обвязка освещения (ambient + источники для снапшота). */ lighting(): GameLighting; + /** Камера (проекция мир→экран для точечных пост-эффектов). */ + camera: Camera; + /** Пост-эффекты сцены (scene:postfx — блюр/аберрация/марево). */ + postfx: PostFx; + /** + * Ручная зона марева: перекрывает авто-источники области, пока не снять. + * undefined возвращает авто-режим (ставит scene:postfx clear). + */ + setShimmer(z: ShimmerState | null | undefined): void; /** Камера в ногах героя (snap — телепорты). */ followCamera(snap: boolean): void; } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 5e05f63..fa7119c 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -17,11 +17,16 @@ VirtualJoystick, CutsceneRunner, dayNightFactor, + PostFx, + unitsToPx, type Camera, + type GameState, type Invariant, type JsonValue, type Scene, type SnapshotLayer, + type ShimmerParams, + type ShimmerState, type Vec2, type DialogueWorld } from '@rpg/engine'; @@ -67,6 +72,21 @@ /** Лампа героя: тёплый, компактный, слегка мерцает (прорезает ночную тьму). */ const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.0, intensity: 1.0, flicker: 0.12 }; +/** + * Источники марева (пост-эффект «горячий воздух») по id интерактива: + * усыпленная Машина не дымит; очаг горит всегда. radiusUnits — юниты. + */ +const SHIMMER_SOURCES: Record = { + old_machine: { + amp: 1.4, + wavelength: 26, + speed: 1.8, + radiusUnits: 2.5, + visible: (s) => !s.hasFlag(FLAGS.machine_lulled) + }, + hearth_elder: { amp: 0.9, wavelength: 22, speed: 1.3, radiusUnits: 2, visible: () => true } +}; + /** Осыпание пепла с крон: период спавна порции (сек) на все видимые деревья. */ const CROWN_ASH_PERIOD = 0.9; @@ -119,6 +139,13 @@ private objects: SceneObjects; /** Освещение: движковый слой (lightRoot) + игровая обвязка. */ private lightView: Lighting; + /** Пост-эффекты мира: блюр резонанса, аберрация урона, марево источников. */ + private postfx: PostFx; + /** + * Ручная зона марева моста (scene:postfx shimmer): перекрывает авто-источники. + * undefined — авто-режим (SHIMMER_SOURCES), null — выключено насовсем. + */ + private manualShimmer: ShimmerState | null | undefined; private lighting: GameLighting; /** Пейзажная фауна (безгласные олени). */ private fauna: FaunaSystem; @@ -442,6 +469,9 @@ lamp: HERO_LAMP }); + // Пост-эффекты: фильтр на корне мира — блюр/аберрация/марево (см. postSim). + this.postfx = new PostFx(this.game.renderer.worldRoot, Game.VIRTUAL_W, Game.VIRTUAL_H); + // HUD: название локации, подсказка управления, счётчик мотов. this.hud = new LocationHud(area.name); this.game.renderer.uiRoot.addChild(this.hud.view); @@ -455,6 +485,7 @@ player: this.player, lighting: this.lighting, camera: this.camera, + postfx: this.postfx, fx: this.fx, spawn: area.spawn, inHazard: () => this.atmosphere.inHazard !== null, @@ -487,6 +518,11 @@ inHazard: () => this.atmosphere.inHazard, damagePlayer: (dmg, from) => this.flow.onPlayerDamaged(dmg, from ?? this.player.position), lighting: () => this.lighting, + camera: this.camera, + postfx: this.postfx, + setShimmer: (z) => { + this.manualShimmer = z; + }, followCamera: (snap) => this.updateCameraFollow(snap) }); @@ -560,6 +596,7 @@ this.atmosphere.destroy(); this.lighting.destroy(); this.lightView.destroy({ children: true }); + this.postfx.dispose(); this.hud.destroy(); this.hint.destroy(); this.joystick.destroy({ children: true }); @@ -576,6 +613,8 @@ this.game.clock.advance(dt); // время суток идёт и в диалогах/катсценах: свет меняется this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам + this.postfx.update(dt); + this.updateShimmer(); this.atmosphere.updateVignette(); this.fauna.update(dt); // Ходоки актёров: тикаются всегда (исполнители until в кат-сценах). @@ -729,6 +768,33 @@ return walker; } + /** + * Зоны марева (пост-эффект «горячий воздух»): первый источник на экране. + * Каждый тик — камера едет, точка должна следовать за объектом. + */ + private updateShimmer(): void { + if (this.manualShimmer !== undefined) { + this.postfx.setShimmer(this.manualShimmer); + return; + } + let zone: ShimmerState | null = null; + for (const def of this.area.interactables ?? []) { + const src = SHIMMER_SOURCES[def.id]; + if (!src || !src.visible(this.game.state)) continue; + const w = tileToWorld(def.tile.x + 0.5, def.tile.y + 0.5); + const s = this.camera.toScreen(w.x, w.y); + const radius = unitsToPx(src.radiusUnits) * this.camera.zoomLevel; + // Зона видна, пока её центр в пределах экрана с запасом радиуса. + if ( + s.x < -radius || s.x > Game.VIRTUAL_W + radius || + s.y < -radius || s.y > Game.VIRTUAL_H + radius + ) continue; + zone = { amp: src.amp, wavelength: src.wavelength, speed: src.speed, center: s, radius }; + break; + } + this.postfx.setShimmer(zone); + } + private updateCameraFollow(snap = false): void { // Непрерывное следование за ногами героя; snap — телепорты/спавн. const p = this.player.position; diff --git a/apps/game/src/systems/combat/CombatFlow.ts b/apps/game/src/systems/combat/CombatFlow.ts index 2557d19..6ba9780 100644 --- a/apps/game/src/systems/combat/CombatFlow.ts +++ b/apps/game/src/systems/combat/CombatFlow.ts @@ -7,6 +7,7 @@ type Camera, type Entity, type FxLayer, + type PostFx, type Vec2 } from '@rpg/engine'; import type { Game } from '../../Game'; @@ -41,6 +42,8 @@ player: PlayerController; lighting: GameLighting; camera: Camera; + /** Пост-эффекты: блюр-волна резонанса, аберрация урона. */ + postfx: PostFx; /** Слой мировых эффектов (флэш урона на герое). */ fx: FxLayer; /** Стартовый тайл локации (респаун после смерти). */ @@ -142,6 +145,8 @@ d.game.camera.addShake(2.5, 0.3); // Красная вспышка на весь экран — экранная реакция на боль. d.lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18, spec: { attack: 0.02, decay: 0.35 } }); + // Хроматическая аберрация: экран «ведёт» от боли (импульс с огибающей). + d.postfx.pulseAberration(3, { attack: 0.02, hold: 0.06, decay: 0.35 }); // Симметричный врагам флэш урона на герое. d.fx.flash(d.player.sprite, 0xd05a5a, 0.18); // Отброс от источника урона (направление — по метрике проекции) @@ -182,6 +187,11 @@ spec: { attack: 0.05, decay: 0.45 } }); d.lighting.pulseAmbient({ color: 0xd99a32, peak: 0.12, spec: { attack: 0.05, decay: 0.5 } }); + // Блюр-волна вокруг героя: воздух плавится от резонанса (точка + радиус). + d.postfx.pulseBlur( + { strength: 5, center: { x: ls.x, y: ls.y }, radius: unitsToPx(3) * d.camera.zoomLevel }, + { attack: 0.06, hold: 0.08, decay: 0.5 } + ); this.bump('resonances', slept > 0 ? 1 : 0); // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел. if (d.inHazard()) { diff --git a/apps/game/tools/checks/postfx.mjs b/apps/game/tools/checks/postfx.mjs new file mode 100644 index 0000000..eb2b77f --- /dev/null +++ b/apps/game/tools/checks/postfx.mjs @@ -0,0 +1,76 @@ +/** + * Сценарий postfx — пост-эффекты мира (PostFx: блюр/аберрация/марево). + * На слух/глаз в headless не проверить силу эффекта — проверяем, что + * команды моста применяются (pulse/set/clear), и снимаем скриншоты в пике + * эффекта для визуальной сверки агентом. + * 1) новая игра: baseline без эффектов; + * 2) blur: точечный импульс вокруг героя — скриншот в пике; + * 3) aberration: импульс сдвига каналов — скриншот в пике; + * 4) shimmer: зона марева у героя — скриншот (держится до clear); + * 5) clear: после сброса и шага — команда отвечает, сцена жива. + * Запуск: node tools/agent.mjs run tools/checks/postfx.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'postfx', + async (t) => { + const { c } = t; + await c.run('новая игра: baseline без эффектов', async () => { + await t.boot(); + await t.ctx.agent.screenshot('/tmp/rpg_postfx_baseline.png'); + return null; + }); + await c.run('blur: точечный импульс вокруг героя', async () => { + const s = await t.ctx.agent.snapshot(); + const ok = await t.ctx.agent.command('scene:postfx', { + effect: 'blur', + x: s.hero.tile.x, + y: s.hero.tile.y, + level: 3, + value: 10 + }); + c.expect(ok === true, 'scene:postfx blur не применился', ok); + await t.ctx.agent.step(12); // пик огибающей (attack 0.05 + hold 0.1) + await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_blur.png' }); + return null; + }); + await c.run('aberration: импульс сдвига каналов', async () => { + const ok = await t.ctx.agent.command('scene:postfx', { + effect: 'aberration', + value: 5 + }); + c.expect(ok === true, 'scene:postfx aberration не применился', ok); + await t.ctx.agent.step(10); + await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_aberration.png' }); + return null; + }); + await c.run('shimmer: зона марева у героя', async () => { + const s = await t.ctx.agent.snapshot(); + const ok = await t.ctx.agent.command('scene:postfx', { + effect: 'shimmer', + x: s.hero.tile.x, + y: s.hero.tile.y, + level: 3, + value: 2.5 + }); + c.expect(ok === true, 'scene:postfx shimmer не применился', ok); + await t.ctx.agent.step(6); + await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_shimmer.png' }); + return null; + }); + await c.run('clear: сброс эффектов, сцена жива', async () => { + const ok = await t.ctx.agent.command('scene:postfx', { effect: 'clear' }); + c.expect(ok === true, 'scene:postfx clear не сработал', ok); + await t.ctx.agent.step(30); + await t.ctx.page.screenshot({ path: '/tmp/rpg_postfx_clear.png' }); + // Сцена отвечает на мост после сброса. + const s = await t.ctx.agent.snapshot(); + c.expect(s.scene === 'location', 'сцена не жива после clear', s.scene); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/docs/demo.md b/docs/demo.md index 3bbdc6e..073f7ca 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -31,6 +31,7 @@ | Lighting II (импульсы, день/ночь, виньетка, тёмные пятна) | непрерывное время суток (1 с = 1 игровая минута): закат/ночь по часам, ночной ambient + светящиеся колокольчики, окна гаснут днём, вспышки ударов/резонанса/урона, виньетка при низком hp и в накате, тёмные ауры зон наката | готово | | Lighting III («тьма с прорезями») | RT-карта силы тьмы: erase-прорези источников (пиксельный ступенчатый falloff), tone-слой отдельно от прорезей, dirty-bake по подписям источников, day fast-path; солнечный день + золотой час (`duskColor`), ночь — тьма с прорезями | готово | | Время суток (GameClock + dayNightFactor) | старт 8:00, часы в HUD нет — предмет «Медные часы» (Ирвин даёт с квестом): Enter в сумке показывает время; в сумке время заморожено | готово | +| PostFx (пост-эффекты: блюр/аберрация/марево) | блюр-волна вокруг героя при резонансе (точка + радиус), хроматическая аберрация при уроне, марево у Старой Машины (пока не усыплена) и очага дома старейшины; проверка — `scene:postfx` + `tools/checks/postfx.mjs` | готово | | StateMachine | `EnemyBrain` — ИИ сгустков (dormant/rise/chase/…/sleep/dead) | готово | | ECS (World) | боевые сущности: враги и снаряды на `Entity` | готово | | Panel/Button/MenuList | меню, настройки, сейвы, сумка (строки с иконками предметов 8×8 `ui/icon_*`, синхронны окну скролла) | готово | diff --git a/docs/engine/practices.md b/docs/engine/practices.md index a3780c8..4d1925c 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -435,6 +435,17 @@ метод-фабрика на `FxLayer`. Хелперы ≤70 строк. 4. Эффекты по группе: слой на группу (мир / UI) — `clear()` гасит только свой слой; зацикленные телеграфы (`repeat: Infinity`) гасит сцена через `cancel()`. +5. **Эффект на весь кадр (блюр/аберрация/марево) — через `PostFx`, не фильтром + на сцене руками**: `new PostFx(worldRoot, W, H)` + `update(dt)` в тике сцены. + Состояние `set*` + импульсы `pulse*`; точка из мира — `camera.toScreen(world)` + (виртуальные экранные px), радиус — `unitsToPx(units) × zoom`. Зоны, + следующие за объектами (марево источника), обновляются каждый тик — камера + едет; если зона ставится внешней командой (мост), сцена держит «ручной» + слот, иначе авто-обновление затирает её каждый тик. Грабли Pixi v8: + `resolution: 'inherit'` у фильтра (иначе вся цепочка падает до 1 и мылит + пиксель-арт), юниформы пишутся напрямую (`uniforms.uTime = t`, не + `.value`), const-массивы в шейдере не работают под GLSL ES 1.00 — + разворачивать в явные сэмплы. ## Грабли среды (кратко, подробности в CLAUDE.md) diff --git a/docs/engine/render.md b/docs/engine/render.md index 861b4eb..7163fc1 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -205,6 +205,51 @@ lighting.upsertDarkSpot({ id: 'hazard@3,4', x: 200, y: 100, radius: 51, alpha: 0.22 }); ``` +## Пост-эффекты (`render/postfx/`) + +Один кастомный Pixi-фильтр на `worldRoot` обрабатывает уже отрисованный мир: +**переменный блюр** (глобальный, точечный с радиусом и на отдельном объекте), +**хроматическая аберрация** и **марево** («горячий воздух»). API по образцу +`Lighting`: состояние `set*` + затухающие импульсы `pulse*` на той же +огибающей `pulseEnvelope` (см. lightSim). + +```ts +import { PostFx } from '@rpg/engine'; + +const postfx = new PostFx(engine.renderer.worldRoot, Game.VIRTUAL_W, Game.VIRTUAL_H); +// ... каждый тик сцены: +postfx.update(dt); +// ... при закрытии сцены: +postfx.dispose(); +``` + +- **Блюр** — `setBlur({ strength, center?, radius? } | null)`: сила в px выборки; + `center` — виртуальные экранные px, `radius` — радиус зоны в тех же px. + `radius <= 0` (или без center) — вся сцена; внутри зоны сила спадает гладкой + ступенью до нуля на радиусе (см. `blurStrengthAt` в `postSim`). +- **`pulseBlur(args, spec)`** — импульс с огибающей `PulseSpec` поверх + установленного состояния (резонанс, удар). +- **Аберрация** — `setAberration(px) / pulseAberration(px, spec)`: радиальный + сдвиг R/B-каналов от центра экрана, `px` — величина сдвига на краях. +- **Марево** — `setShimmer({ amp, wavelength, speed, center?, radius? } | null)`: + вертикальный синусоидальный сдвиг по X и времени (`shimmerOffsetY`), зона — + как у блюра. Один слот. +- **Объектный блюр** — `blurObject(view, strength = 3) / clearBlurObject(view)`: + отдельный `BlurFilter` на конкретной вьюхе (не участвует в общем фильтре). + +Координаты точек — виртуальные экранные пиксели (480×270): текстура фильтра +выровнена по вьюпорту (`clipToViewport` в Pixi v8), камера уже учтена; точку +из мира получают проекцией `camera.toScreen(world)`, радиус — `unitsToPx(units)` +(с масштабом зума, если эффект должен следовать за зумом). `resolution: 'inherit'` +у фильтра — рендер в разрешении бэкинг-стора, пиксель-арт не мылится; фильтр +включается только при ненулевых эффектах (`filter.enabled`). + +Чистая математика — `postSim.ts` (`blurStrengthAt`, `blurKernel`, `aberrationShiftAt`, +`shimmerOffsetY`), тестируется headless; `PostFx.ts` только переносит сэмплы +в шейдер. Ядро блюра развёрнуто в явные сэмплы — const-массивы недопустимы +в GLSL ES 1.00 (WebGL1-фолбэк); юниформы v8 пишутся напрямую +(`uniforms.uTime = t`), не через обёртки `{value}`. + ## IsoDepthLayer Сортировка глубины для сущностей на изометрической карте: глубина = `tx + ty` diff --git a/docs/llms.txt b/docs/llms.txt index a3fdcbb..3409492 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -14,7 +14,7 @@ - [README движка](engine/README.md): архитектура и принципы, карта модулей. - [Начало работы](engine/getting-started.md): Engine, GameLoop, сцены, первый запуск. - [Ядро](engine/core.md): GameLoop, GameState, Settings, EventBus, Tween. -- [Рендер](engine/render.md): Renderer, Camera, IsoDepthLayer, Particles, pixel-perfect масштаб. +- [Рендер](engine/render.md): Renderer, Camera, IsoDepthLayer, Particles, PostFx (блюр/аберрация/марево), pixel-perfect масштаб. - [Ввод](engine/input.md): действия, геймпад, VirtualJoystick, инъекция ввода. - [Карты](engine/maps.md): изометрия, A*, коллизии (круг поверх сетки), формат rpg-map, Tiled-импорт. - [Анимация](engine/anim.md): клипы, SpriteAnimator, оживители, реестр тика — всё от фиксированного шага. diff --git a/docs/plan.md b/docs/plan.md index f238c2d..ec944f4 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -38,6 +38,7 @@ | 17 | Второй раунд фидбека: баг мотов/сейвы/маркер NPC, свет +25% и фонарь, замах+след боя, деревья (варианты+качание+пепел), фасады домов, пепельная трава и читаемые зоны наката, амбиент день/ночь, миникарта (движок + предмет + оверлей) | ✅ сделано (2026-09-08): B1 (моты/автосейв на переходах и закрытии вкладки/маркер), B2 (лампа 2.0/1.0, спрайт фонаря, центр внизу), B3 (сектор замаха + дуга-след, `conePath` в движке), B4 7f14fb0 (3 вида крон × 2 кадра качания, осыпание пепла), B5 7ae42cf (3 фасада домов, явная расстановка в Звенце), B6 1a906e0 (трава сквозь пепел, плотнее летающий пепел, тёплый DARK_SPOT_TINT), B7 19ce175 (ночные лупы областей + ashflow, кроссфейд по `dayNightFactor`, positions-слои зон), B8 ef8d9b7+8516872 (`renderMinimap` в движке, предмет `map_scroll` у Ирвина, оверлей `MapScene`) | | 18 | Третий раунд фидбека: свет в центре персонажа, пепел с заметной травой, изометрические объёмные дома (5 видов), валун и куст, карты внешних локаций ×2, карта мира «Низина Звенца» (композит областей), амбиент лугов заново (тихий «дыхательный» ветер + события) | ✅ сделано (2026-09-08): C1 25d2875 (свет лампы в центре тела героя), C2 6c3e23b (стебли травы в пепле заметнее, ярче, пучками), C3 9883a24 (изо-дома: две стены + двускатная крыша, 5 видов), C4 a8622f5 (валун BOULDER и куст BUSH — новые тайлы), C5 1dada11 (все внешние карты ×2, пересчёт координат контента/тулз), C6 b86b458 (карта мира — композит миниатюр областей с дорогами и подписями), C7 0dacab3 (новая фактура ветра: мягкий низкий шум с порывами, шуршание травы, скрипы и далёкий звон, тише) | | 19 | Герой за tall-объектами: деревья/дома-тайлы/башня/валуны/кусты рисуются в `objectsLayer` карты — целиком под слоем `actors`, герой всегда поверх (пропы с footprint уже сортируются правильно через `actors.addRect`) | ✅ сделано (2026-09-09): движок — `IsometricTileMap.setTallDepthLayer(layer\|null)` переносит tall-вьюхи клеток в общий `IsoDepthLayer` с глубиной `tx+ty` (кулинг/`setTile` работают по ссылке, `null` возвращает в карту); игра — `LocationScene` вызывает `setTallDepthLayer(this.actors)` + `actors.addRect` для пропов; тесты движка (2 новых), сценарий `hero_depth.mjs` (численные zIndex-assert + скриншоты) | +| 20 | Пост-эффекты движка: переменный блюр (в точке с радиусом / на объекте / на всю сцену), хроматическая аберрация, марево («горячий воздух») | ✅ сделано (2026-09-09): движок — `render/postfx/` (`postSim.ts` чистая математика + 7 тестов, `PostFx.ts` — один кастомный Pixi-фильтр на `worldRoot`, API `set*/pulse*` по образцу Lighting, `blurObject` на вьюхе, `resolution: 'inherit'`); игра — блюр-волна резонанса, аберрация урона, авто-марево у Старой Машины/очага, мост `scene:postfx` (blur/aberration/shimmer/clear), сценарий `checks/postfx.mjs` (команды + скриншоты в пике) | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 53bdcce..de3f076 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -189,6 +189,14 @@ type DayNightSpec } from './render/lightSim'; export { + blurKernel, + blurStrengthAt, + aberrationShiftAt, + shimmerOffsetY, + type ShimmerParams +} from './render/postfx/postSim'; +export { PostFx, type BlurState, type ShimmerState } from './render/postfx/PostFx'; +export { stepParticle, sampleSpawn, sampleBurst, diff --git a/packages/engine/src/render/postfx/PostFx.ts b/packages/engine/src/render/postfx/PostFx.ts new file mode 100644 index 0000000..53241f6 --- /dev/null +++ b/packages/engine/src/render/postfx/PostFx.ts @@ -0,0 +1,255 @@ +/** + * Пост-эффекты: один кастомный фильтр на корне мира — переменный блюр + * (глобальный и точечный с радиусом), хроматическая аберрация, марево. + * API по образцу Lighting: состояние set* + затухающие импульсы pulse* + * (огибающая — pulseEnvelope из postSim). Координаты точек — виртуальные + * экранные пиксели (текстура фильтра выровнена по вьюпорту, камера уже + * учтена); радиусы — в тех же пикселях. blurObject — отдельный BlurFilter + * на конкретной вьюхе (не участвует в общем фильтре). + */ +import { BlurFilter, Container, Filter, GlProgram, UniformGroup } from 'pixi.js'; +import { blurKernel, pulseEnvelope, pulseTotal, type PulseSpec, type ShimmerParams } from './postSim'; + +const VERT = `in vec2 aPosition; +out vec2 vTextureCoord; +uniform vec4 uInputSize; +uniform vec4 uOutputFrame; +uniform vec4 uOutputTexture; +vec4 filterVertexPosition(vec2 aPosition) +{ + vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy; + position.x = position.x * (2.0 / uOutputTexture.x) - 1.0; + position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z; + return vec4(position, 0.0, 1.0); +} +vec2 filterTextureCoord(vec2 aPosition) +{ + return aPosition * (uOutputFrame.zw * uInputSize.zw); +} +void main(void) +{ + gl_Position = filterVertexPosition(aPosition); + vTextureCoord = filterTextureCoord(aPosition); +}`; + +// Ядро блюра (постоянное): спираль золотого сечения из postSim, развёрнутая +// в явные сэмплы — const-массивы недопустимы в GLSL ES 1.00 (WebGL1-фолбэк). +const KERNEL = blurKernel(8) + .map(([x, y]) => `acc += texture(uTexture, base + vec2(${x.toFixed(4)}, ${y.toFixed(4)}) * s / uViewport);`) + .join('\n '); + +const FRAG = `in vec2 vTextureCoord; +out vec4 finalColor; +uniform sampler2D uTexture; +uniform vec2 uViewport; +uniform float uBlurStrength; +uniform vec2 uBlurCenter; +uniform float uBlurRadius; +uniform float uAberration; +uniform float uShimmerAmp; +uniform vec2 uShimmerCenter; +uniform float uShimmerRadius; +uniform float uShimmerWl; +uniform float uShimmerSpeed; +uniform float uTime; +// Спад силы от точки (гладкая ступень, как в postSim); радиус <= 0 — глобально. +float fall(vec2 px, vec2 center, float radius) +{ + if (radius <= 0.0) return 1.0; + float t = clamp(1.0 - length(px - center) / radius, 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} +void main(void) +{ + vec2 px = vTextureCoord * uViewport; + vec2 base = vTextureCoord; + if (uShimmerAmp > 0.001) { + float dy = uShimmerAmp * sin((px.x / uShimmerWl + uTime * uShimmerSpeed) * 6.2831853); + base.y += (dy * fall(px, uShimmerCenter, uShimmerRadius)) / uViewport.y; + } + if (uAberration > 0.001) { + // Радиальный сдвиг каналов от центра экрана: R наружу, B внутрь. + vec2 c = uViewport * 0.5; + vec2 d = px - c; + vec2 dir = length(d) > 0.0001 ? normalize(d) : vec2(0.0); + float r = texture(uTexture, base + dir * (uAberration / uViewport.x)).r; + vec4 g = texture(uTexture, base); + float b = texture(uTexture, base - dir * (uAberration / uViewport.x)).b; + finalColor = vec4(r, g.g, b, g.a); + return; + } + if (uBlurStrength > 0.001) { + float s = fall(px, uBlurCenter, uBlurRadius) * uBlurStrength; + vec4 acc = vec4(0.0); + ${KERNEL} + finalColor = acc / 8.0; + return; + } + finalColor = texture(uTexture, base); +}`; + +/** Состояние блюра: сила (px выборки), точка и радиус (px; радиус <= 0 — вся сцена). */ +export interface BlurState { + strength: number; + center?: { x: number; y: number }; + radius?: number; +} + +/** Марево: параметры из postSim + опорная зона (вне зоны амплитуда 0). */ +export type ShimmerState = ShimmerParams & { center?: { x: number; y: number }; radius?: number }; + +interface PulseEntry { + elapsed: number; + spec: PulseSpec; + blur?: BlurState; + aberration?: number; +} + +export class PostFx { + /** Фильтр — вешается на корень мира (Renderer.worldRoot.filters). */ + readonly filter: Filter; + private readonly worldRoot: Container; + private readonly viewportX: number; + private readonly viewportY: number; + private readonly uniforms: UniformGroup; + + private time = 0; + private blur: BlurState | null = null; + private aberration = 0; + private shimmer: ShimmerState | null = null; + private readonly pulses = new Map(); + private pulseSeq = 0; + private readonly objectBlurs = new Map(); + private disposed = false; + + constructor(worldRoot: Container, viewportX: number, viewportY: number) { + this.worldRoot = worldRoot; + this.viewportX = viewportX; + this.viewportY = viewportY; + this.uniforms = new UniformGroup({ + uViewport: { value: new Float32Array([viewportX, viewportY]), type: 'vec2' }, + uBlurStrength: { value: 0, type: 'f32' }, + uBlurCenter: { value: new Float32Array([viewportX / 2, viewportY / 2]), type: 'vec2' }, + uBlurRadius: { value: 0, type: 'f32' }, + uAberration: { value: 0, type: 'f32' }, + uShimmerAmp: { value: 0, type: 'f32' }, + uShimmerCenter: { value: new Float32Array([viewportX / 2, viewportY / 2]), type: 'vec2' }, + uShimmerRadius: { value: 0, type: 'f32' }, + uShimmerWl: { value: 40, type: 'f32' }, + uShimmerSpeed: { value: 1, type: 'f32' }, + uTime: { value: 0, type: 'f32' } + }); + this.filter = new Filter({ + glProgram: GlProgram.from({ vertex: VERT, fragment: FRAG }), + resources: { postFxUniforms: this.uniforms }, + // inherit: текстура фильтра в разрешении рендера — пиксель-арт не мылится. + resolution: 'inherit', + padding: 0 + }); + worldRoot.filters = [this.filter]; + } + + /** Блюр сцены/точки: null — выключить. Импульсы поверх (см. pulseBlur). */ + setBlur(args: BlurState | null): void { + this.blur = args ? { ...args } : null; + } + + /** Импульс блюра с огибающей (attack/hold/decay, сек) — удар, резонанс. */ + pulseBlur(args: BlurState, spec: PulseSpec): void { + const id = `blur#${this.pulseSeq++}`; + this.pulses.set(id, { elapsed: 0, spec, blur: { ...args } }); + } + + /** Хроматическая аберрация, px сдвига каналов (0 — выкл). */ + setAberration(px: number): void { + this.aberration = px; + } + + /** Импульс аберрации — урон, вспышки. */ + pulseAberration(px: number, spec: PulseSpec): void { + const id = `aberr#${this.pulseSeq++}`; + this.pulses.set(id, { elapsed: 0, spec, aberration: px }); + } + + /** Марево (горячий воздух); null — выключить. */ + setShimmer(args: ShimmerState | null): void { + this.shimmer = args ? { ...args } : null; + } + + /** Блюр конкретной вьюхи (отдельный BlurFilter, не общий фильтр). */ + blurObject(view: Container, strength = 3): void { + const existing = this.objectBlurs.get(view); + if (existing) { + existing.strength = strength; + return; + } + const blur = new BlurFilter({ strength, quality: 2, kernelSize: 5 }); + this.objectBlurs.set(view, blur); + view.filters = [...(view.filters ?? []), blur]; + } + + /** Снять объектный блюр. */ + clearBlurObject(view: Container): void { + const blur = this.objectBlurs.get(view); + if (!blur) return; + this.objectBlurs.delete(view); + view.filters = (view.filters ?? []).filter((f) => f !== blur); + } + + /** Сброс состояний и импульсов (объектный блюр не трогает). */ + clear(): void { + this.blur = null; + this.aberration = 0; + this.shimmer = null; + this.pulses.clear(); + } + + /** Тик: время марева, затухание импульсов, перенос состояния в шейдер. */ + update(dt: number): void { + if (this.disposed) return; + this.time += dt; + for (const [id, p] of [...this.pulses]) { + p.elapsed += dt; + if (p.elapsed >= pulseTotal(p.spec)) this.pulses.delete(id); + } + // Эффективные значения: импульс перекрывает установленное состояние. + let blur = this.blur; + let aberration = this.aberration; + for (const p of this.pulses.values()) { + const env = pulseEnvelope(p.elapsed, p.spec); + if (p.blur && env > 0) blur = { ...p.blur, strength: p.blur.strength * env }; + if (p.aberration !== undefined && env > 0) aberration = Math.max(aberration, p.aberration * env); + } + // uniforms-карта v8 даёт сами значения (не обёртки {value}). + const u = this.uniforms.uniforms; + u.uTime = this.time; + u.uBlurStrength = blur ? blur.strength : 0; + (u.uBlurCenter as Float32Array).set([ + blur?.center?.x ?? this.viewportX / 2, + blur?.center?.y ?? this.viewportY / 2 + ]); + u.uBlurRadius = blur?.radius ?? 0; + u.uAberration = aberration; + u.uShimmerAmp = this.shimmer ? this.shimmer.amp : 0; + (u.uShimmerCenter as Float32Array).set([ + this.shimmer?.center?.x ?? this.viewportX / 2, + this.shimmer?.center?.y ?? this.viewportY / 2 + ]); + u.uShimmerRadius = this.shimmer?.radius ?? 0; + u.uShimmerWl = this.shimmer?.wavelength ?? 40; + u.uShimmerSpeed = this.shimmer?.speed ?? 1; + this.filter.enabled = + (blur !== null && blur.strength > 0.001) || + aberration > 0.001 || + (this.shimmer !== null && this.shimmer.amp > 0.001) || + this.pulses.size > 0; + } + + /** Снять фильтр с корня мира и объектные блюры. */ + dispose(): void { + this.disposed = true; + this.clear(); + for (const view of [...this.objectBlurs.keys()]) this.clearBlurObject(view); + this.worldRoot.filters = (this.worldRoot.filters ?? []).filter((f) => f !== this.filter); + } +} \ No newline at end of file diff --git a/packages/engine/src/render/postfx/__tests__/postSim.test.ts b/packages/engine/src/render/postfx/__tests__/postSim.test.ts new file mode 100644 index 0000000..ffc289f --- /dev/null +++ b/packages/engine/src/render/postfx/__tests__/postSim.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { + aberrationShiftAt, + blurKernel, + blurStrengthAt, + pulseEnvelope, + pulseTotal, + shimmerOffsetY +} from '../postSim'; + +describe('blurStrengthAt', () => { + it('глобальный режим: сила постоянна', () => { + expect(blurStrengthAt(0, 0, 4)).toBe(4); + expect(blurStrengthAt(1000, 0, 4)).toBe(4); + }); + + it('пик в центре, гладкий спад до нуля на радиусе', () => { + expect(blurStrengthAt(0, 100, 4)).toBe(4); + expect(blurStrengthAt(100, 100, 4)).toBe(0); + expect(blurStrengthAt(150, 100, 4)).toBe(0); // за радиусом — 0 + const mid = blurStrengthAt(50, 100, 4); + expect(mid).toBeGreaterThan(0); + expect(mid).toBeLessThan(4); + }); + + it('спад симметричен и монотонен', () => { + let prev = Infinity; + for (let d = 0; d <= 100; d += 5) { + const v = blurStrengthAt(d, 100, 4); + expect(v).toBeLessThanOrEqual(prev); + prev = v; + } + }); +}); + +describe('blurKernel', () => { + it('смещения в пределах единичного круга и различны', () => { + const k = blurKernel(8); + expect(k.length).toBe(8); + const seen = new Set(k.map(([x, y]) => `${x.toFixed(4)},${y.toFixed(4)}`)); + expect(seen.size).toBe(8); + for (const [x, y] of k) { + expect(Math.hypot(x, y)).toBeLessThanOrEqual(1); + } + }); +}); + +describe('aberrationShiftAt', () => { + it('тот же профиль спада, что у блюра', () => { + expect(aberrationShiftAt(0, 100, 2)).toBe(2); + expect(aberrationShiftAt(100, 100, 2)).toBe(0); + expect(aberrationShiftAt(0, 0, 2)).toBe(2); // глобальный режим + }); +}); + +describe('shimmerOffsetY', () => { + it('периодичен по X и по времени', () => { + const p = { amp: 1.5, wavelength: 40, speed: 2 }; + expect(shimmerOffsetY(0, 0, p)).toBeCloseTo(0); + expect(shimmerOffsetY(40, 0, p)).toBeCloseTo(0); // период по X = wavelength + expect(shimmerOffsetY(0, 0.5, p)).toBeCloseTo(0); // период по t = 1/speed + expect(Math.abs(shimmerOffsetY(10, 0, p))).toBeCloseTo(p.amp); // четверть волны + }); +}); + +describe('переэкспорт pulseEnvelope', () => { + it('атака/плато/спад сходятся', () => { + const spec = { attack: 0.1, hold: 0.2, decay: 0.3 }; + expect(pulseTotal(spec)).toBeCloseTo(0.6); + expect(pulseEnvelope(0.05, spec)).toBeCloseTo(0.5); + expect(pulseEnvelope(0.2, spec)).toBe(1); + expect(pulseEnvelope(0.45, spec)).toBeCloseTo(0.5); + expect(pulseEnvelope(0.7, spec)).toBe(0); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/render/postfx/postSim.ts b/packages/engine/src/render/postfx/postSim.ts new file mode 100644 index 0000000..41be933 --- /dev/null +++ b/packages/engine/src/render/postfx/postSim.ts @@ -0,0 +1,58 @@ +/** + * Чистая математика пост-эффектов: переменный блюр (радиальный спад силы), + * сэмпл-ядро блюра, хроматическая аберрация, марево. Без Pixi — тестируется + * в Vitest headless; PostFx.ts только переносит сэмплы в шейдер. + */ +export { pulseEnvelope, pulseTotal, type PulseSpec } from '../lightSim'; + +/** Гладкая ступень 0..1 (с нулевой производной на краях — без «шва» спада). */ +function smoothstep(t: number): number { + const x = Math.max(0, Math.min(1, t)); + return x * x * (3 - 2 * x); +} + +/** + * Сила блюра на расстоянии distPx от центра, px выборки: пик в центре, + * гладкий спад до нуля на радиусе. radiusPx <= 0 — глобальный режим + * (сила постоянна по всему кадру). + */ +export function blurStrengthAt(distPx: number, radiusPx: number, strengthPx: number): number { + if (radiusPx <= 0) return strengthPx; + return strengthPx * smoothstep(1 - distPx / radiusPx); +} + +/** + * Ядро блюра: taps смещений равномерного диска (спираль золотого сечения) — + * покрывает круг без кластерных артефактов сетки; норма 1 px выборки. + */ +export function blurKernel(taps = 8): ReadonlyArray { + const golden = Math.PI * (3 - Math.sqrt(5)) * 2; + const out: Array = []; + for (let i = 0; i < taps; i++) { + const r = Math.sqrt((i + 0.5) / taps); + const a = i * golden; + out.push([Math.cos(a) * r, Math.sin(a) * r]); + } + return out; +} + +/** + * Сдвиг каналов хроматической аберрации на расстоянии distPx от центра, px: + * R/B разводятся по радиалии; спад как у блюра — импульсы «от точки». + */ +export function aberrationShiftAt(distPx: number, radiusPx: number, strengthPx: number): number { + if (radiusPx <= 0) return strengthPx; + return strengthPx * smoothstep(1 - distPx / radiusPx); +} + +/** Параметры марева: амплитуда px, длина волны px, скорость фаз/с. */ +export interface ShimmerParams { + amp: number; + wavelength: number; + speed: number; +} + +/** Смещение по Y (px) синусоидой по X и времени — «горячий воздух». */ +export function shimmerOffsetY(xPx: number, timeSec: number, p: ShimmerParams): number { + return p.amp * Math.sin((xPx / p.wavelength + timeSec * p.speed) * Math.PI * 2); +} \ No newline at end of file