Newer
Older
rpg / apps / game / src / scenes / MapScene.ts
import {
    Graphics,
    MenuSceneBase,
    Panel,
    PixelText,
    Sprite,
    Texture,
    renderMinimap,
    type MinimapImage,
    type TileMapData
} from '@rpg/engine';
import { Game } from '../Game';
import { TILES } from '../data/map';
import { areaOf } from '../data/locations';
import { playSfx } from '../data/sfxSpecs';

/**
 * Палитра миникарты: id тайла → цвет (0xRRGGBB, из арт-палитры). Варианты
 * тайлов красятся как базовый. Незаданный — тёмный fallback движка.
 */
const MAP_COLORS: Record<number, number> = {
    [TILES.GRASS]: 0x4a5340, // G1 — трава
    [TILES.GRASS_V1]: 0x4a5340,
    [TILES.GRASS_V2]: 0x4a5340,
    [TILES.PATH]: 0x77634f, // T1 — протоптанная земля
    [TILES.PATH_V1]: 0x77634f,
    [TILES.WATER]: 0x3a4356, // C0 — вода
    [TILES.TREE]: 0x343b2c, // G0 — тёмная крона
    [TILES.TREE_V1]: 0x343b2c,
    [TILES.TREE_V2]: 0x343b2c,
    [TILES.ASH]: 0x3e3e48, // P3 — пепел
    [TILES.ASH_V1]: 0x3e3e48,
    [TILES.BELLFLOWER]: 0xf2b45a, // F1 — цветок (тёплый)
    [TILES.TOWER]: 0x26262e, // M0 — высокая башня
    [TILES.HOUSE]: 0x5a4a3c, // T0 — крыши
    [TILES.HOUSE_V1]: 0x5a4a3c,
    [TILES.HOUSE_V2]: 0x5a4a3c,
    [TILES.HOUSE_V3]: 0x5a4a3c,
    [TILES.HOUSE_V4]: 0x5a4a3c,
    [TILES.HOUSE_BIG]: 0x5a4a3c,
    [TILES.BOULDER]: 0x767681, // P5
    [TILES.BOULDER_2]: 0x767681,
    [TILES.BOULDER_3]: 0x767681,
    [TILES.BUSH]: 0x343b2c, // G0
    [TILES.BUSH_2]: 0x343b2c,
    [TILES.BUSH_3]: 0x343b2c,
    [TILES.FLOOR]: 0x44454e, // M1 — полы интерьеров
    [TILES.WALL]: 0x26262e, // M0 — стены
    [TILES.WELL]: 0x8a6a2e, // B0 — латунный колодец
    [TILES.WIRE]: 0x5c3b2e, // R1 — бухты кабеля
    [TILES.RAIL]: 0x767681, // P5 — металл рельсов
    [TILES.STAIRS]: 0x77634f,
    [TILES.MACHINE]: 0x26262e
};

/** Цвета маркеров: герой (мигает), переходы, NPC. */
const HERO_COLOR = 0xf2d49a;
const TRANSITION_COLOR = 0xb2b2bb;
const NPC_COLOR = 0xf2b45a;
/** Цвет дорог-линий на листе мира (тропа). */
const ROAD_COLOR = 0x77634f; // T1

/** Часть листа мира: область и её оффсет в тайлах листа. */
interface WorldPart {
    id: string;
    name: string;
    x: number;
    y: number;
    map: TileMapData;
}

/**
 * Раскладка листа мира «Низина Звенца» (оффсеты в тайлах, cell = 1):
 * пруды на северо-западе, луга в центре, восточнее — деревня, роща, разъезд.
 * Интерьеры домов и зал Машины на карте мира не показаны.
 */
function worldParts(game: Game): WorldPart[] {
    const defs: [id: string, name: string, x: number, y: number][] = [
        ['ponds', 'Серые пруды', 0, 0],
        ['meadows', 'Выжженные луга', 53, 10],
        ['zvenets', 'Звенец', 114, 20],
        ['rust_grove', 'Ржавая роща', 167, 20],
        // Разъезд сдвинут вправо: подписи соседних областей не слипаются.
        ['old_siding', 'Старый разъезд', 231, 20]
    ];
    return defs.flatMap(([id, name, x, y]) => {
        const map = game.mapFiles.get(id);
        return map ? [{ id, name, x, y, map }] : [];
    });
}

/** Дороги мира: пары тайлов областей (связки переходов, тропы и колодцы). */
const WORLD_ROADS: { a: [string, number, number]; b: [string, number, number] }[] = [
    { a: ['ponds', 2, 2], b: ['meadows', 2, 2] }, // тропа на пруды
    { a: ['meadows', 54, 28], b: ['zvenets', 2, 20] }, // тропа в Звенец
    { a: ['meadows', 44, 40], b: ['zvenets', 16, 13] }, // колодцы (телепорт)
    { a: ['zvenets', 54, 20], b: ['rust_grove', 1, 20] }, // дорога в рощу
    { a: ['rust_grove', 46, 20], b: ['old_siding', 1, 20] } // дорога на разъезд
];

/** RGBA-картинка движка → пиксельная текстура (nearest, без размытия). */
function textureFromMinimap(img: { width: number; height: number; rgba: Uint8Array }): Texture {
    const canvas = document.createElement('canvas');
    canvas.width = img.width;
    canvas.height = img.height;
    const ctx = canvas.getContext('2d')!;
    ctx.putImageData(new ImageData(new Uint8ClampedArray(img.rgba), img.width, img.height), 0, 0);
    const tex = Texture.from(canvas);
    tex.source.scaleMode = 'nearest';
    return tex;
}

