diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index c04ee92..233d806 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -1,9 +1,7 @@ import { Container, - Graphics, IsoDepthLayer, Lighting, - PixelText, Sprite, SpriteMotion, Texture, @@ -12,15 +10,12 @@ emberPreset, readSeconds, worldToScreen, - worldNorm, tileToWorld, - unitsToPx, DebugOverlay, SpriteDebugView, VirtualJoystick, CutsceneRunner, type Camera, - type Entity, type Invariant, type JsonValue, type Scene, @@ -50,14 +45,14 @@ import { DialogueSystem } from '../systems/DialogueSystem'; import { CombatWorld } from '../systems/combat/CombatWorld'; import { CombatViews } from '../systems/combat/CombatViews'; -import { PlayerCombat } from '../systems/combat/PlayerCombat'; -import { HealthBar } from '../systems/combat/HealthBar'; +import { CombatFlow } from '../systems/combat/CombatFlow'; import { PLAYER_COMBAT } from '../systems/combat/stats'; import { Interactables } from '../systems/Interactables'; import { GameLighting, MAX_LIGHTS, type HeroLampDef } from '../systems/Lighting'; import { SceneObjects } from '../systems/SceneObjects'; import { LocationAudio } from '../systems/LocationAudio'; -import { InteractableViews } from '../systems/InteractableViews'; +import { InteractableViews, makeExitGlow } from '../systems/InteractableViews'; +import { LocationHud } from '../systems/LocationHud'; import { Atmosphere } from '../systems/Atmosphere'; import { FlowerQuest } from '../systems/quest/FlowerQuest'; import { enemyTextures, heroTextures, tileTextures } from '../data/assetSets'; @@ -79,7 +74,6 @@ private pendingCustom = new Set(); private npcs: { def: NpcDef; view: Container; body: Sprite }[] = []; private actors = new IsoDepthLayer(); - private hint: Container; /** Аудио локации: слои/roomtone/шорохи/музыка угрозы. */ private locationAudio: LocationAudio; /** Атмосфера: пепел/мотыли/дымка, зоны наката, виньетка. */ @@ -90,23 +84,19 @@ private interactViews: InteractableViews; /** Мигание при неуязвимости — процедурный blink (duty живой). */ private heroMotion: SpriteMotion; - private locationLabel: PixelText; + /** Название локации, подсказка, счётчик мотов (одна вьюха в uiRoot). */ + private hud: LocationHud; private camera: Camera; // --- бой --- private combat: CombatWorld; private combatViews: CombatViews; + /** Боевая обвязка героя: ввод/резонанс/урон/респаун/hp-бар/кольцо заряда. */ + private flow: CombatFlow; /** Слой мировых эффектов (кольца, частицы, растворения) — тикается engine.fx. */ private fx: FxLayer; /** Слой UI-эффектов (тосты): clear() не гасит мировые эффекты. */ private uiFx: FxLayer; - private playerCombat: PlayerCombat; - private healthBar: HealthBar; - /** Счётчик собранных мотов (HUD под сердечками); текст меняется по факту. */ - private moteLabel: PixelText; - private lastMotes = -1; - /** Индикатор заряда резонанса (под героем). */ - private chargeRing: Graphics; /** Маршрутизация клика/переходов/отложенных взаимодействий. */ private router: InteractionRouter; /** Интерактивные объекты области (сундуки, очаги, прилавки). */ @@ -159,20 +149,8 @@ // Выход интерьера: бронзовый контур проёма + подпись (у kind 'return'). const exit = area.transitions.find((t) => t.target.kind === 'return'); if (exit) { - const p = this.tileCenter(exit.tile.x, exit.tile.y); - const glow = new Graphics(); - glow - .moveTo(0, -8) - .lineTo(16, 0) - .lineTo(0, 8) - .closePath() - .stroke({ color: 0x8a6d3a, width: 1 }); - glow.position.set(p.x, p.y); + const glow = makeExitGlow(this.tileCenter(exit.tile.x, exit.tile.y)); this.world.addChildAt(glow, 1); // поверх пола карты, под акторами - const label = new PixelText({ text: 'выход', size: 9, color: 0xd8c79a }); - label.anchor.set(0.5, 1); - label.position.set(p.x, p.y - 12); - this.world.addChildAt(label, 2); } // Источники амбиент-слоёв: позиции тайлов по id — один проход по карте. for (let y = 0; y < data.height; y++) { @@ -252,7 +230,7 @@ map: this.map, events: this.game.engine.events, getPlayerPos: () => this.player.position, - damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from), + damagePlayer: (dmg, from) => this.flow.onPlayerDamaged(dmg, from), registry: this.objects.registry }, (kind) => { @@ -276,27 +254,7 @@ this.fx ); - // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира. - this.eventOffs.push( - this.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => { - const en = this.combat.enemies.get(entity); - if (!en) return; - this.combatViews.flashEnemy(entity); - const s = worldToScreen(en.pos.x, en.pos.y); - if (en.brain.dead) this.combatViews.deathBurst(s); - else this.combatViews.hitBurst(s); - // Световой импульс в точке попадания (lightRoot — экранное пространство). - const ls = this.camera.toScreen(en.pos.x, en.pos.y); - this.lighting.pulseLight({ - x: ls.x, - y: ls.y, - color: 0xf2b45a, - intensity: 0.45, - radius: unitsToPx(1.5), - spec: { attack: 0.02, decay: 0.2 } - }); - }) - ); + // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира (в CombatFlow). // Пейзажная фауна: безгласные олени у берегов (сама по себе, вне боя). this.fauna = new FaunaSystem( @@ -324,19 +282,6 @@ }) ); - const savedHp = this.game.state.getNumber(VARS.hp) || PLAYER_COMBAT.maxHp; - this.playerCombat = new PlayerCombat(savedHp); - this.healthBar = new HealthBar(); - this.healthBar.setHp(this.playerCombat.hp); - this.game.renderer.uiRoot.addChild(this.healthBar); - this.moteLabel = new PixelText({ text: '', size: 10, color: 0xd8c79a }); - this.moteLabel.anchor.set(1, 0); - this.moteLabel.position.set(474, 17); // под названием локации - this.game.renderer.uiRoot.addChild(this.moteLabel); - - this.chargeRing = new Graphics(); - this.game.renderer.worldRoot.addChild(this.chargeRing); - this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, this.game.state, { width: Game.VIRTUAL_W, height: Game.VIRTUAL_H, @@ -386,31 +331,10 @@ talkTo: (d) => this.talkTo(d), collectFlower: (x, y) => this.flowers.collectFlower(x, y), useTransition: (d) => this.useTransition(d), - attackTarget: (from, dir) => this.attackTarget(from, dir) + attackTarget: (from, dir) => this.flow.attackTarget(from, dir) } }); - // Агентный мост сцены (снапшот/инварианты/команды). - this.agentView = new SceneAgentView({ - game: this.game, - map: this.map, - area, - player: this.player, - playerCombat: this.playerCombat, - combat: this.combat, - npcs: this.npcs.map((n) => n.def), - interactables: this.interactables, - registry: this.objects.registry, - walkGrid: () => this.objects.walkGrid(), - dialogue: this.dialogue, - cutscene: this.cutscene, - lastToast: () => this.lastToast, - inHazard: () => this.atmosphere.inHazard, - damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from ?? this.player.position), - lighting: () => this.lighting, - followCamera: (snap) => this.updateCameraFollow(snap) - }); - // Атмосфера локации: пепел/мотыли/дымка, зоны наката, виньетка. this.atmosphere = new Atmosphere({ game: this.game, @@ -419,7 +343,7 @@ uiRoot: this.game.renderer.uiRoot, addFx: (u) => this.game.engine.fx.add(u), player: this.player, - heroHp: () => this.playerCombat.hp, + heroHp: () => this.flow.playerCombat.hp, vignette: { level: () => this.lighting.vignetteLevel, set: (level, fade) => this.lighting.setVignette(level, fade) @@ -435,7 +359,7 @@ layerTilePos: this.layerTilePos, heroPos: () => this.player.position, playTheme: (key) => this.game.playTheme(key), - threat: () => this.combatThreat() + threat: () => this.flow.threat() }); // Освещение: ambient области + статические источники (очаг, окна, лампа). @@ -457,12 +381,48 @@ lamp: HERO_LAMP }); - // Название локации в правом верхнем углу. - const name = new PixelText({ text: area.name, size: 11, color: 0x999988 }); - name.anchor.set(1, 0); - name.position.set(474, 4); - this.game.renderer.uiRoot.addChild(name); - this.locationLabel = name; + // HUD: название локации, подсказка управления, счётчик мотов. + this.hud = new LocationHud(area.name); + this.game.renderer.uiRoot.addChild(this.hud.view); + + // Боевая обвязка героя: ввод атаки, резонанс, урон/респаун, hp-бар. + this.flow = new CombatFlow( + { + game: this.game, + combat: this.combat, + combatViews: this.combatViews, + player: this.player, + lighting: this.lighting, + camera: this.camera, + fx: this.fx, + spawn: area.spawn, + inHazard: () => this.atmosphere.inHazard !== null, + snapCamera: () => this.updateCameraFollow(true) + }, + this.game.state.getNumber(VARS.hp) || PLAYER_COMBAT.maxHp + ); + this.game.renderer.uiRoot.addChild(this.flow.healthBar); + + // Агентный мост сцены (снапшот/инварианты/команды). + this.agentView = new SceneAgentView({ + game: this.game, + map: this.map, + area, + player: this.player, + playerCombat: this.flow.playerCombat, + combat: this.combat, + npcs: this.npcs.map((n) => n.def), + interactables: this.interactables, + registry: this.objects.registry, + walkGrid: () => this.objects.walkGrid(), + dialogue: this.dialogue, + cutscene: this.cutscene, + lastToast: () => this.lastToast, + inHazard: () => this.atmosphere.inHazard, + damagePlayer: (dmg, from) => this.flow.onPlayerDamaged(dmg, from ?? this.player.position), + lighting: () => this.lighting, + followCamera: (snap) => this.updateCameraFollow(snap) + }); // Камера: следим за героем; границы — мировой прямоугольник карты (юниты). this.camera.bounds = this.map.worldBounds; @@ -471,17 +431,6 @@ this.camera.deadZonePx = { width: 180, height: 120 }; this.updateCameraFollow(true); - this.hint = new Container(); - const text = new PixelText({ - text: 'клик — идти/атаковать · Space — удар (удержать — резонанс) · I — сумка · Esc — меню', - size: 10, - color: 0x999988 - }); - // Под сердечками (HealthBar занимает y≈13..18). - text.position.set(6, 23); - this.hint.addChild(text); - this.game.renderer.uiRoot.addChild(this.hint); - // Тач-джойстик: перехватывает касания (мышь проходит насквозь). this.joystick = new VirtualJoystick({ screen: { width: Game.VIRTUAL_W, height: Game.VIRTUAL_H } }); this.joystick.eventMode = 'none'; // включается при первом касании @@ -519,17 +468,14 @@ this.dialogue.destroy(); this.fauna.exit(); this.combatViews.exit(); + this.flow.exit(); this.fx.destroy({ children: true }); this.uiFx.destroy({ children: true }); this.world.destroy({ children: true }); this.atmosphere.destroy(); this.lighting.destroy(); this.lightView.destroy({ children: true }); - this.healthBar.destroy({ children: true }); - this.moteLabel.destroy({ children: true }); - this.chargeRing.destroy(); // в worldRoot — не вычищается с this.world - this.hint.destroy({ children: true }); - this.locationLabel.destroy({ children: true }); + this.hud.destroy(); this.joystick.destroy({ children: true }); this.debug.view.destroy({ children: true }); this.charDebug.view.destroy({ children: true }); @@ -586,10 +532,10 @@ // --- ввод боя: тап = короткий удар, удержание = заряд резонанса --- if (input.isActionJustPressed('attack')) { - this.playerCombat.startCharge(); + this.flow.onAttackPressed(); } - if (input.isActionJustReleased('attack') && this.playerCombat.isCharging()) { - this.doPlayerRelease(); + if (input.isActionJustReleased('attack')) { + this.flow.onAttackReleased(); } const pointer = input.getPointer(); @@ -605,14 +551,12 @@ // --- боевой мир --- this.combat.update(dt); this.combatViews.sync(dt); - this.playerCombat.update(dt); this.locationAudio.updateMusic(dt); this.router.updateTarget(dt); - this.updateChargeRing(); - this.healthBar.setHp(this.playerCombat.hp); + this.flow.update(dt); // Мигание героя при неуязвимости: duty blink-оживителя живой. - this.heroMotion.params.blink!.duty = this.playerCombat.invuln ? 0.5 : 1; + this.heroMotion.params.blink!.duty = this.flow.playerCombat.invuln ? 0.5 : 1; if (this.joystick.active) { this.player.moveFree(this.joystick.getVector(), dt); @@ -621,12 +565,7 @@ } this.router.resolvePending(); const tile = this.player.currentTile(); - // Счётчик мотов: текст пишем только при изменении (сбор — редкое событие). - const motes = this.game.state.getNumber(VARS.motes); - if (motes !== this.lastMotes) { - this.lastMotes = motes; - this.moteLabel.text = motes > 0 ? `Пепельные моты: ${motes}` : ''; - } + this.hud.setMotes(this.game.state.getNumber(VARS.motes)); this.atmosphere.updateHazard(tile); this.actors.setDepth(this.player.view, tile.x, tile.y); this.updateCameraFollow(); @@ -641,7 +580,7 @@ this.debug.setLines([ `tile ${tile.x},${tile.y}`, `pos ${Math.round(this.player.position.x)},${Math.round(this.player.position.y)}`, - `hp ${this.playerCombat.hp} kills ${this.game.state.getNumber(VARS.kills)}` + `hp ${this.flow.playerCombat.hp} kills ${this.game.state.getNumber(VARS.kills)}` ]); this.debug.update(dt); if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture); @@ -649,98 +588,6 @@ render(): void {} - // ---------- бой ---------- - - /** Отпускание атаки: резонанс (долгое) или короткий удар. */ - private doPlayerRelease(): void { - const action = this.playerCombat.release(); - const from = this.player.position; - if (action === 'resonance') { - const slept = this.combat.resonancePulse(from); - const s = worldToScreen(from.x, from.y); - this.combatViews.resonanceRing(s); - this.game.camera.addShake(1.5, 0.25); - // Звон вспыхивает: тёплый импульс у героя и лёгкий подъём ambient. - const ls = this.camera.toScreen(from.x, from.y); - this.lighting.pulseLight({ - x: ls.x, - y: ls.y, - color: 0xd99a32, - intensity: 0.6, - radius: unitsToPx(2.5), - spec: { attack: 0.05, decay: 0.45 } - }); - this.lighting.pulseAmbient({ color: 0xd99a32, peak: 0.12, spec: { attack: 0.05, decay: 0.5 } }); - this.game.state.setVar(VARS.resonances, this.game.state.getNumber(VARS.resonances) + (slept > 0 ? 1 : 0)); - // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел. - if (this.atmosphere.inHazard !== null) { - this.combatViews.hitBurst({ x: s.x - 14, y: s.y + 6 }); - this.combatViews.hitBurst({ x: s.x + 14, y: s.y + 6 }); - void this.game.audio.play('sfx/ash_hiss', 0.7); - } - } else if (action === 'attack') { - this.combat.playerConeAttack(from, this.player.dirVector); - } - } - - /** Удар из зоны авто-атаки (кулдаун внутри PlayerCombat). */ - private attackTarget(from: Vec2, dir: Vec2): boolean { - if (this.playerCombat.attackCd.trigger()) { - this.combat.playerConeAttack(from, dir); - return true; - } - return false; - } - - /** Уровень угрозы 0..1 по состояниям врагов (музыка: бой/настороженность). */ - private combatThreat(): number { - let threat = 0; - for (const en of this.combat.enemies.values()) { - const st = en.brain.state; - if (st === 'chase' || st === 'windup' || st === 'attack' || st === 'hurt') threat = 1; - else if (st === 'wary') threat = Math.max(threat, 0.4); - } - return threat; - } - - /** Индикатор заряда резонанса под героем. */ - private updateChargeRing(): void { - this.chargeRing.clear(); - if (!this.playerCombat.isCharging()) return; - const u = this.player.position; - const p = worldToScreen(u.x, u.y); - const k = Math.min(1, this.playerCombat.chargeTime / PLAYER_COMBAT.resonanceCharge); - const color = k >= 1 ? 0xd99a32 : 0x888899; - this.chargeRing - .circle(p.x, p.y - 2, 6 + 4 * k) - .stroke({ color, width: 1, alpha: 0.9 }); - } - - /** Урон герою: неуязвимость внутри PlayerCombat; здесь отброс, шейк, звук, смерть. */ - private onPlayerDamaged(damage: number, from: Vec2): void { - const applied = this.playerCombat.takeDamage(damage); - if (applied <= 0) return; - this.game.engine.events.emit('combat:playerHit', { hp: this.playerCombat.hp }); - 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; - this.player.applyKnockback(worldNorm(dx, dy), PLAYER_COMBAT.knockback); - - if (this.playerCombat.dead) { - this.game.state.setVar(VARS.deaths, this.game.state.getNumber(VARS.deaths) + 1); - this.playerCombat.revive(); - // Респаун на стартовом тайле локации - this.player.teleportTo(this.area.spawn); - this.updateCameraFollow(true); - this.healthBar.setHp(this.playerCombat.hp); - } - } - // ---------- агентный мост (SceneAgent → SceneAgentView) ---------- /** Контентный слой снапшота — см. apps/game/src/agent/snapshot.ts. */ @@ -870,7 +717,7 @@ private saveAndExit(): void { const pos = this.player.currentTile(); - this.game.state.setVar(VARS.hp, this.playerCombat.hp); + this.game.state.setVar(VARS.hp, this.flow.playerCombat.hp); this.game.saves.save( 'autosave', { diff --git a/apps/game/src/systems/InteractableViews.ts b/apps/game/src/systems/InteractableViews.ts index 87c31f7..890cd98 100644 --- a/apps/game/src/systems/InteractableViews.ts +++ b/apps/game/src/systems/InteractableViews.ts @@ -137,4 +137,23 @@ view.addChild(marker); return { view, body: sprite }; } +} + +/** Бронзовый контур проёма + подпись «выход» — маркер интерьеров (kind 'return'). */ +export function makeExitGlow(p: { x: number; y: number }): Container { + const view = new Container(); + const glow = new Graphics(); + glow + .moveTo(0, -8) + .lineTo(16, 0) + .lineTo(0, 8) + .closePath() + .stroke({ color: 0x8a6d3a, width: 1 }); + glow.position.set(p.x, p.y); + view.addChild(glow); + const label = new PixelText({ text: 'выход', size: 9, color: 0xd8c79a }); + label.anchor.set(0.5, 1); + label.position.set(p.x, p.y - 12); + view.addChild(label); + return view; } \ No newline at end of file diff --git a/apps/game/src/systems/LocationHud.ts b/apps/game/src/systems/LocationHud.ts new file mode 100644 index 0000000..8bc6b59 --- /dev/null +++ b/apps/game/src/systems/LocationHud.ts @@ -0,0 +1,42 @@ +import { Container, PixelText } from '@rpg/engine'; + +/** + * HUD локации: название области (правый верх), подсказка управления (левый + * верх), счётчик мотов (под названием). Одна вьюха в uiRoot — сцена только + * добавляет её, тикает setMotes и гасит в exit. + */ +export class LocationHud { + readonly view = new Container(); + private motesLabel: PixelText; + private lastMotes = -1; + + constructor(areaName: string) { + const label = new PixelText({ text: areaName, size: 11, color: 0x999988 }); + label.anchor.set(1, 0); + label.position.set(474, 4); + this.view.addChild(label); + this.motesLabel = new PixelText({ text: '', size: 10, color: 0xd8c79a }); + this.motesLabel.anchor.set(1, 0); + this.motesLabel.position.set(474, 17); // под названием локации + this.view.addChild(this.motesLabel); + const hint = new PixelText({ + text: 'клик — идти/атаковать · Space — удар (удержать — резонанс) · I — сумка · Esc — меню', + size: 10, + color: 0x999988 + }); + // Под сердечками (HealthBar занимает y≈13..18). + hint.position.set(6, 23); + this.view.addChild(hint); + } + + /** Счётчик мотов: текст пишем только при изменении (сбор — редкое событие). */ + setMotes(count: number): void { + if (count === this.lastMotes) return; + this.lastMotes = count; + this.motesLabel.text = count > 0 ? `Пепельные моты: ${count}` : ''; + } + + destroy(): void { + this.view.destroy({ children: true }); + } +} \ No newline at end of file diff --git a/apps/game/src/systems/combat/CombatFlow.ts b/apps/game/src/systems/combat/CombatFlow.ts new file mode 100644 index 0000000..3225d03 --- /dev/null +++ b/apps/game/src/systems/combat/CombatFlow.ts @@ -0,0 +1,209 @@ +import { + Graphics, + worldNorm, + worldToScreen, + unitsToPx, + type Camera, + type Entity, + type FxLayer, + type Vec2 +} from '@rpg/engine'; +import type { Game } from '../../Game'; +import type { VarId } from '../../data/ids'; +import { VARS } from '../../data/ids'; +import { GameLighting } from '../Lighting'; +import type { PlayerController } from '../PlayerController'; +import { CombatWorld } from './CombatWorld'; +import { CombatViews } from './CombatViews'; +import { HealthBar } from './HealthBar'; +import { PlayerCombat } from './PlayerCombat'; +import { PLAYER_COMBAT } from './stats'; + +/** Боевые счётчики GameState (инкрементируются здесь). */ +const BUMP: Record<'kills' | 'motes' | 'deaths' | 'resonances', VarId> = { + kills: VARS.kills, + motes: VARS.motes, + deaths: VARS.deaths, + resonances: VARS.resonances +}; + +/** + * Боевая обвязка героя поверх ECS-мира CombatWorld: ввод атаки (тап — короткий + * удар, удержание — резонанс), реакции на урон (вспышка, шейк, отброс, + * смерть-респаун), hp-бар, индикатор заряда, уровень угрозы для музыки. + * Сцена только собирает систему, тикает и передаёт ей ввод. + */ +export interface CombatFlowDeps { + game: Game; + combat: CombatWorld; + combatViews: CombatViews; + player: PlayerController; + lighting: GameLighting; + camera: Camera; + /** Слой мировых эффектов (флэш урона на герое). */ + fx: FxLayer; + /** Стартовый тайл локации (респаун после смерти). */ + spawn: Vec2; + /** Зона пепельного наката (резонанс вспыхивает пепел). */ + inHazard(): boolean; + /** Респаун/телепорт: камера мгновенно на героя. */ + snapCamera(): void; +} + +export class CombatFlow { + /** Боевое состояние героя (читают агентный мост и мигание неуязвимости). */ + readonly playerCombat: PlayerCombat; + /** Полоска сердец (сцена добавляет в uiRoot). */ + readonly healthBar: HealthBar; + private ring: Graphics; + private offs: (() => void)[] = []; + + constructor(private deps: CombatFlowDeps, savedHp: number) { + this.playerCombat = new PlayerCombat(savedHp); + this.healthBar = new HealthBar(); + this.healthBar.setHp(this.playerCombat.hp); + this.ring = new Graphics(); + deps.game.renderer.worldRoot.addChild(this.ring); + // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира. + this.offs.push( + deps.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => { + const en = deps.combat.enemies.get(entity); + if (!en) return; + deps.combatViews.flashEnemy(entity); + const s = worldToScreen(en.pos.x, en.pos.y); + if (en.brain.dead) deps.combatViews.deathBurst(s); + else deps.combatViews.hitBurst(s); + // Световой импульс в точке попадания (lightRoot — экранное пространство). + const ls = deps.camera.toScreen(en.pos.x, en.pos.y); + deps.lighting.pulseLight({ + x: ls.x, + y: ls.y, + color: 0xf2b45a, + intensity: 0.45, + radius: unitsToPx(1.5), + spec: { attack: 0.02, decay: 0.2 } + }); + }) + ); + } + + /** Нажатие атаки: начало зарядки резонанса. */ + onAttackPressed(): void { + this.playerCombat.startCharge(); + } + + /** Отпускание атаки: резонанс (долгое) или короткий удар. */ + onAttackReleased(): void { + if (!this.playerCombat.isCharging()) return; + const action = this.playerCombat.release(); + const from = this.deps.player.position; + if (action === 'resonance') { + this.doResonance(from); + } else if (action === 'attack') { + this.deps.combat.playerConeAttack(from, this.deps.player.dirVector); + } + } + + /** Удар из зоны авто-атаки (кулдаун внутри PlayerCombat). false — кулдаун. */ + attackTarget(from: Vec2, dir: Vec2): boolean { + if (this.playerCombat.attackCd.trigger()) { + this.deps.combat.playerConeAttack(from, dir); + return true; + } + return false; + } + + /** Уровень угрозы 0..1 по состояниям врагов (музыка: бой/настороженность). */ + threat(): number { + let threat = 0; + for (const en of this.deps.combat.enemies.values()) { + const st = en.brain.state; + if (st === 'chase' || st === 'windup' || st === 'attack' || st === 'hurt') threat = 1; + else if (st === 'wary') threat = Math.max(threat, 0.4); + } + return threat; + } + + /** Тик: таймеры боя, hp-бар, кольцо заряда под героем. */ + update(dt: number): void { + this.playerCombat.update(dt); + this.updateRing(); + this.healthBar.setHp(this.playerCombat.hp); + } + + /** Урон герою: неуязвимость внутри PlayerCombat; здесь отброс, шейк, звук, смерть. */ + onPlayerDamaged(damage: number, from: Vec2): void { + const d = this.deps; + const applied = this.playerCombat.takeDamage(damage); + if (applied <= 0) return; + d.game.engine.events.emit('combat:playerHit', { hp: this.playerCombat.hp }); + d.game.camera.addShake(2.5, 0.3); + // Красная вспышка на весь экран — экранная реакция на боль. + d.lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18, spec: { attack: 0.02, decay: 0.35 } }); + // Симметричный врагам флэш урона на герое. + d.fx.flash(d.player.sprite, 0xd05a5a, 0.18); + // Отброс от источника урона (направление — по метрике проекции) + const dx = d.player.position.x - from.x; + const dy = d.player.position.y - from.y; + d.player.applyKnockback(worldNorm(dx, dy), PLAYER_COMBAT.knockback); + + if (this.playerCombat.dead) { + this.bump('deaths', 1); + this.playerCombat.revive(); + d.player.teleportTo(d.spawn); + d.snapCamera(); + } + } + + exit(): void { + for (const off of this.offs) off(); + this.offs = []; + this.ring.destroy(); // в worldRoot — не вычищается с this.world + } + + /** Резонанс: волна сна, кольцо, звон света, реакция наката. */ + private doResonance(from: Vec2): void { + const d = this.deps; + const slept = d.combat.resonancePulse(from); + const s = worldToScreen(from.x, from.y); + d.combatViews.resonanceRing(s); + d.game.camera.addShake(1.5, 0.25); + // Звон вспыхивает: тёплый импульс у героя и лёгкий подъём ambient. + const ls = d.camera.toScreen(from.x, from.y); + d.lighting.pulseLight({ + x: ls.x, + y: ls.y, + color: 0xd99a32, + intensity: 0.6, + radius: unitsToPx(2.5), + spec: { attack: 0.05, decay: 0.45 } + }); + d.lighting.pulseAmbient({ color: 0xd99a32, peak: 0.12, spec: { attack: 0.05, decay: 0.5 } }); + this.bump('resonances', slept > 0 ? 1 : 0); + // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел. + if (d.inHazard()) { + d.combatViews.hitBurst({ x: s.x - 14, y: s.y + 6 }); + d.combatViews.hitBurst({ x: s.x + 14, y: s.y + 6 }); + void d.game.audio.play('sfx/ash_hiss', 0.7); + } + } + + /** Индикатор заряда резонанса под героем. */ + private updateRing(): void { + this.ring.clear(); + if (!this.playerCombat.isCharging()) return; + const u = this.deps.player.position; + const p = worldToScreen(u.x, u.y); + const k = Math.min(1, this.playerCombat.chargeTime / PLAYER_COMBAT.resonanceCharge); + const color = k >= 1 ? 0xd99a32 : 0x888899; + this.ring + .circle(p.x, p.y - 2, 6 + 4 * k) + .stroke({ color, width: 1, alpha: 0.9 }); + } + + /** Инкремент боевого счётчика в vars GameState. */ + private bump(name: keyof typeof BUMP, by: number): void { + const key = BUMP[name]; + this.deps.game.state.setVar(key, this.deps.game.state.getNumber(key) + by); + } +} \ No newline at end of file diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index a58e254..cb2991b 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -112,7 +112,11 @@ ## DialogueRunner (графы диалогов) Рантайм диалогов отделён от отрисовки: движок ходит по графу и применяет эффекты -к GameState, игра рисует реплики своим view. Граф — обычные данные (TS или JSON): +к GameState, игра рисует реплики своим view. Модель графа (типы узлов/выборов, +условия) и чистые предикаты (`evalConditions`, `hasConditions` — их берут +валидатор и dry-run) живут в `dialogue/graph.ts`, рантайм обхода — в +`dialogue/DialogueRunner.ts`; оба ре-экспортируются из `@rpg/engine`. +Граф — обычные данные (TS или JSON): ```ts import { DialogueRunner, type DialogueGraph } from '@rpg/engine'; diff --git a/packages/engine/src/dialogue/DialogueRunner.ts b/packages/engine/src/dialogue/DialogueRunner.ts index c8025cd..2434206 100644 --- a/packages/engine/src/dialogue/DialogueRunner.ts +++ b/packages/engine/src/dialogue/DialogueRunner.ts @@ -1,177 +1,24 @@ import { GameState } from '../core/GameState'; +import { + hasConditions, + hasText, + evalConditions, + type DialogueChoice, + type DialogueConditions, + type DialogueEffects, + type DialogueEffectOp, + type DialogueGraph, + type DialogueHooks, + type DialogueNode, + type DialogueResult +} from './graph'; /** - * Рантайм диалоговых графов, отделённый от отрисовки. - * Граф — данные (TS/JSON): узлы с репликами, выборами и условиями по GameState. - * Игра рисует диалог своим view (например DialogueBox) и вызывает advance()/pick(). + * Рантайм обхода диалоговых графов, отделённый от отрисовки и от модели + * (типы графа и чистые предикаты — dialogue/graph.ts). Игра рисует диалог + * своим view (например DialogueBox) и вызывает advance()/pick(). */ -/** Условие по переменной состояния. */ -export interface VarCondition { - key: string; - op: 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'; - value: number | string; -} - -/** Действия, применяемые к GameState при входе в узел/выборе. */ -export interface DialogueEffects { - setFlags?: string[]; - clearFlags?: string[]; - setVars?: Record; - /** - * Игровые эффекты как данные: движок только эмитит их через onEffect, - * исполнение — на стороне игры (EffectSink). Неизвестный kind — ошибка - * валидатора, рантайм его игнорирует. - */ - do?: DialogueEffectOp[]; -} - -/** Одна игровая операция эффекта (что именно она значит — знает игра). */ -export interface DialogueEffectOp { - kind: 'giveItem' | 'takeItem' | 'sound' | 'toast' | 'custom'; - /** id предмета (give/take), звука (sound) или имя сюжетного эффекта (custom). */ - id?: string; - /** Количество (give/take), по умолчанию 1. */ - count?: number; - /** Текст всплывашки (toast). */ - text?: string; - /** Свободные параметры (custom). */ - payload?: Record; -} - -/** Итог пройденного диалога: где остановились, как дошли, что выбрали. */ -export interface DialogueResult { - /** id последнего показанного узла (null — ни одного). */ - lastNodeId: string | null; - /** ids узлов по порядку показа. */ - path: string[]; - /** Выборы игрока: узел, индекс в узле, текст варианта. */ - picks: { nodeId: string; index: number; text: string }[]; -} - -/** Хуки мира: предикаты условий и резолв ключей строк (локализация). */ -export interface DialogueHooks { - world?: DialogueWorld; - /** Ключ строки → текст (textKey); нет резолва — ключ и есть текст. */ - resolve?: (key: string) => string; -} - -/** - * Предикаты мира, которых движок знать не может (сумка, репутация...). - * Игра передаёт реализацию в раннер; без неё hasItem-условия ложны. - */ -export interface DialogueWorld { - hasItem(id: string): boolean; -} - -/** Условия показа узла/варианта (все перечисленные группы — AND). */ -export interface DialogueConditions { - /** Все эти флаги должны быть установлены. */ - when?: string[]; - /** Ни один из этих флагов не должен быть установлен. */ - whenNot?: string[]; - /** Условие по переменной. */ - whenVar?: VarCondition; - /** Несколько условий по переменным, AND. */ - whenVars?: VarCondition[]; - /** Все эти предметы должны быть в сумке (резолв — DialogueWorld). */ - hasItem?: string[]; -} - -export interface DialogueChoice extends DialogueEffects, DialogueConditions { - text: string; - /** Следующий узел (по умолчанию — конец диалога). */ - next?: string; - /** Настроение реплики (игра мапит на цвет/портрет). */ - mood?: string; - /** Свободные метки для агента/инструментов (на геймплей не влияют). */ - tags?: string[]; - /** Ключ строки в таблице локализации; переопределяет inline text. */ - textKey?: string; -} - -export interface DialogueNode extends DialogueEffects, DialogueConditions { - /** Имя говорящего (опционально). */ - speaker?: string; - /** Ключ имени говорящего (локализация, как textKey). */ - speakerKey?: string; - /** Текст реплики. Узел без текста — «действие»: применяет эффекты и уходит в next. */ - text?: string; - /** Варианты ответа игрока. */ - choices?: DialogueChoice[]; - /** Следующий узел. */ - next?: string; - /** Явный конец диалога (для конечных узлов без choices/next). */ - end?: boolean; - /** Настроение реплики (игра мапит на цвет/портрет). */ - mood?: string; - /** Свободные метки для агента/инструментов (на геймплей не влияют). */ - tags?: string[]; - /** Ключ строки в таблице локализации; переопределяет inline text. */ - textKey?: string; -} - -export interface DialogueGraph { - /** id стартового узла. */ - start: string; - nodes: Record; -} - -/** Есть ли в условиях хоть что-то для проверки. */ -export function hasConditions(c: DialogueConditions): boolean { - return Boolean( - c.when?.length || c.whenNot?.length || c.whenVar || c.whenVars?.length || c.hasItem?.length - ); -} - -/** Узел-реплика: есть inline text или ключ строки. */ -function hasText(n: { text?: string; textKey?: string }): boolean { - return n.text !== undefined || n.textKey !== undefined; -} - -/** Сравнение переменной по op; undefined (переменной нет) не проходит gt/lt/ge/le. */ -function checkVar(state: GameState, cond: VarCondition): boolean { - const v = state.getVar(cond.key); - switch (cond.op) { - case 'eq': - return v === cond.value; - case 'ne': - return v !== cond.value; - case 'gt': - return Number(v) > Number(cond.value); - case 'lt': - return Number(v) < Number(cond.value); - case 'ge': - return Number(v) >= Number(cond.value); - case 'le': - return Number(v) <= Number(cond.value); - } -} - -/** - * Чистая проверка условий узла/выбора без рантайма: удобна для квестовых - * стадий, dry-run и валидатора. world не задан → hasItem ложен. - */ -export function evalConditions(c: DialogueConditions, state: GameState, world?: DialogueWorld): boolean { - for (const f of c.when ?? []) { - if (!state.hasFlag(f)) return false; - } - for (const f of c.whenNot ?? []) { - if (state.hasFlag(f)) return false; - } - if (c.whenVar && !checkVar(state, c.whenVar)) return false; - for (const cond of c.whenVars ?? []) { - if (!checkVar(state, cond)) return false; - } - if (c.hasItem) { - if (!world) return false; - for (const id of c.hasItem) { - if (!world.hasItem(id)) return false; - } - } - return true; -} - /** Вид: игра рисует реплику и варианты своими средствами. */ export interface DialogueView { /** Показать реплику. choices пуст, если выбора нет. */ @@ -206,6 +53,7 @@ private walkedPath: string[] = []; private madePicks: { nodeId: string; index: number; text: string }[] = []; private walkedResult: DialogueResult | null = null; + private currentId: string | null = null; constructor( private state: GameState, @@ -239,7 +87,8 @@ /** Текущий узел (для снапшота моста). */ get node(): DialogueNode | null { - return this.currentNode; + if (!this.graph || this.currentId === null) return null; + return this.graph.nodes[this.currentId] ?? null; } /** Показанные варианты выбора (индексы — в узел графа). */ @@ -291,7 +140,7 @@ if (!this.graph || !this.awaitingChoice) return; const shown = this.shownChoices[index]; if (!shown) return; - const choice = this.currentNode?.choices?.[shown.index]; + const choice = this.node?.choices?.[shown.index]; if (!choice) return; const atNode = this.currentId; @@ -326,13 +175,6 @@ } } - private get currentNode(): DialogueNode | null { - if (!this.graph || this.currentId === null) return null; - return this.graph.nodes[this.currentId] ?? null; - } - - private currentId: string | null = null; - /** Войти в узел: проверить условия, применить эффекты, показать или продолжить. */ private enterNode(id: string): void { if (!this.graph) return; @@ -349,23 +191,21 @@ for (let steps = 0; current && steps < MAX_STEPS; steps++) { const n: DialogueNode = current.node; - if (hasConditions(n)) { - if (!this.checkConditions(n)) { - // условие не прошло — уходим по next или заканчиваем - if (n.next !== undefined) { - const nextNode = this.graph.nodes[n.next]; - if (!nextNode) { - this.currentId = null; - this.finish(); - return; - } - current = { id: n.next, node: nextNode }; - continue; - } + if (hasConditions(n) && !this.checkConditions(n)) { + // условие не прошло — уходим по next или заканчиваем + if (n.next === undefined) { this.currentId = null; this.finish(); return; } + const nextNode = this.graph.nodes[n.next]; + if (!nextNode) { + this.currentId = null; + this.finish(); + return; + } + current = { id: n.next, node: nextNode }; + continue; } this.currentId = current.id; diff --git a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts index 02b9606..602641b 100644 --- a/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts +++ b/packages/engine/src/dialogue/__tests__/DialogueRunner.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { DialogueRunner, evalConditions, type DialogueView } from '../DialogueRunner'; +import { DialogueRunner, type DialogueView } from '../DialogueRunner'; +import { evalConditions } from '../graph'; import { GameState } from '../../core/GameState'; /** Тестовый view: запоминает показанные реплики. */ diff --git a/packages/engine/src/dialogue/graph.ts b/packages/engine/src/dialogue/graph.ts new file mode 100644 index 0000000..0ea39d6 --- /dev/null +++ b/packages/engine/src/dialogue/graph.ts @@ -0,0 +1,174 @@ +import { GameState } from '../core/GameState'; + +/** + * Модель диалоговых графов и чистые предикаты над ней (без рантайма). + * Граф — данные (TS/JSON): узлы с репликами, выборами и условиями по GameState. + * Рантайм обхода графа — DialogueRunner; валидатор/dry-run/квесты пользуются + * типами и evalConditions отсюда. + */ + +/** Условие по переменной состояния. */ +export interface VarCondition { + key: string; + op: 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'; + value: number | string; +} + +/** Действия, применяемые к GameState при входе в узел/выборе. */ +export interface DialogueEffects { + setFlags?: string[]; + clearFlags?: string[]; + setVars?: Record; + /** + * Игровые эффекты как данные: движок только эмитит их через onEffect, + * исполнение — на стороне игры (EffectSink). Неизвестный kind — ошибка + * валидатора, рантайм его игнорирует. + */ + do?: DialogueEffectOp[]; +} + +/** Одна игровая операция эффекта (что именно она значит — знает игра). */ +export interface DialogueEffectOp { + kind: 'giveItem' | 'takeItem' | 'sound' | 'toast' | 'custom'; + /** id предмета (give/take), звука (sound) или имя сюжетного эффекта (custom). */ + id?: string; + /** Количество (give/take), по умолчанию 1. */ + count?: number; + /** Текст всплывашки (toast). */ + text?: string; + /** Свободные параметры (custom). */ + payload?: Record; +} + +/** Итог пройденного диалога: где остановились, как дошли, что выбрали. */ +export interface DialogueResult { + /** id последнего показанного узла (null — ни одного). */ + lastNodeId: string | null; + /** ids узлов по порядку показа. */ + path: string[]; + /** Выборы игрока: узел, индекс в узле, текст варианта. */ + picks: { nodeId: string; index: number; text: string }[]; +} + +/** Хуки мира: предикаты условий и резолв ключей строк (локализация). */ +export interface DialogueHooks { + world?: DialogueWorld; + /** Ключ строки → текст (textKey); нет резолва — ключ и есть текст. */ + resolve?: (key: string) => string; +} + +/** + * Предикаты мира, которых движок знать не может (сумка, репутация...). + * Игра передаёт реализацию в раннер; без неё hasItem-условия ложны. + */ +export interface DialogueWorld { + hasItem(id: string): boolean; +} + +/** Условия показа узла/варианта (все перечисленные группы — AND). */ +export interface DialogueConditions { + /** Все эти флаги должны быть установлены. */ + when?: string[]; + /** Ни один из этих флагов не должен быть установлен. */ + whenNot?: string[]; + /** Условие по переменной. */ + whenVar?: VarCondition; + /** Несколько условий по переменным, AND. */ + whenVars?: VarCondition[]; + /** Все эти предметы должны быть в сумке (резолв — DialogueWorld). */ + hasItem?: string[]; +} + +export interface DialogueChoice extends DialogueEffects, DialogueConditions { + text: string; + /** Следующий узел (по умолчанию — конец диалога). */ + next?: string; + /** Настроение реплики (игра мапит на цвет/портрет). */ + mood?: string; + /** Свободные метки для агента/инструментов (на геймплей не влияют). */ + tags?: string[]; + /** Ключ строки в таблице локализации; переопределяет inline text. */ + textKey?: string; +} + +export interface DialogueNode extends DialogueEffects, DialogueConditions { + /** Имя говорящего (опционально). */ + speaker?: string; + /** Ключ имени говорящего (локализация, как textKey). */ + speakerKey?: string; + /** Текст реплики. Узел без текста — «действие»: применяет эффекты и уходит в next. */ + text?: string; + /** Варианты ответа игрока. */ + choices?: DialogueChoice[]; + /** Следующий узел. */ + next?: string; + /** Явный конец диалога (для конечных узлов без choices/next). */ + end?: boolean; + /** Настроение реплики (игра мапит на цвет/портрет). */ + mood?: string; + /** Свободные метки для агента/инструментов (на геймплей не влияют). */ + tags?: string[]; + /** Ключ строки в таблице локализации; переопределяет inline text. */ + textKey?: string; +} + +export interface DialogueGraph { + /** id стартового узла. */ + start: string; + nodes: Record; +} + +/** Есть ли в условиях хоть что-то для проверки. */ +export function hasConditions(c: DialogueConditions): boolean { + return Boolean( + c.when?.length || c.whenNot?.length || c.whenVar || c.whenVars?.length || c.hasItem?.length + ); +} + +/** Узел-реплика: есть inline text или ключ строки. */ +export function hasText(n: { text?: string; textKey?: string }): boolean { + return n.text !== undefined || n.textKey !== undefined; +} + +/** Сравнение переменной по op; undefined (переменной нет) не проходит gt/lt/ge/le. */ +function checkVar(state: GameState, cond: VarCondition): boolean { + const v = state.getVar(cond.key); + switch (cond.op) { + case 'eq': + return v === cond.value; + case 'ne': + return v !== cond.value; + case 'gt': + return Number(v) > Number(cond.value); + case 'lt': + return Number(v) < Number(cond.value); + case 'ge': + return Number(v) >= Number(cond.value); + case 'le': + return Number(v) <= Number(cond.value); + } +} + +/** + * Чистая проверка условий узла/выбора без рантайма: удобна для квестовых + * стадий, dry-run и валидатора. world не задан → hasItem ложен. + */ +export function evalConditions(c: DialogueConditions, state: GameState, world?: DialogueWorld): boolean { + for (const f of c.when ?? []) { + if (!state.hasFlag(f)) return false; + } + for (const f of c.whenNot ?? []) { + if (state.hasFlag(f)) return false; + } + if (c.whenVar && !checkVar(state, c.whenVar)) return false; + for (const cond of c.whenVars ?? []) { + if (!checkVar(state, cond)) return false; + } + if (c.hasItem) { + if (!world) return false; + for (const id of c.hasItem) { + if (!world.hasItem(id)) return false; + } + } + return true; +} \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 69790a5..d66fe78 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -202,15 +202,17 @@ export { SpriteMotion } from './anim/SpriteMotion'; export type { SpriteMotionOptions } from './anim/SpriteMotion'; -// dialogue +// dialogue: модель графа и чистые предикаты (graph.ts) + рантайм обхода export { DialogueRunner, + type DialogueView +} from './dialogue/DialogueRunner'; +export { evalConditions, hasConditions, type DialogueGraph, type DialogueNode, type DialogueChoice, - type DialogueView, type DialogueWorld, type DialogueConditions, type DialogueEffects, @@ -218,7 +220,7 @@ type DialogueResult, type DialogueHooks, type VarCondition -} from './dialogue/DialogueRunner'; +} from './dialogue/graph'; // ui export { DialogueBox, type DialogueLine, type DialogueBoxOptions } from './ui/DialogueBox';