import {
Container,
Graphics,
IsoDepthLayer,
Lighting,
PixelText,
Sprite,
SpriteMotion,
Texture,
IsometricTileMap,
FxLayer,
emberPreset,
readSeconds,
worldToScreen,
worldNorm,
tileToWorld,
unitsToPx,
DebugOverlay,
SpriteDebugView,
VirtualJoystick,
CutsceneRunner,
type Camera,
type Entity,
type Invariant,
type JsonValue,
type Scene,
type SnapshotLayer,
type Vec2,
type DialogueWorld
} from '@rpg/engine';
import { Game } from '../Game';
import { MenuScene } from './MenuScene';
import { SAVE_VERSION, type SaveData } from './saveData';
import { InventoryScene } from './InventoryScene';
import { TILES } from '../data/map';
import { playSfx } from '../data/sfxSpecs';
import { areaOf, type AreaDef, type AreaId, type TransitionDef } from '../data/locations';
import { AudioSystem } from '../systems/AudioSystem';
import type { StepSurface } from '../systems/StepVariants';
import { InteractionRouter, resolvePixelTile } from '../systems/ClickRouting';
import { SceneAgentView } from '../agent/SceneAgentView';
import type { NpcDef } from '../data/npcs';
import { DIALOGUES } from '../data/dialogues';
import { questDialogueFor } from '../data/quests';
import { DIALOGUE_CUSTOM, type DialogueCustomId } from '../data/effects';
import type { ItemId } from '../data/items';
import { FLAGS, VARS } from '../data/ids';
import { PlayerController } from '../systems/PlayerController';
import { FaunaSystem } from '../systems/fauna/FaunaSystem';
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 { 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 { Atmosphere } from '../systems/Atmosphere';
import { FlowerQuest } from '../systems/quest/FlowerQuest';
import { enemyTextures, heroTextures, tileTextures } from '../data/assetSets';
/** Лампа героя: тёплый, тихий, слегка мерцает (сумерки мира). */
const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.5, intensity: 0.5, flicker: 0.12 };
/**
* Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc.
* Сцена — оркестратор вьюх: аудио/атмосфера/квест/вьюхи объектов живут
* своими подсистемами (systems/), а здесь только их сборка и тик.
*/
export class LocationScene implements Scene {
private world = new Container();
private map: IsometricTileMap;
private player: PlayerController;
private dialogue: DialogueSystem;
/** Сюжетные custom-эффекты, отложенные до конца диалога. */
private pendingCustom = new Set<DialogueCustomId>();
private npcs: { def: NpcDef; view: Container; body: Sprite }[] = [];
private actors = new IsoDepthLayer();
private hint: Container;
/** Аудио локации: слои/roomtone/шорохи/музыка угрозы. */
private locationAudio: LocationAudio;
/** Атмосфера: пепел/мотыли/дымка, зоны наката, виньетка. */
private atmosphere: Atmosphere;
/** Квест «Три цветка»: сбор/посадка/кат-сцена сдачи. */
private flowers: FlowerQuest;
/** Вьюхи интерактивных объектов и NPC (фабрики + растворение). */
private interactViews: InteractableViews;
/** Мигание при неуязвимости — процедурный blink (duty живой). */
private heroMotion: SpriteMotion;
private locationLabel: PixelText;
private camera: Camera;
// --- бой ---
private combat: CombatWorld;
private combatViews: CombatViews;
/** Слой мировых эффектов (кольца, частицы, растворения) — тикается 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;
/** Интерактивные объекты области (сундуки, очаги, прилавки). */
private interactables: Interactables;
/** Объекты сцены на движковом реестре (кто на тайле — для кликов/коллизий). */
private objects: SceneObjects;
/** Освещение: движковый слой (lightRoot) + игровая обвязка. */
private lightView: Lighting;
private lighting: GameLighting;
/** Пейзажная фауна (безгласные олени). */
private fauna: FaunaSystem;
/** Позиционный звук мира + события боя (звук — подпиской). */
private worldAudio: AudioSystem;
/** off() подписок worldAudio (снять в exit). */
private audioOff: () => void;
/** off() подписок на шину событий боя (снять в exit). */
private eventOffs: (() => void)[] = [];
/** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */
private cutscene = new CutsceneRunner();
/** Тач-джойстик (активен только для касаний). */
private joystick: VirtualJoystick;
private debug: DebugOverlay;
private charDebug: SpriteDebugView;
/** Последний тост (текст + тик) — канал текста для агентного моста. */
private lastToast: { text: string; tick: number } | null = null;
/** Агентный мост сцены (снапшот/инварианты/команды). */
private agentView: SceneAgentView;
/** Позиции тайлов-источников амбиент-слоёв, по id (собираются один раз). */
private layerTilePos = new Map<number, Vec2[]>();
constructor(
private game: Game,
save: SaveData | null,
private area: AreaDef,
/** Точка входа при переходе из другой области (приоритетнее save). */
entry?: { x: number; y: number },
/** Откуда вошли (для target kind 'return'): область + тайл триггера. */
private returnTo?: { area: AreaId; entry: { x: number; y: number } }
) {
this.camera = game.engine.camera;
const data = this.game.mapFiles.get(area.id)!;
this.map = new IsometricTileMap(data, tileTextures(this.game.assets));
// Живая вода: один общий таймлайн на все клетки с id WATER (2 кадра).
this.map.setTileAnimation(
TILES.WATER,
[this.game.assets.texture('tiles/water_1'), this.game.assets.texture('tiles/water_2')],
2
);
this.world.addChild(this.map.view, this.actors);
// Выход интерьера: бронзовый контур проёма + подпись (у 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);
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++) {
for (let x = 0; x < data.width; x++) {
const id = data.tiles[y * data.width + x]!;
const list = this.layerTilePos.get(id) ?? [];
list.push({ x, y });
this.layerTilePos.set(id, list);
}
}
// Крупные объекты карты (дома) — в общий depth-слой по footprint'у.
for (const p of this.map.propViews) this.actors.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h);
this.game.renderer.worldRoot.addChild(this.world);
// Слой мировых эффектов (раньше вьюх объектов: consume растворяет через него).
this.fx = new FxLayer();
this.world.addChild(this.fx);
this.game.engine.fx.add(this.fx);
// Единый реестр объектов сцены: NPC, интерактивы, пропы (враги/фауна
// добавляются позже своими системами).
this.objects = new SceneObjects(area, this.map);
const startTile = entry ?? save?.pos ?? area.spawn;
// Тайл входа: step-переходы на нём глушатся, пока герой с него не ушёл
// (иначе вошёл в дверь — и немедленно вылетел обратно).
const disarmTile = entry ? { ...startTile } : null;
if (save?.state) this.game.state.load(save.state);
// Позиционный звук: слушатель — герой.
this.worldAudio = new AudioSystem({
audio: this.game.audio,
events: this.game.engine.events,
getListener: () => this.player.position
});
this.audioOff = this.worldAudio.attach();
this.player = new PlayerController(this.map, heroTextures(this.game.assets), startTile, () => {
this.worldAudio.playStep(this.stepSurface(this.player.currentTile()), this.player.position, 0.35);
// Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит.
this.combat.noise(this.player.position, 0.35);
});
// Тела: герой не проходит сквозь NPC (расталкивание через реестр),
// пути строятся по grid с занятыми NPC-тайлами — A* не ведёт сквозь тело.
this.player.setBodies(this.objects.registry);
this.player.setWalkGrid(this.objects.walkGrid());
// Мигание героя при неуязвимости — процедурный blink (duty меняется на лету).
this.heroMotion = new SpriteMotion(this.player.view, { blink: { period: 0.25, duty: 1 } });
this.game.engine.fx.add(this.heroMotion);
// Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе).
this.actors.add(this.player.view, startTile.x, startTile.y);
this.interactViews = new InteractableViews({
assets: this.game.assets,
addFx: (u) => this.game.engine.fx.add(u),
fx: this.fx
});
for (const def of area.npcs) {
const made = this.interactViews.createNpcView(def, (tx, ty) => this.tileCenter(tx, ty));
this.actors.add(made.view, def.tile.x, def.tile.y);
this.npcs.push({ def, view: made.view, body: made.body });
}
// Интерактивные объекты: те же вьюхи в actors-слое, реакции — через Interactables.
this.interactables = new Interactables(area.interactables ?? [], this.game.state, {
hasItem: (id) => this.game.inventory.has(id),
give: (id, n) => this.game.inventory.add(id, n),
playSound: (k) => this.worldAudio.playAt(k, this.player.position, 0.6),
showToast: (t) => this.showToast(t),
onConsumed: (def) => this.interactViews.consume(def, this.tileCenter, (s) => this.combatViews.hitBurst(s))
});
for (const def of area.interactables ?? []) {
const made = this.interactViews.createInteractableView(def, (tx, ty) => this.tileCenter(tx, ty));
this.actors.add(made.view, def.tile.x, def.tile.y);
}
// --- боевая система: враги на ECS, звуки/события/урон героя — через колбэки ---
this.combat = new CombatWorld(
{
map: this.map,
events: this.game.engine.events,
getPlayerPos: () => this.player.position,
damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from),
registry: this.objects.registry
},
(kind) => {
// счётчики прогресса — в vars GameState
this.game.state.setVar(VARS.kills, this.game.state.getNumber(VARS.kills) + 1);
this.game.state.setVar(VARS.motes, this.game.state.getNumber(VARS.motes) + kind.motes);
}
);
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.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,
enemyTextures(this.game.assets),
this.world,
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 }
});
})
);
// Пейзажная фауна: безгласные олени у берегов (сама по себе, вне боя).
this.fauna = new FaunaSystem(
this.actors,
this.game.engine.events,
data,
(area.fauna ?? []).map((t) => tileToWorld(t.x, t.y)),
this.objects.registry
);
this.fauna.setFrames(
this.game.assets.animation('chars/fauna_sheet.json', 'fauna_deer_walk') as [Texture, Texture]
);
// Мотыли над потревоженным пеплом: серые, дрейф вверх от точки звона
// (через слой эффектов: тик и зачистка — сами; раньше эмиттер не тикался).
this.eventOffs.push(
this.game.engine.events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => {
const s = worldToScreen(origin.x, origin.y);
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] },
seed: ((origin.x * 13 + origin.y) | 0) || 1
}));
})
);
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,
margin: 8,
typewriter: 40,
moodColors: { sad: 0x9aaad8, angry: 0xd89a9a, warm: 0xd8c79a }
});
this.dialogue.onDialogueFinished = () => this.onDialogueFinished();
this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5);
this.dialogue.registerSink({
giveItem: (id, count) => this.game.inventory.add(id, count),
takeItem: (id, count) => this.game.inventory.remove(id, count),
playSound: (key) => void this.game.audio.play(key, 0.8),
showToast: (text) => this.showToast(text),
custom: (name) => {
// Сюжетные эффекты — по завершении диалога (кат-сцена не рвёт реплику).
this.pendingCustom.add(name);
}
});
// Квест «Три цветка»: сбор/посадка/кат-сцена — свои изменения тайлов.
this.flowers = new FlowerQuest(
this.game,
this.map,
this.layerTilePos,
(tx, ty) => this.tileCenter(tx, ty),
(s) => this.combatViews.hitBurst(s),
(t) => this.showToast(t),
this.cutscene
);
// Маршрутизация клика/переходов/отложенных взаимодействий.
this.router = new InteractionRouter({
game: this.game,
map: this.map,
area,
player: this.player,
combat: this.combat,
interactables: this.interactables,
objects: this.objects,
disarmTile,
worldRootOffset: () => this.game.renderer.worldRoot.position,
pixelTile: (px, py) => this.pixelTile(px, py),
callbacks: {
showToast: (t) => this.showToast(t),
playUiClick: () => playSfx(this.game.audio, 'sfx/ui_click', 0.4),
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)
}
});
// Агентный мост сцены (снапшот/инварианты/команды).
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,
area,
worldRoot: this.game.renderer.worldRoot,
uiRoot: this.game.renderer.uiRoot,
addFx: (u) => this.game.engine.fx.add(u),
player: this.player,
heroHp: () => this.playerCombat.hp,
vignette: {
level: () => this.lighting.vignetteLevel,
set: (level, fade) => this.lighting.setVignette(level, fade)
},
showToast: (t) => this.showToast(t)
});
// Аудио локации: roomtone, слои, шорохи, музыка угрозы.
this.locationAudio = new LocationAudio({
audio: this.game.audio,
worldAudio: this.worldAudio,
area,
layerTilePos: this.layerTilePos,
heroPos: () => this.player.position,
playTheme: (key) => this.game.playTheme(key),
threat: () => this.combatThreat()
});
// Освещение: ambient области + статические источники (очаг, окна, лампа).
this.lightView = new Lighting({
width: Game.VIRTUAL_W,
height: Game.VIRTUAL_H,
renderer: this.game.renderer,
maxLights: MAX_LIGHTS
});
this.game.renderer.lightRoot.addChild(this.lightView);
this.lighting = new GameLighting({
area,
lighting: this.lightView,
layerTilePos: this.layerTilePos,
camera: this.camera,
heroPos: () => this.player.position,
hasFlag: (f) => this.game.state.hasFlag(f),
timeHours: () => this.game.clock.hours,
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;
// Камера: следим за героем; границы — мировой прямоугольник карты (юниты).
this.camera.bounds = this.map.worldBounds;
// Камера-«окно»: герой ходит в центральной зоне свободно, у виртуальной
// границы экрана толкает камеру (движение остаётся плавным).
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'; // включается при первом касании
this.game.renderer.uiRoot.addChild(this.joystick);
// Дебаг-оверлей (F3).
this.debug = new DebugOverlay(this.game.engine.fpsMeter, false);
this.game.renderer.uiRoot.addChild(this.debug.view);
// Дебаг спрайта героя (P): текущий кадр, увеличенный с пиксельной сеткой.
this.charDebug = new SpriteDebugView({ zoom: 8 });
this.charDebug.view.position.set(390, 120);
this.game.renderer.uiRoot.addChild(this.charDebug.view);
}
enter(): void {
console.log('[location]', this.area.id); // маяк для смоук-тестов
// Амбиент локации (кроссфейд; тот же ключ не перезапускается, '' — тишина).
this.game.playAmbience(this.area.ambience ?? '');
this.game.playTheme(this.area.theme ?? '');
this.locationAudio.startRoomTone();
// Первая подсказка сбора: как брать лунные колокольчики (один раз).
if (this.area.id === 'ponds' && !this.game.state.hasFlag(FLAGS.hint_bells)) {
this.game.state.setFlag(FLAGS.hint_bells);
this.showToast('Лунные колокольчики в земле: подойди и щёлкни по цветку.');
}
}
exit(): void {
for (const off of this.eventOffs) off();
this.eventOffs = [];
this.audioOff();
this.worldAudio.setLayers([]); // лупы локальных слоёв: иначе звучат поверх следующей сцены
this.locationAudio.exit();
this.dialogue.destroy();
this.fauna.exit();
this.combatViews.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.joystick.destroy({ children: true });
this.debug.view.destroy({ children: true });
this.charDebug.view.destroy({ children: true });
}
update(dt: number): void {
this.map.update(dt); // анимированные тайлы (вода) — no-op без анимаций
this.worldAudio.update(dt);
this.locationAudio.updateLayers();
this.locationAudio.updateAmbient(dt);
this.game.clock.advance(dt); // время суток идёт и в диалогах/катсценах: свет меняется
this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов
this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам
this.atmosphere.updateVignette();
this.fauna.update(dt);
// Кат-сцена: мир на паузе, камера под контролем раннера.
if (this.cutscene.active) {
this.cutscene.update(dt);
return;
}
const input = this.game.engine.input;
if (this.dialogue.active) {
// Во время диалога up/down листают варианты, клик/пробел — дальше/выбор.
this.dialogue.update(dt);
if (input.isActionJustPressed('up')) this.dialogue.moveCursor(-1);
if (input.isActionJustPressed('down')) this.dialogue.moveCursor(1);
if (input.getPointer().justPressed || input.isActionJustPressed('advance')) {
this.dialogue.advance();
}
return;
}
if (this.game.engine.scenes.transitioning) return;
if (input.isActionJustPressed('menu')) {
this.saveAndExit();
return;
}
if (input.isActionJustPressed('inventory')) {
playSfx(this.game.audio, 'sfx/ui_click');
void this.game.scenes.push(
new InventoryScene(this.game, () => void this.game.scenes.pop()),
{ duration: 0.2 }
);
return;
}
if (input.isActionJustPressed('debug')) {
this.debug.view.visible = !this.debug.view.visible;
}
if (input.isActionJustPressed('debugChar')) {
this.charDebug.view.visible = !this.charDebug.view.visible;
}
// --- ввод боя: тап = короткий удар, удержание = заряд резонанса ---
if (input.isActionJustPressed('attack')) {
this.playerCombat.startCharge();
}
if (input.isActionJustReleased('attack') && this.playerCombat.isCharging()) {
this.doPlayerRelease();
}
const pointer = input.getPointer();
if (pointer.justPressed) {
if (pointer.isTouch) {
// Касание отдаём джойстику (мышь идёт в мир обычным порядком).
this.joystick.eventMode = 'static';
} else {
this.router.handleWorldClick(pointer.x, pointer.y);
}
}
// --- боевой мир ---
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);
// Мигание героя при неуязвимости: duty blink-оживителя живой.
this.heroMotion.params.blink!.duty = this.playerCombat.invuln ? 0.5 : 1;
if (this.joystick.active) {
this.player.moveFree(this.joystick.getVector(), dt);
} else {
this.player.update(dt);
}
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.atmosphere.updateHazard(tile);
this.actors.setDepth(this.player.view, tile.x, tile.y);
this.updateCameraFollow();
this.router.checkTransitions(tile);
// Подписи объектов: видны вблизи, пока объект не использован (once).
for (const iv of this.interactViews.items) {
iv.label.visible =
this.router.inInteractRange(iv.def.tile.x, iv.def.tile.y) &&
!(iv.def.once && this.interactables.isUsed(iv.def.id));
}
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)}`
]);
this.debug.update(dt);
if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture);
}
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. */
agentSnapshot(): SnapshotLayer {
return this.agentView.agentSnapshot();
}
/** Инварианты сцены: валидность контента + целостность героя/врагов. */
agentInvariants(): Invariant[] {
return this.agentView.agentInvariants();
}
/** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */
agentCommand(name: string, args?: JsonValue): JsonValue {
return this.agentView.agentCommand(name, args);
}
// ---------- остальное ----------
private updateCameraFollow(snap = false): void {
// Непрерывное следование за ногами героя; snap — телепорты/спавн.
const p = this.player.position;
if (snap) this.camera.snap(p.x, p.y);
else this.camera.follow(p.x, p.y);
}
/** Экранные координаты центра ромба тайла (с Origin карты). */
private tileCenter(tx: number, ty: number): { x: number; y: number } {
const u = tileToWorld(tx, ty);
return worldToScreen(u.x, u.y);
}
/** Поверхность под ногами (трава/пепел/вода/пол) — для вариативных шагов. */
private stepSurface(tile: Vec2): StepSurface {
const d = this.map.data;
const id = d.tiles[tile.y * d.width + tile.x]!;
if (id === TILES.WATER) return 'water';
if (id === TILES.ASH || id === TILES.HOUSE || id === TILES.TOWER) return 'ash';
if (id === TILES.FLOOR || id === TILES.PATH) return 'floor';
return 'grass';
}
/**
* Тайл клика «по телу объекта» (RouterDeps.pixelTile): чистый резолвер
* resolvePixelTile с телами вьюх NPC/интерактивов и кликабельностью базы
* высокого тайла (двери-переходы, объекты).
*/
private pixelTile(px: number, py: number): { x: number; y: number } | null {
const bodies = [
...this.npcs.map((n) => ({ tile: n.def.tile, body: n.body })),
...this.interactViews.items.map((iv) => ({ tile: iv.def.tile, body: iv.body }))
].map(({ tile, body }) => ({ tile, bounds: body.getBounds() }));
return resolvePixelTile(
px,
py,
this.game.renderer.worldRoot.position,
bodies,
this.map.data,
(tile) =>
this.area.transitions.some(
(tr) => tr.tile.x === tile.x && tr.tile.y === tile.y && tr.trigger === 'click'
) || this.interactables.defAt(tile.x, tile.y) !== null
);
}
/** Предикаты мира для условий (сумка героя). */
private dialogueWorld(): DialogueWorld {
return { hasItem: (id) => this.game.inventory.count(id as ItemId) > 0 };
}
private talkTo(def: NpcDef): void {
// Флаг знакомства поднимает сам граф dialogueFirst; сцена только читает.
const met = this.game.state.hasFlag(def.flagKey);
// Сюжетная ветка — из квест-стадии; иначе обычный диалог.
const id = !met
? def.dialogueFirst
: (questDialogueFor(this.game.state, def.id, this.dialogueWorld()) ?? def.dialogueRepeat);
this.dialogue.start(DIALOGUES[id], id);
}
private onDialogueFinished(): void {
// Сюжетные custom-эффекты графа — только когда реплики доскажены.
if (this.pendingCustom.has(DIALOGUE_CUSTOM.plant_flowers)) {
this.pendingCustom.delete(DIALOGUE_CUSTOM.plant_flowers);
this.flowers.startHandInCutscene();
}
}
/** Всплывающая подсказка: появляется, держится, растворяется вверх (FxLayer). */
private showToast(text: string): void {
this.lastToast = { text, tick: this.game.engine.tickCount };
// Новый тост вытесняет старый (раньше наслаивались).
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 }
});
}
/** Куда ведёт переход: область + точка входа ('return' — откуда вошли). */
private targetEntry(def: TransitionDef): { area: AreaDef; entry: { x: number; y: number } } {
const t = def.target;
if (t.kind === 'area') return { area: areaOf(t.area), entry: t.entry };
return {
area: areaOf(this.returnTo?.area),
entry: this.returnTo?.entry ?? this.area.spawn
};
}
/** Выполнить переход: новая сцена с fade (длительность — из определения). */
private useTransition(def: TransitionDef): void {
const next = this.targetEntry(def);
playSfx(this.game.audio, 'sfx/whoosh', 0.5);
void this.game.scenes.replace(
new LocationScene(this.game, null, next.area, next.entry, {
area: this.area.id,
entry: this.player.currentTile()
}),
{ duration: def.duration ?? 0.4 }
);
}
private saveAndExit(): void {
const pos = this.player.currentTile();
this.game.state.setVar(VARS.hp, this.playerCombat.hp);
this.game.saves.save(
'autosave',
{
version: SAVE_VERSION,
area: this.area.id,
pos,
state: this.game.state.serialize(),
items: this.game.inventory.serialize().items,
clock: { minutes: this.game.clock.minutes },
returnTo: this.returnTo ?? null,
savedAt: Date.now()
} satisfies SaveData,
{ title: 'Автосохранение', savedAt: Date.now(), version: SAVE_VERSION, extras: { area: this.area.id } }
);
void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 });
}
}