diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 518178d..e912f1c 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -12,6 +12,9 @@ IsometricTileMap, RATE, createRng, + FxLayer, + emberPreset, + readSeconds, renderSpec, renderMusicStereo, worldToScreen, @@ -106,6 +109,10 @@ // --- бой --- private combat: CombatWorld; private combatViews: CombatViews; + /** Слой мировых эффектов (кольца, частицы, растворения) — тикается engine.fx. */ + private fx: FxLayer; + /** Слой UI-эффектов (тосты): clear() не гасит мировые эффекты. */ + private uiFx: FxLayer; private playerCombat: PlayerCombat; private healthBar: HealthBar; /** Счётчик собранных мотов (HUD под сердечками); текст меняется по факту. */ @@ -279,12 +286,19 @@ for (const s of area.enemies) { this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y), s.patrol ? { patrol: s.patrol } : undefined); } + // Слои эффектов: мировые поверх мира, UI (тосты) поверх uiRoot. + this.fx = new FxLayer(); + this.world.addChild(this.fx); + this.game.engine.fx.add(this.fx); + this.uiFx = new FxLayer(); + this.game.renderer.uiRoot.addChild(this.uiFx); + this.game.engine.fx.add(this.uiFx); this.combatViews = new CombatViews( this.combat, this.actors, this.enemyTextures(), this.world, - this.game.engine.fx + this.fx ); // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира. @@ -318,19 +332,17 @@ this.fauna.setFrames( this.game.assets.animation('chars/fauna_sheet.json', 'fauna_deer_walk') as [Texture, Texture] ); - // Мотыли над потревоженным пеплом: серые, дрейф вверх от точки звона. + // Мотыли над потревоженным пеплом: серые, дрейф вверх от точки звона + // (через слой эффектов: тик и зачистка — сами; раньше эмиттер не тикался). this.game.engine.events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => { const s = worldToScreen(origin.x, origin.y); - const fx = ParticleEmitter.oneShot(6, { - color: 0x8a8a96, - rate: 0, + this.fx.burst(6, { x: s.x, y: s.y - 8 }, emberPreset(0x8a8a96, { + blend: 'normal', + fadeIn: 0, lifetime: [1.2, 2.2], velocity: { x: [-6, 6], y: [-14, -8] }, - size: 1, seed: ((origin.x * 13 + origin.y) | 0) || 1 - }); - fx.position.set(s.x, s.y - 8); - this.world.addChild(fx); + })); }); const savedHp = this.game.state.getNumber(VARS.hp) || PLAYER_COMBAT.maxHp; @@ -562,6 +574,8 @@ this.fauna.exit(); this.combatViews.exit(); this.ash?.clear(); + this.fx.destroy({ children: true }); + this.uiFx.destroy({ children: true }); this.world.destroy({ children: true }); this.ash?.destroy(); this.moths?.destroy(); @@ -812,6 +826,8 @@ this.game.camera.addShake(2.5, 0.3); // Красная вспышка на весь экран — экранная реакция на боль. this.lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18, spec: { attack: 0.02, decay: 0.35 } }); + // Симметричный врагам флэш урона на герое. + this.fx.flash(this.player.sprite, 0xd05a5a, 0.18); // Отброс от источника урона (направление — по метрике проекции) const dx = this.player.position.x - from.x; const dy = this.player.position.y - from.y; @@ -1122,19 +1138,19 @@ ]); } - /** Всплывающая подсказка: появляется и растворяется над UI. */ + /** Всплывающая подсказка: появляется, держится, растворяется вверх (FxLayer). */ private showToast(text: string): void { this.lastToast = { text, tick: this.game.engine.tickCount }; - const toast = new PixelText({ text, size: 11, color: 0xd8c79a }); - toast.anchor.set(0.5); - toast.position.set(240, 40); - toast.alpha = 0; - this.game.renderer.uiRoot.addChild(toast); - const tweens = this.game.engine.tweens; - tweens.to(toast, { alpha: 1 }, { duration: 0.25 }); - // Держим, пока читается: базовые 1.4 с + ~45 мс на символ, не дольше 6 с. - tweens.delay(Math.min(6, 1.4 + text.length * 0.045), () => { - tweens.to(toast, { alpha: 0, y: 32 }, { duration: 0.5, onDone: () => toast.destroy() }); + // Новый тост вытесняет старый (раньше наслаивались). + this.uiFx.clear(); + const hold = readSeconds(text); // базовые 1.4 с + ~45 мс на символ, не дольше 6 + this.uiFx.floatText(text, { x: 240, y: 40 }, { + duration: hold + 0.75, + hold, + fadeIn: 0.25, + fadeOut: 0.5, + rise: 8, + style: { size: 11, color: 0xd8c79a } }); } @@ -1148,13 +1164,8 @@ this.interactViews = this.interactViews.filter((v) => v !== iv); const w = tileToWorld(def.tile.x, def.tile.y); this.combatViews.hitBurst(worldToScreen(w.x, w.y)); - this.game.engine.tweens.to(iv.view, { alpha: 0 }, { - duration: 0.5, - onDone: () => { - this.actors.removeChild(iv.view); - iv.view.destroy({ children: true }); - } - }); + // Вьюха тает и удаляется сама (пресет; removeChild+destroy внутри). + this.fx.fade(iv.view, { duration: 0.5 }); } /** diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index d39ddcc..d9c4d89 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -42,7 +42,8 @@ /** Именованные клипы: walk_* на ходу, idle_* — стоп-кадр направления. */ readonly animator: SpriteAnimator; - private sprite: Sprite; + /** Спрайт тела (публичный: хит-флэши урона через FxLayer.flash). */ + readonly sprite: Sprite; private facing: Facing = 'down'; /** Позиция в мировых юнитах (источник истины, ноги героя). */ @@ -63,7 +64,7 @@ private onStep?: () => void ) { this.view = new Container(); - this.sprite = new Sprite(textures.down[0]); + this.sprite = new Sprite(textures.down[0]); // публичный: флэши урона (FxLayer.flash) this.sprite.anchor.set(0.5, 1); // ноги в точке позиции this.view.addChild(this.sprite); this.animator = new SpriteAnimator(this.sprite, { diff --git a/apps/game/src/systems/combat/CombatViews.ts b/apps/game/src/systems/combat/CombatViews.ts index d82b1f6..65bcc3c 100644 --- a/apps/game/src/systems/combat/CombatViews.ts +++ b/apps/game/src/systems/combat/CombatViews.ts @@ -2,15 +2,13 @@ Container, Graphics, IsoDepthLayer, - ParticleEmitter, Sprite, SpriteAnimator, - SpriteFlash, Texture, worldToTile, worldToScreen, type Entity, - type Updatables, + type FxLayer, type Vec2 } from '@rpg/engine'; import type { CombatWorld } from './CombatWorld'; @@ -24,27 +22,27 @@ interface EnemyView { root: Container; sprite: Sprite; - flash: SpriteFlash; animator: SpriteAnimator; + /** Растворение запущено (труп тает через FxFade, не вручную). */ + dissolving?: boolean; } export class CombatViews { private views = new Map(); private projectileViews = new Map(); - private rings: { g: Graphics; t: number }[] = []; constructor( private combat: CombatWorld, private actors: IsoDepthLayer, private enemyTextures: Map, private fxRoot: Container, - /** Реестр тика эффектов (engine.fx): oneShot-эмиттеры тикаются сами. */ - private fx: Updatables + /** Слой эффектов (FxLayer сцены): тик и зачистка — сами. */ + private fx: FxLayer ) {} /** Спавн-вспышка пепла (взрыв частиц). */ hitBurst(pos: Vec2): void { - const fx = ParticleEmitter.oneShot(10, { + this.fx.burst(10, { x: pos.x, y: pos.y - 6 }, { color: 0x52525c, rate: 0, lifetime: [0.2, 0.45], @@ -53,15 +51,12 @@ drag: 2.5, scaleOverLife: 'shrink', seed: (pos.x * 31 + pos.y) | 0 - }); - fx.position.set(pos.x, pos.y - 6); - this.fxRoot.addChild(fx); - this.fx.add(fx); // тик и самоуничтожение — через реестр (раньше стояли на месте) + }, this.fxRoot); } /** Смерть: заметный выброс пепла. */ deathBurst(pos: Vec2): void { - const fx = ParticleEmitter.oneShot(16, { + this.fx.burst(16, { x: pos.x, y: pos.y - 8 }, { colors: [0x6a6a74, 0x8a8a96], rate: 0, lifetime: [0.3, 0.6], @@ -71,18 +66,16 @@ spin: [-4, 4], scaleOverLife: 'shrink', seed: (pos.x * 17 + pos.y) | 0 - }); - fx.position.set(pos.x, pos.y - 8); - this.fxRoot.addChild(fx); - this.fx.add(fx); + }, this.fxRoot); } - /** Кольцо резонанса — расходящаяся окружность (тикает в sync). */ + /** Кольцо резонанса — расходящаяся окружность (пресет, тикается слоем). */ resonanceRing(pos: Vec2): void { - const g = new Graphics(); - g.position.set(pos.x, pos.y - 4); - this.fxRoot.addChild(g); - this.rings.push({ g, t: 0 }); + this.fx.ring( + { color: 0xd99a32, from: 8, to: 96, duration: 0.4, width: 1 }, + { x: pos.x, y: pos.y - 4 }, + { parent: this.fxRoot } + ); } /** Синхронизация вьюх с ECS-миром. Вызывать каждый тик после combat.update. */ @@ -106,7 +99,6 @@ this.views.set(e, { root, sprite, - flash: new SpriteFlash(sprite, 0xf2b45a, 0.15), animator: new SpriteAnimator(sprite, { walk: { frames, fps: 4 } }, 'walk') }); } @@ -121,8 +113,12 @@ this.actors.setDepth(view.root, t.x, t.y); if (en.brain.dead) { - // Растворение трупа - view.root.alpha = Math.max(0, en.corpseTimer / 0.5); + // Растворение трупа: один fade на переход alive→dead (keep — + // вьюху уничтожает сцена по !isAlive, без двойного destroy). + if (!view.dissolving) { + view.dissolving = true; + this.fx.fade(view.root, { duration: 0.5, keep: true }); + } view.sprite.tint = 0x555560; continue; } @@ -137,10 +133,6 @@ view.animator.play('walk'); } view.animator.update(dt); - - // Вспышка урона: короткая подсветка в момент hp снижения — через flash.start - // (запускает CombatWorld через damageEnemy -> событие, здесь упрощённо по alpha) - view.flash.update(dt); } // Удаление уничтоженных сущностей @@ -170,23 +162,12 @@ this.projectileViews.delete(e); } } - - // Кольца резонанса - for (const ring of this.rings) { - ring.t += dt; - const k = Math.min(1, ring.t / 0.4); - ring.g.clear(); - ring.g.circle(0, 0, 96 * k).stroke({ color: 0xd99a32, width: 1, alpha: 1 - k }); - if (k >= 1) { - ring.g.destroy(); - } - } - this.rings = this.rings.filter((r) => r.t < 0.4); } /** Вспышка конкретного врага (вызывает CombatWorld при попадании). */ flashEnemy(e: Entity): void { - this.views.get(e)?.flash.start(); + const view = this.views.get(e); + if (view) this.fx.flash(view.sprite, 0xf2b45a, 0.15); } exit(): void { @@ -197,7 +178,5 @@ this.views.clear(); for (const [, pv] of this.projectileViews) pv.destroy(); this.projectileViews.clear(); - for (const r of this.rings) r.g.destroy(); - this.rings = []; } } \ No newline at end of file diff --git a/docs/demo.md b/docs/demo.md index edf00dc..078d331 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -23,7 +23,8 @@ | SaveManager (слоты) | autosave (Esc) + 3 слота, `SaveSlotsScene` | готово | | Settings + onChange | громкости master/ambience/sfx, `SettingsScene` | готово | | AudioManager (шины) | амбиент локаций с кроссфейдом, sfx боя/шагов/UI | готово | -| Tween + easing | хит-флэш, кольца резонанса, всплывающие тосты, кат-сцены | готово | +| Tween + easing | кат-сцены, лерпы освещения; `backOut` для pop-эффектов | готово | +| FxLayer + пресеты (`render/fx/`) | кольца резонанса, всплывающие тосты, растворения трупов/интерактивов, хит-флэши (герой и враги), мотыли над пеплом (`emberPreset`), частицы ударов/смертей | готово | | Particles (атмосфера + burst) | пепел лугов, туман прудов, удары/смерти сгустков | готово | | Lighting (ambient multiply + аддитивные источники) | сумерки Звенца и окна домов, тёмный интерьер с очагом, лампа героя в темноте, отсвет башни после звонка | готово | | Lighting II (импульсы, день/ночь, виньетка, тёмные пятна) | непрерывное время суток (1 с = 1 игровая минута): закат/ночь по часам, ночной ambient + светящиеся колокольчики, окна гаснут днём, вспышки ударов/резонанса/урона, виньетка при низком hp и в накате, тёмные ауры зон наката | готово | @@ -85,6 +86,9 @@ - `core/Cooldown.ts` — `trigger/update/ready/progress`. - `render/particleSim.ts` — чистая интеграция частиц; `Particles.burst/oneShot`. - `anim/flash.ts` + `render/spriteFx.ts` — `TintFlash`/`flashSprite`. +- `render/fx/` — `FxLayer` (one-shot эффекты: `ring/floatText/fade/pop/flash/burst/trail` + + пресеты частиц) и чистая математика сэмплов `fxSim.ts`; вся рукописная + эффектная обвязка демо мигрировала на пресеты. - `input/InputManager` — `isActionJustReleased`, `wasKeyReleased`, `pointer.downTicks`, мульти-биндинг (одна клавиша → несколько действий). - `core/StateMachine.transitionAny` — переход из любого состояния.