/**
 * Склеить миниатюры областей в лист мира: каждая область рендерится
 * renderMinimap (cell = 1), строки копируются в общий RGBA по оффсету.
 * Промежутки между областями остаются прозрачными (alpha 0).
 */
function composeWorldSheet(parts: WorldPart[]): MinimapImage {
    const width = Math.max(...parts.map((p) => p.x + p.map.width));
    const height = Math.max(...parts.map((p) => p.y + p.map.height));
    const rgba = new Uint8Array(width * height * 4); // всё прозрачено
    for (const p of parts) {
        const img = renderMinimap(p.map, { colors: MAP_COLORS, cell: 1 });
        for (let row = 0; row < img.height; row++) {
            const src = row * img.width * 4;
            const dst = ((p.y + row) * width + p.x) * 4;
            rgba.set(img.rgba.subarray(src, src + img.width * 4), dst);
        }
    }
    return { width, height, rgba };
}

/**
 * Карта мира «Низина Звенца» (предмет map_scroll): композит миниатюр всех
 * внешних областей на одном листе, дороги между переходами, маркеры текущей
 * области и мигающая точка героя. Интерьеры и зал Машины не показаны.
 * Esc/E/клавиша сумки — назад.
 */
export class MapScene extends MenuSceneBase {
    /** Мигающий маркер героя. */
    private hero: Graphics | null = null;
    private blinkT = 0;

    constructor(
        private game: Game,
        private onBack: () => void
    ) {
        super(
            { input: game.engine.input, inputBlocked: () => game.scenes.transitioning },
            { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', extra: ['inventory'] }
        );
    }

    protected build(): void {
        const parts = worldParts(this.game);
        if (parts.length === 0) return; // карты ещё не загружены — не бывает

        const panel = new Panel({ width: 300, height: 222 });
        panel.position.set((Game.VIRTUAL_W - 300) / 2, (Game.VIRTUAL_H - 222) / 2);

        const title = new PixelText({ text: 'Низина Звенца', size: 14, color: 0xd8c79a });
        title.anchor.set(0.5);
        title.position.set(150, 14);
        panel.addChild(title);

        const sheet = composeWorldSheet(parts);
        const sprite = new Sprite(textureFromMinimap(sheet));
        sprite.position.set(150 - sheet.width / 2, 46);
        panel.addChild(sprite);

        // Дороги между областями: тропы и колодцы, по связкам переходов.
        const roads = new Graphics();
        roads.lineStyle(1, ROAD_COLOR, 1);
        for (const road of WORLD_ROADS) {
            const a = parts.find((p) => p.id === road.a[0]);
            const b = parts.find((p) => p.id === road.b[0]);
            if (!a || !b) continue;
            roads.moveTo(sprite.position.x + a.x + road.a[1] + 0.5, sprite.position.y + a.y + road.a[2] + 0.5);
            roads.lineTo(sprite.position.x + b.x + road.b[1] + 0.5, sprite.position.y + b.y + road.b[2] + 0.5);
        }
        panel.addChild(roads);

        // Подписи областей под миниатюрами.
        for (const p of parts) {
            const label = new PixelText({ text: p.name, size: 8, color: 0xb2b2bb });
            label.anchor.set(0.5, 0);
            label.position.set(
                sprite.position.x + p.x + p.map.width / 2,
                sprite.position.y + p.y + p.map.height + 2
            );
            panel.addChild(label);
        }

        this.addMarkers(panel, sprite.position.x, sprite.position.y, parts);

        const hint = new PixelText({ text: 'Esc — назад', size: 9, color: 0xf2d49a });
        hint.position.set(16, 204);
        panel.addChild(hint);

        this.view.addChild(panel);
        this.game.renderer.uiRoot.addChild(this.view);
    }

    /** Маркеры текущей области: переходы, NPC и мигающий герой. */
    private addMarkers(panel: Panel, sheetX: number, sheetY: number, parts: WorldPart[]): void {
        const areaId = this.game.activeLocation?.areaId;
        const part = areaId ? parts.find((p) => p.id === areaId) : undefined;
        if (!part) return; // интерьер/подземелье: на карте мира маркеров нет
        const area = areaOf(part.id);
        const g = new Graphics();
        const at = (x: number, y: number): { x: number; y: number } => ({
            x: sheetX + part.x + x - 1,
            y: sheetY + part.y + y - 1
        });
        // Переходы — светлые клетки, NPC — тёплые.
        for (const t of area.transitions) {
            const p = at(t.tile.x, t.tile.y);
            g.rect(p.x, p.y, 3, 3).fill(TRANSITION_COLOR);
        }
        for (const npc of area.npcs) {
            const p = at(Math.floor(npc.pos.x), Math.floor(npc.pos.y));
            g.rect(p.x, p.y, 3, 3).fill(NPC_COLOR);
        }
        panel.addChild(g);

        // Герой — мигающая клетка поверх (update).
        const heroTile = this.game.activeLocation!.heroTile();
        this.hero = new Graphics();
        this.hero.rect(0, 0, 4, 4).fill(HERO_COLOR);
        const hp = at(heroTile.x, heroTile.y);
        this.hero.position.set(hp.x - 0.5, hp.y - 0.5);
        panel.addChild(this.hero);
    }

    update(dt: number): void {
        super.update(dt);
        // Мигание маркера героя (локация не тикается — мерцает сама карта).
        if (this.hero) {
            this.blinkT += dt;
            this.hero.visible = Math.floor(this.blinkT / 0.4) % 2 === 0;
        }
    }

    /** Esc или клавиша сумки — назад (в сумку, если карта открыта из неё). */
    protected onCancel(): void {
        this.close();
    }

    protected onAction(): void {
        this.close();
    }

    private close(): void {
        playSfx(this.game.audio, 'sfx/ui_click');
        this.onBack();
    }
}