Newer
Older
rpg / packages / engine / src / map / IsometricTileMap.ts
import { Container, Graphics, Texture, Sprite } from 'pixi.js';
import { Grid } from './pathfinding';
import { IsoLayout, DEFAULT_ISO, isoToScreen } from '../math/iso';

/**
 * Высокий объект на тайле: рисуется спрайтом (якорь в центре ромба) или,
 * если текстуры нет, колонной высотой heightPx.
 * ground — id тайла, каким рисовать землю под объектом (по умолчанию — сам объект).
 */
export interface TallSpec {
    height: number;
    ground?: number;
}

/**
 * Изометрическая тайл-карта.
 * Данные карты — числа (id тайлов); отрисовка — по таблице id -> Texture.
 * Проходимость определяется набором блокирующих id; тайлы «высоких» объектов
 * (стены, деревья) рисуются поверх земли по строкам глубины.
 */
export interface TileMapData {
    width: number;
    height: number;
    /** Индексы тайлов, length = width * height. */
    tiles: number[];
    /** Тайлы с этими id непроходимы. */
    blocked: number[];
    /** Тайлы с этими id рисуются как вертикальные объекты. */
    tall?: Record<number, number | TallSpec>;
}

export class IsometricTileMap implements Grid {
    readonly data: TileMapData;
    readonly iso: IsoLayout;
    readonly view: Container;

    private blockedSet: Set<number>;
    private tallSpecs: Map<number, TallSpec>;

    get width(): number {
        return this.data.width;
    }

    get height(): number {
        return this.data.height;
    }

    constructor(data: TileMapData, textures: Map<number, Texture>, iso: IsoLayout = DEFAULT_ISO) {
        this.data = data;
        this.iso = iso;
        this.blockedSet = new Set(data.blocked);
        this.tallSpecs = new Map(
            Object.entries(data.tall ?? {}).map(([id, spec]) => [
                Number(id),
                typeof spec === 'number' ? { height: spec } : spec
            ])
        );
        this.view = new Container();

        const ground = new Container();
        const objects = new Container();
        this.view.addChild(ground, objects);

        // Сортировка глубины по диагонали (tx + ty) — классика изометрии.
        const cells: { tx: number; ty: number; id: number }[] = [];
        for (let ty = 0; ty < data.height; ty++) {
            for (let tx = 0; tx < data.width; tx++) {
                cells.push({ tx, ty, id: data.tiles[ty * data.width + tx] });
            }
        }
        cells.sort((a, b) => a.tx + a.ty - (b.tx + b.ty));

        for (const cell of cells) {
            const tall = this.tallSpecs.get(cell.id);
            const p = isoToScreen(cell.tx, cell.ty, iso);

            // Земля: под высоким объектом рисуем его ground-тайл (или плейсхолдер).
            const groundId = tall?.ground ?? cell.id;
            const groundTex = textures.get(groundId);
            if (groundTex) {
                const s = new Sprite(groundTex);
                s.anchor.set(0.5, 0);
                s.position.set(p.x, p.y);
                ground.addChild(s);
            } else {
                // Нет текстуры — плейсхолдер-ромб: зелёный (проходимо) или коричневый (блок).
                const g = new Graphics();
                const hw = iso.tileW / 2;
                const hh = iso.tileH / 2;
                g.poly([p.x, p.y, p.x + hw, p.y + hh, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]);
                g.fill(this.blockedSet.has(cell.id) ? 0x4a3b2a : 0x2d5a27);
                ground.addChild(g);
            }

            // Высокий объект: спрайт с якорем в центре ромба, иначе колонна.
            if (tall) {
                const objTex = textures.get(cell.id);
                if (objTex) {
                    const s = new Sprite(objTex);
                    s.anchor.set(0.5, 1);
                    s.position.set(p.x, p.y + iso.tileH / 2);
                    objects.addChild(s);
                } else {
                    const block = new Graphics();
                    const h = tall.height;
                    const hw = iso.tileW / 2;
                    const hh = iso.tileH / 2;
                    block.poly([p.x, p.y - h, p.x + hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x - hw, p.y + hh - h]);
                    block.fill(0x6b5a44);
                    block.poly([p.x - hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]);
                    block.fill(0x4a3b2a);
                    block.poly([p.x + hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x, p.y + iso.tileH, p.x + hw, p.y + hh]);
                    block.fill(0x352a1e);
                    objects.addChild(block);
                }
            }
        }
    }

    isWalkable(x: number, y: number): boolean {
        if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return false;
        const id = this.data.tiles[y * this.data.width + x];
        return !this.blockedSet.has(id);
    }

    /** Размер карты в экранных координатах (для ограничения камеры). */
    get screenSize(): { width: number; height: number } {
        const halfW = (this.iso.tileW / 2) * (this.data.width + this.data.height);
        const halfH = (this.iso.tileH / 2) * (this.data.width + this.data.height);
        return { width: halfW, height: halfH };
    }
}