import {
Container,
Graphics,
IsoDepthLayer,
ParticleEmitter,
PixelText,
Sprite,
Texture,
IsometricTileMap,
isoToScreen,
screenToIsoExact,
DEFAULT_ISO,
type Camera,
type Scene
} from '@rpg/engine';
import { Game } from '../Game';
import { MenuScene, type SaveData } from './MenuScene';
import { buildMeadowsMap, TILES } from '../data/map';
import { NPCS, type NpcDef } from '../data/npcs';
import { DIALOGUES } from '../data/dialogues';
import { PlayerController, type HeroTextures } from '../systems/PlayerController';
import { DialogueSystem } from '../systems/DialogueSystem';
/**
* Локация «Выжженные луга»: карта, герой, 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 camera: Camera;
constructor(private game: Game, save: SaveData | null) {
this.camera = game.engine.camera;
const data = buildMeadowsMap();
this.map = new IsometricTileMap(data, this.tileTextures(), DEFAULT_ISO);
this.world.addChild(this.map.view, this.actors);
this.game.renderer.worldRoot.addChild(this.world);
const startTile = save?.pos ?? { x: 14, y: 14 };
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 NPCS) {
const view = this.makeNpcView(def);
this.actors.add(view, def.tile.x, def.tile.y);
this.npcs.push({ def, view });
}
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 = 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);
// Камера: следим за героем; ромб карты уходит в минус по X —
// границы начинаются от его западного угла.
const size = this.map.screenSize;
this.camera.bounds = {
x: -size.width / 2,
y: 0,
width: size.width,
height: size.height
};
this.updateCameraFollow();
this.hint = new Container();
const text = new PixelText({
text: 'клик — идти · клик по NPC — говорить · Esc — меню',
size: 8,
color: 0x999988
});
text.position.set(6, 4);
this.hint.addChild(text);
this.game.renderer.uiRoot.addChild(this.hint);
}
enter(): void {
// Амбиент локации (кроссфейд; тот же трек не перезапускается).
this.game.playMusic('music/meadows');
}
exit(): void {
this.ash.clear();
this.world.destroy({ children: true });
this.ash.destroy();
this.hint.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;
}
const pointer = input.getPointer();
if (pointer.justPressed) {
this.handleWorldClick(pointer.x, pointer.y);
}
this.player.update(dt);
const tile = this.player.currentTile();
this.actors.setDepth(this.player.view, tile.x, tile.y);
this.updateCameraFollow();
}
render(): void {}
private updateCameraFollow(): void {
const tile = this.player.currentTile();
const c = this.tileCenter(tile.x, tile.y);
this.camera.follow(c.x, c.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/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 p = isoToScreen(tx, ty, DEFAULT_ISO);
return { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 };
}
private handleWorldClick(px: number, py: number): void {
// Из координат экрана в мировые (учёт позиции камеры).
const worldX = px - this.game.renderer.worldRoot.position.x;
const worldY = py - this.game.renderer.worldRoot.position.y;
const clicked = screenToIsoExact(
worldX,
worldY,
this.map.data.width,
this.map.data.height,
DEFAULT_ISO
);
// Клик по тайлу NPC — диалог.
if (clicked) {
const npc = this.npcs.find(
(n) => n.def.tile.x === clicked.x && n.def.tile.y === clicked.y
);
if (npc) {
this.talkTo(npc.def);
return;
}
}
this.player.onWorldClick(worldX, worldY);
}
private talkTo(def: NpcDef): void {
const met = this.game.state.hasFlag(def.flagKey);
if (!met) this.game.state.setFlag(def.flagKey);
const id = met ? def.dialogueRepeat : def.dialogueFirst;
this.dialogue.start(DIALOGUES[id], id);
}
private onDialogueFinished(_id: string): void {
// Триггеры сюжета вешаются на id завершённого диалога (квесты — по флагам GameState).
}
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.saves.save('autosave', {
pos,
state: this.game.state.serialize(),
savedAt: Date.now()
} satisfies SaveData);
void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 });
}
}