Newer
Older
rpg / apps / game / src / scenes / LocationScene.ts
import { Container, Graphics, Text, Texture } from 'pixi.js';
import {
    World,
    IsometricTileMap,
    isoToScreen,
    screenToIsoExact,
    Sprite,
    DEFAULT_ISO,
    type Scene,
    type Camera
} from '@rpg/engine';
import { Game } from '../Game';
import { buildMeadowsMap, TILES } from '../data/map';
import { NPCS, type NpcDef } from '../data/npcs';
import { PlayerController, type HeroTextures } from '../systems/PlayerController';
import { DialogueSystem } from '../systems/DialogueSystem';
import { MenuScene, type SaveData } from './MenuScene';

/**
 * Локация «Выжженные луга»: карта, герой, NPC, диалоги, автосейв по Esc.
 */
export class LocationScene implements Scene {
    private world = new World();
    private map: IsometricTileMap;
    private player: PlayerController;
    private dialogue: DialogueSystem;
    private npcs: { def: NpcDef; view: Container }[] = [];
    private hint: Text;
    private mapLayer = new Container();
    private camera: Camera;

    constructor(
        private game: Game,
        save: SaveData | null
    ) {
        this.camera = game.camera;

        const data = buildMeadowsMap();
        this.map = new IsometricTileMap(data, this.tileTextures(), DEFAULT_ISO);
        this.mapLayer.addChild(this.map.view);
        this.game.renderer.worldRoot.addChild(this.mapLayer);

        const startTile = save?.pos ?? { x: 14, y: 14 };
        this.player = new PlayerController(this.world, this.map, this.heroTextures(), startTile);
        this.mapLayer.addChild(this.player.view);

        for (const def of NPCS) {
            const view = this.makeNpcView(def);
            this.mapLayer.addChild(view);
            this.npcs.push({ def, view });
        }

        this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, {
            width: 480,
            height: 270,
            margin: 8
        });
        this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id);
        if (save?.flags) {
            Object.assign(this.dialogue.flags, save.flags);
        }

        // Камера: следим за героем, границы — по экранному размеру карты.
        const size = this.map.screenSize;
        this.camera.bounds = { width: size.width, height: size.height };
        const tile = this.player.currentTile();
        const c = this.tileCenter(tile.x, tile.y);
        this.camera.follow(c.x, c.y);

        this.hint = new Text({
            text: 'клик — идти · клик по NPC — говорить · Esc — меню',
            style: { fontFamily: 'monospace', fontSize: 8, fill: 0x999988 }
        });
        this.hint.position.set(6, 4);
        this.game.renderer.uiRoot.addChild(this.hint);
    }

    enter(): void {}

    exit(): void {
        this.mapLayer.destroy({ children: true });
        this.hint.destroy();
    }

    update(dt: number): void {
        const input = this.game.engine.input;
        if (this.dialogue.active) {
            // Во время диалога клик/пробел только листают реплики.
            if (input.getPointer().justPressed || input.isActionJustPressed('advance')) {
                this.dialogue.advance();
            }
        } else {
            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();
        const c = this.tileCenter(tile.x, tile.y);
        this.camera.follow(c.x, c.y);
    }

    render(): void {}

    /** Текстуры тайлов из загруженных ассетов (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')]
        ]);
    }

    /** Кадры героя из ассетов. */
    private heroTextures(): HeroTextures {
        const a = this.game.assets;
        return {
            down: [a.texture('chars/hero_down_1'), a.texture('chars/hero_down_2')],
            up: [a.texture('chars/hero_up_1'), a.texture('chars/hero_up_2')],
            side: [a.texture('chars/hero_side_1'), a.texture('chars/hero_side_2')]
        };
    }

    /** Экранные координаты центра ромба тайла (с 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.dialogue.flags[def.flagKey];
        if (!met) {
            this.dialogue.flags[def.flagKey] = true;
        }
        this.dialogue.start(met ? def.dialogueRepeat : def.dialogueFirst);
    }

    private onDialogueFinished(_id: string): void {
        // Триггеры сюжета вешаются на id завершённого диалога (позже).
    }

    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,
            flags: this.dialogue.flags
        } satisfies SaveData);
        void this.game.scenes.replace(new MenuScene(this.game));
    }
}