import {
Container,
Graphics,
IsoDepthLayer,
ParticleEmitter,
PixelText,
Sprite,
Texture,
IsometricTileMap,
worldToScreen,
screenToWorld,
worldDist,
worldNorm,
worldToTile,
tileToWorld,
inCircleW,
findPathToNeighbor,
DebugOverlay,
SpriteDebugView,
VirtualJoystick,
type Camera,
type Entity,
type Scene,
type Vec2
} from '@rpg/engine';
import { Game } from '../Game';
import { MenuScene, SAVE_VERSION, type SaveData } from './MenuScene';
import { InventoryScene } from './InventoryScene';
import { TILES } from '../data/map';
import { locationOf, type LocationDef } from '../data/locations';
import type { NpcDef } from '../data/npcs';
import { DIALOGUES } from '../data/dialogues';
import { QUEST_FLOWERS } from '../data/quests';
import type { EnemyKindId } from '../data/enemies';
import { PlayerController, type HeroTextures } from '../systems/PlayerController';
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';
/**
* Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc.
*/
export class LocationScene implements Scene {
private world = new Container();
private map: IsometricTileMap;
private player: PlayerController;
private dialogue: DialogueSystem;
private npcs: { def: NpcDef; view: Container }[] = [];
private actors = new IsoDepthLayer();
private hint: Container;
private ash: ParticleEmitter;
private locationLabel: PixelText;
private camera: Camera;
// --- бой ---
private combat: CombatWorld;
private combatViews: CombatViews;
private playerCombat: PlayerCombat;
private healthBar: HealthBar;
/** Индикатор заряда резонанса (под героем). */
private chargeRing: Graphics;
/** Цель, выбранная кликом по врагу (авто-подход и удар). */
private target: Entity | null = null;
private repathTimer = 0;
/** Отложенное взаимодействие: сработает, когда герой подойдёт. */
private pendingInteraction:
| { kind: 'talk'; def: NpcDef }
| { kind: 'flower'; x: number; y: number }
| null = null;
/** Таймер мигания при неуязвимости (свой, не боевой). */
private blinkT = 0;
/** Тач-джойстик (активен только для касаний). */
private joystick: VirtualJoystick;
private debug: DebugOverlay;
private charDebug: SpriteDebugView;
constructor(
private game: Game,
save: SaveData | null,
private location: LocationDef,
/** Точка входа при переходе из другой локации (приоритетнее save). */
entry?: { x: number; y: number }
) {
this.camera = game.engine.camera;
const data = this.game.mapFiles.get(location.id)!;
this.map = new IsometricTileMap(data, this.tileTextures());
this.world.addChild(this.map.view, this.actors);
this.game.renderer.worldRoot.addChild(this.world);
const startTile = entry ?? save?.pos ?? location.spawn;
if (save?.state) this.game.state.load(save.state);
this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => {
void this.game.audio.play('sfx/step', 0.35);
});
// Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе).
this.actors.add(this.player.view, startTile.x, startTile.y);
for (const def of location.npcs) {
const view = this.makeNpcView(def);
this.actors.add(view, def.tile.x, def.tile.y);
this.npcs.push({ def, view });
}
// --- боевая система: враги на ECS, звуки/события/урон героя — через колбэки ---
this.combat = new CombatWorld(
{
map: this.map,
events: this.game.engine.events,
audio: this.game.audio,
getPlayerPos: () => this.player.position,
damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from)
},
(kind) => {
// счётчики прогресса — в vars GameState
this.game.state.setVar('kills', this.game.state.getNumber('kills') + 1);
this.game.state.setVar('motes', this.game.state.getNumber('motes') + kind.motes);
}
);
for (const s of location.enemies) {
this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y));
}
this.combatViews = new CombatViews(this.combat, this.actors, this.enemyTextures(), this.world);
// Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира.
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);
});
const savedHp = this.game.state.getNumber('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.chargeRing = new Graphics();
this.game.renderer.worldRoot.addChild(this.chargeRing);
this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, this.game.state, {
width: 480,
height: 270,
margin: 8
});
this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id);
this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5);
// Атмосфера локации: пепел на лугах, туман над прудами.
this.ash =
location.ambience === 'fog'
? new ParticleEmitter({
color: 0x7a7a88,
rate: 6,
lifetime: [5, 10],
velocity: { x: [-3, 3], y: [-1, 1] },
size: 2,
spawnArea: { width: 560, height: 340 },
seed: 20260906
})
: new ParticleEmitter({
color: 0x666677,
rate: 5,
lifetime: [4, 9],
velocity: { x: [-9, -3], y: [-2, 2] },
size: 1,
spawnArea: { width: 520, height: 300 },
seed: 20260905
});
this.ash.position.set(240, 120);
this.game.renderer.worldRoot.addChild(this.ash);
// Название локации в правом верхнем углу.
const name = new PixelText({ text: location.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: 480, height: 270 } });
this.joystick.eventMode = 'none'; // включается при первом касании
this.game.renderer.uiRoot.addChild(this.joystick);
// Дебаг-оверлей (F3).
this.debug = new DebugOverlay(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.location.id); // маяк для смоук-тестов
// Амбиент локации (кроссфейд; тот же трек не перезапускается).
this.game.playMusic(this.location.music);
}
exit(): void {
this.combatViews.exit();
this.ash.clear();
this.world.destroy({ children: true });
this.ash.destroy();
this.healthBar.destroy({ children: true });
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.ash.update(dt);
const input = this.game.engine.input;
if (this.dialogue.active) {
// Во время диалога клик/пробел только листают реплики.
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')) {
void this.game.audio.play('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.handleWorldClick(pointer.x, pointer.y);
}
}
// --- боевой мир ---
this.combat.update(dt);
this.combatViews.sync(dt);
this.playerCombat.update(dt);
this.updateTarget(dt);
this.updateChargeRing();
this.healthBar.setHp(this.playerCombat.hp);
// Мигание героя при неуязвимости
this.blinkT += dt;
this.player.view.alpha =
this.playerCombat.invuln && Math.floor(this.blinkT * 8) % 2 === 0 ? 0.4 : 1;
if (this.joystick.active) {
this.player.moveFree(this.joystick.getVector(), dt);
} else {
this.player.update(dt);
}
this.resolvePendingInteraction();
const tile = this.player.currentTile();
this.actors.setDepth(this.player.view, tile.x, tile.y);
this.updateCameraFollow();
this.checkExits(tile);
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('kills')}`
]);
this.debug.update(dt);
if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture);
}
/** Переход между локациями: герой наступил на тайл-триггер. */
private checkExits(tile: { x: number; y: number }): void {
const exit = this.location.exits.find((e) => e.tile.x === tile.x && e.tile.y === tile.y);
if (!exit) return;
void this.game.scenes.replace(
new LocationScene(this.game, null, locationOf(exit.to), exit.entry),
{ duration: 0.4 }
);
}
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);
this.game.state.setVar('resonances', this.game.state.getNumber('resonances') + (slept > 0 ? 1 : 0));
} else if (action === 'attack') {
this.combat.playerConeAttack(from, this.player.dirVector);
}
}
/** Авто-подход к выбранной цели и удар при входе в конус. */
private updateTarget(dt: number): void {
if (this.target === null) return;
const en = this.combat.enemies.get(this.target);
if (!en || en.brain.dead) {
this.target = null;
return;
}
const from = this.player.position;
const dist = worldDist(from, en.pos);
if (dist <= PLAYER_COMBAT.attackStop) {
// В зоне — стоим и бьём по кулдауну
this.player.stop();
if (this.playerCombat.attackCd.trigger()) {
this.combat.playerConeAttack(from, worldNorm(en.pos.x - from.x, en.pos.y - from.y));
}
this.target = null; // цель «снята» ударом; дальше игрок решает сам
return;
}
// Перестраиваем путь к цели пару раз в секунду
this.repathTimer -= dt;
if (this.repathTimer <= 0) {
this.repathTimer = 0.5;
this.player.onWorldClick(en.pos.x, en.pos.y);
}
}
/** Индикатор заряда резонанса под героем. */
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 });
void this.game.audio.play('sfx/hurt', 0.8);
this.game.camera.addShake(2.5, 0.3);
// Отброс от источника урона (направление — по метрике проекции)
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('deaths', this.game.state.getNumber('deaths') + 1);
this.playerCombat.revive();
// Респаун на стартовом тайле локации
this.player.teleportTo(this.location.spawn);
this.updateCameraFollow(true);
this.healthBar.setHp(this.playerCombat.hp);
}
}
// ---------- остальное ----------
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);
}
/** Текстуры тайлов из загруженных ассетов (id -> Texture). */
private tileTextures(): Map<number, Texture> {
const a = this.game.assets;
return new Map([
[TILES.GRASS, a.texture('tiles/grass')],
[TILES.PATH, a.texture('tiles/path')],
[TILES.WATER, a.texture('tiles/water')],
[TILES.ASH, a.texture('tiles/ash')],
[TILES.BELLFLOWER, a.texture('tiles/bellflower')],
[TILES.TREE, a.texture('tiles/tree')]
]);
}
/** Кадры сгустков из атласа chars/clumps_sheet. */
private enemyTextures(): Map<EnemyKindId, [Texture, Texture]> {
const frames = (prefix: string) =>
this.game.assets.frames('chars/clumps_sheet.json', prefix) as [Texture, Texture];
return new Map([
['crawler', frames('clump_crawler')],
['spitter', frames('clump_spitter')],
['heavy', frames('clump_heavy')]
]);
}
/** Кадры героя из атласа chars/hero_sheet. */
private heroTextures(): HeroTextures {
const frames = (prefix: string) =>
this.game.assets.frames('chars/hero_sheet.json', prefix) as [Texture, Texture];
return {
down: frames('hero_down'),
up: frames('hero_up'),
side: frames('hero_side')
};
}
/** Экранные координаты центра ромба тайла (с Origin карты). */
private tileCenter(tx: number, ty: number): { x: number; y: number } {
const u = tileToWorld(tx, ty);
return worldToScreen(u.x, u.y);
}
private handleWorldClick(px: number, py: number): void {
// Координаты указателя (виртуальные px) -> мировые юниты (учёт камеры).
const p = screenToWorld(
px - this.game.renderer.worldRoot.position.x,
py - this.game.renderer.worldRoot.position.y
);
const worldX = p.x;
const worldY = p.y;
const clicked = worldToTile(worldX, worldY, this.map.data.width, this.map.data.height);
// Клик по тайлу NPC — диалог (в радиусе сразу, издалека — подходим).
if (clicked) {
const npc = this.npcs.find(
(n) => n.def.tile.x === clicked.x && n.def.tile.y === clicked.y
);
if (npc) {
this.requestTalk(npc.def);
return;
}
// Клик по лунному колокольчику (пруды) — собрать цветок.
if (this.location.id === 'ponds' && this.tileId(clicked.x, clicked.y) === TILES.BELLFLOWER) {
this.requestCollect(clicked.x, clicked.y);
return;
}
}
// Клик по сгустку — выбрать цель (авто-подход и удар).
const world = { x: worldX, y: worldY };
for (const [e, en] of this.combat.enemies) {
if (en.brain.dead) continue;
if (inCircleW(en.pos, en.kind.radius + 0.15, world)) {
this.target = e;
this.repathTimer = 0;
return;
}
}
this.target = null;
this.pendingInteraction = null;
this.player.onWorldClick(worldX, worldY);
}
/** Радиус взаимодействия (юнитов): от ног героя до центра тайла цели. */
private static readonly INTERACT_RANGE = 1.5;
private inInteractRange(tx: number, ty: number): boolean {
return inCircleW(this.player.position, LocationScene.INTERACT_RANGE, tileToWorld(tx, ty));
}
/**
* Взаимодействие с NPC: в радиусе — сразу; издалека — герой идёт к краю тайла,
* диалог начнётся на месте. force — сюжетное исключение без подхода.
*/
private requestTalk(def: NpcDef, force = false): void {
this.pendingInteraction = null;
if (force || this.inInteractRange(def.tile.x, def.tile.y)) {
this.talkTo(def);
return;
}
const path = findPathToNeighbor(this.map, this.player.currentTile(), def.tile);
if (path) {
this.pendingInteraction = { kind: 'talk', def };
this.player.followPath(path);
}
}
/** Сбор цветка: в радиусе — сразу, издалека — подойти и собрать. */
private requestCollect(x: number, y: number): void {
this.pendingInteraction = null;
if (this.inInteractRange(x, y)) {
this.collectFlower(x, y);
return;
}
const path = findPathToNeighbor(this.map, this.player.currentTile(), { x, y });
if (path) {
this.pendingInteraction = { kind: 'flower', x, y };
this.player.followPath(path);
}
}
/** Сработать отложенным взаимодействием, когда герой остановился. */
private resolvePendingInteraction(): void {
if (!this.pendingInteraction || this.player.moving) return;
const p = this.pendingInteraction;
this.pendingInteraction = null;
if (p.kind === 'talk') {
if (this.inInteractRange(p.def.tile.x, p.def.tile.y)) this.talkTo(p.def);
} else {
if (this.inInteractRange(p.x, p.y)) this.collectFlower(p.x, p.y);
}
}
/** id тайла карты (для кликов по сборным объектам). */
private tileId(x: number, y: number): number {
return this.map.data.tiles[y * this.map.data.width + x];
}
/** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */
private collectFlower(x: number, y: number): void {
this.map.setTile(x, y, TILES.GRASS);
this.game.inventory.add('bellflower');
const n = this.game.state.getNumber('flowers') + 1;
this.game.state.setVar('flowers', n);
this.game.engine.events.emit('quest:flower', { n });
void this.game.audio.play('sfx/bell_hit', 0.6);
const c = this.tileCenter(x, y);
this.combatViews.hitBurst(c);
this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`);
}
private talkTo(def: NpcDef): void {
const met = this.game.state.hasFlag(def.flagKey);
if (!met) this.game.state.setFlag(def.flagKey);
let id = met ? def.dialogueRepeat : def.dialogueFirst;
// Сюжетные ветки по прогрессу (квесты — по флагам GameState).
if (met && def.id === 'elder') {
const flowers = this.game.state.getNumber('flowers');
if (!this.game.state.hasFlag('quest_bells_done') && flowers >= QUEST_FLOWERS) {
id = 'elder_hand_in';
}
}
if (met && def.id === 'trader' && this.game.state.hasFlag('quest_bells_done')) {
id = 'trader_after';
}
this.dialogue.start(DIALOGUES[id], id);
}
private onDialogueFinished(id: string): void {
if (id === 'elder_hand_in') this.plantFlowers();
// Полотно — предмет сумки (флаг got_cloth остаётся как метка знакомства).
// TODO(батч 2): выдача предметов уходит в квест-модель.
if (id === 'trader_first') this.game.inventory.add('cloth');
}
/** Посадка цветов у тропы: тайлы гудят колокольчиками (крючок акта 1). */
private plantFlowers(): void {
const left = this.game.state.getNumber('flowers') - QUEST_FLOWERS;
this.game.state.setVar('flowers', Math.max(0, left));
for (const [tx, ty] of [
[15, 13],
[16, 14],
[15, 15]
]) {
this.map.setTile(tx, ty, TILES.BELLFLOWER);
this.combatViews.hitBurst(this.tileCenter(tx, ty));
}
void this.game.audio.play('sfx/bell_low', 0.8);
this.showToast('Гул снизу стал громче... Машина дышит.');
}
/** Всплывающая подсказка: появляется и растворяется над UI. */
private showToast(text: string): void {
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 });
tweens.delay(1.6, () => {
tweens.to(toast, { alpha: 0, y: 32 }, { duration: 0.5, onDone: () => toast.destroy() });
});
}
private makeNpcView(def: NpcDef): Container {
const view = new Container();
const p = this.tileCenter(def.tile.x, def.tile.y);
view.position.set(p.x, p.y);
const sprite = new Sprite(this.game.assets.texture(`chars/${def.sprite}`));
sprite.anchor.set(0.5, 1); // ноги в центре ромба
view.addChild(sprite);
// Маркер «с ним можно говорить»: восклицательный штрих.
const marker = new Graphics();
marker.rect(-1, -27, 2, 4).fill(0xf0d878);
marker.rect(-1, -21, 2, 2).fill(0xf0d878);
view.addChild(marker);
return view;
}
private saveAndExit(): void {
const pos = this.player.currentTile();
this.game.state.setVar('hp', this.playerCombat.hp);
this.game.saves.save('autosave', {
version: SAVE_VERSION,
location: this.location.id,
pos,
state: this.game.state.serialize(),
items: this.game.inventory.serialize().items,
savedAt: Date.now()
} satisfies SaveData);
void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 });
}
}