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';
import { hashTile } from '../math/rng';

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

/**
 * Крупный объект на несколько тайлов (дом, большое дерево): footprint w×h
 * от северо-западного угла (x, y). Коллизия — весь footprint; земля под ним —
 * ground (если задан); спрайт якорится низом в центр footprint'а.
 * Спрайт живёт в map.propViews — сцена добавляет его в свой IsoDepthLayer
 * через addRect, чтобы герои корректно перекрывались объектом.
 */
export interface PropData {
    /** id текстуры спрайта. */
    id: number;
    /** Северо-западный тайл footprint. */
    x: number;
    y: number;
    /** Размер footprint в тайлах (по умолчанию 1×1). */
    w?: number;
    h?: number;
    /** Чем рисовать землю под footprint (по умолчанию — не трогать). */
    ground?: number;
    /** Высота в мировых юнитах — для плейсхолдера без текстуры. */
    height?: number;
}

/** Проп после нормализации (w/h заполнены). */
export interface PropPlaced extends PropData {
    w: number;
    h: number;
}

/** Спрайт пропа + его footprint — сцена вставляет view в свой depth-слой. */
export interface PropView {
    view: Container;
    prop: PropPlaced;
}

/**
 * Изометрическая тайл-карта.
 * Данные карты — числа (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>;
    /** Крупные объекты с footprint'ом в несколько тайлов. */
    props?: PropData[];
    /**
     * Варианты текстур: id -> список id-шников вариантов (включая сам id).
     * Клетка рисуется одним из вариантов — детерминированно по (x, y, id),
     * поэтому вид карты стабилен между запусками и при setTile.
     */
    variants?: Record<number, number[]>;
}

/** Спрайты одной ячейки — для перерисовки тайла без перестроения карты. */
interface CellSprites {
    ground?: Sprite;
    object?: Sprite;
    /** id тайла, по которому создан спрайт (для регистрации в анимациях). */
    groundId?: number;
    objectId?: number;
}

/** Анимация тайлов одного id: кадры, fps и общий таймлайн на все клетки. */
interface TileAnim {
    frames: Texture[];
    fps: number;
    t: number;
    sprites: Set<Sprite>;
}

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

    private textures: Map<number, Texture>;
    private blockedSet: Set<number>;
    private tallSpecs: Map<number, TallSpec>;
    /** Пропы после нормализации; их footprint блокирует клетки карты. */
    readonly props: PropPlaced[];
    /** Спрайты пропов: сцена вставляет их в свой IsoDepthLayer (addRect). */
    readonly propViews: PropView[] = [];
    private propBlocked = new Set<number>();
    private groundLayer: Container;
    private objectsLayer: Container;
    private cells = new Map<number, CellSprites>();
    private anims = new Map<number, TileAnim>();
    private spriteAnims = new Map<Sprite, number>();
    /** Варианты текстур по id (из данных или setTileVariants). */
    private variants = new Map<number, number[]>();

    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.textures = textures;
        this.blockedSet = new Set(data.blocked);
        for (const [id, ids] of Object.entries(data.variants ?? {})) {
            this.variants.set(Number(id), ids);
        }
        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.groundLayer = ground;
        this.objectsLayer = objects;
        this.view.addChild(ground, objects);

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

        for (const cell of cells) {
            this.drawCell(cell.tx, cell.ty);
        }

        // Пропы: footprint блокирует клетки, под ним рисуем ground, спрайт —
        // в propViews (сцена вставит его в свой depth-слой с сортировкой addRect).
        this.props = (data.props ?? []).map((p) => ({ ...p, w: p.w ?? 1, h: p.h ?? 1 }));
        for (const prop of this.props) {
            for (let ty = prop.y; ty < prop.y + prop.h; ty++) {
                for (let tx = prop.x; tx < prop.x + prop.w; tx++) {
                    this.propBlocked.add(ty * data.width + tx);
                }
            }
            this.drawPropGround(prop);
            this.propViews.push({ view: this.makePropView(prop), prop });
        }
    }

    /** Земля под footprint'ом пропа (если задан ground и текстура есть). */
    private drawPropGround(prop: PropPlaced): void {
        const groundTex = prop.ground !== undefined ? this.textures.get(prop.ground) : undefined;
        if (!groundTex) return;
        for (let ty = prop.y; ty < prop.y + prop.h; ty++) {
            for (let tx = prop.x; tx < prop.x + prop.w; tx++) {
                const p = isoToScreen(tx, ty, this.iso);
                const s = new Sprite(groundTex);
                s.anchor.set(0.5, 0);
                s.position.set(p.x, p.y);
                this.groundLayer.addChild(s);
            }
        }
    }

    /** Спрайт пропа (якорь низ-центр footprint'а) или колонна-плейсхолдер. */
    private makePropView(prop: PropPlaced): Container {
        const c = isoToScreen(prop.x + prop.w / 2, prop.y + prop.h / 2, this.iso);
        const tex = this.textures.get(prop.id);
        if (tex) {
            const s = new Sprite(tex);
            s.anchor.set(0.5, 1);
            s.position.set(c.x, c.y);
            return s;
        }
        return this.drawPlaceholder(c.x, c.y, prop.w, prop.h, prop.height ?? 2);
    }

    /**
     * Колонна-плейсхолдер: основание — параллелограмм footprint (w×h юнитов),
     * выдавленный вверх на height юнитов. Три видимые грани: верх, лево, право.
     */
    private drawPlaceholder(cx: number, cy: number, w: number, h: number, height: number): Container {
        const hw = this.iso.tileW / 2;
        const hh = this.iso.tileH / 2;
        const top = height * this.iso.tileW;
        // Углы основания относительно центра (проекция мирового смещения).
        const nw = { x: ((h - w) / 2) * hw, y: -((w + h) / 2) * hh };
        const ne = { x: ((w + h) / 2) * hw, y: ((h - w) / 2) * hh };
        const se = { x: ((w - h) / 2) * hw, y: ((w + h) / 2) * hh };
        const sw = { x: -((w + h) / 2) * hw, y: ((w - h) / 2) * hh };
        const g = new Graphics();
        const face = (a: typeof nw, b: typeof nw, fill: number) =>
            g.poly([cx + a.x, cy + a.y - top, cx + b.x, cy + b.y - top, cx + b.x, cy + b.y, cx + a.x, cy + a.y]).fill(fill);
        // верхняя грань
        g.poly([cx + nw.x, cy + nw.y - top, cx + ne.x, cy + ne.y - top, cx + se.x, cy + se.y - top, cx + sw.x, cy + sw.y - top]).fill(0x6b5a44);
        // левые и правые грани (к юго-западному и юго-восточному ребру)
        face(sw, se, 0x4a3b2a);
        face(se, ne, 0x352a1e);
        return g;
    }

    /**
     * Изменить тайл: обновляет данные и перерисовывает одну ячейку
     * (сбор предметов, посадка цветов, разрушаемые стены и т.п.).
     */
    setTile(x: number, y: number, id: number): void {
        if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return;
        this.data.tiles[y * this.data.width + x] = id;
        this.drawCell(x, y);
    }

    /**
     * Анимированный тайл: клетки с этим id переключают кадры по одному общему
     * таймлайну (дёшево при сотнях клеток воды). Регистрирует и уже
     * нарисованные клетки. frames — текстуры кадров (вода_1, вода_2, ...).
     * Анимация сильнее вариантов: варианты этого id снимаются.
     */
    setTileAnimation(id: number, frames: Texture[], fps: number): void {
        if (frames.length === 0 || fps <= 0) return;
        this.variants.delete(id);
        this.anims.set(id, { frames, fps, t: 0, sprites: new Set() });
        for (const cell of this.cells.values()) {
            if (cell.ground && cell.groundId === id) this.registerAnim(cell.ground, id);
            if (cell.object && cell.objectId === id) this.registerAnim(cell.object, id);
        }
    }

    /** Тик анимаций тайлов; из update сцены (no-op, если анимаций нет). */
    update(dt: number): void {
        for (const anim of this.anims.values()) {
            anim.t += dt;
            const tex = anim.frames[Math.floor(anim.t * anim.fps) % anim.frames.length];
            for (const s of anim.sprites) s.texture = tex;
        }
    }

    private registerAnim(sprite: Sprite, id: number): void {
        this.anims.get(id)?.sprites.add(sprite);
        this.spriteAnims.set(sprite, id);
    }

    /**
     * Рантайм-варианты текстур (движок не знает, что это — трава или пепел):
     * клетки id рисуются детерминированным выбором из ids (включая сам id).
     * Анимированный id вариантов не получает — анимация сильнее.
     */
    setTileVariants(id: number, ids: number[]): void {
        if (ids.length === 0 || this.anims.has(id)) return;
        this.variants.set(id, ids);
        for (const [key, cell] of this.cells) {
            if (cell.groundId === id || cell.objectId === id) {
                this.drawCell(key % this.data.width, Math.floor(key / this.data.width));
            }
        }
    }

    private unregisterAnim(sprite: Sprite): void {
        const id = this.spriteAnims.get(sprite);
        if (id === undefined) return;
        this.anims.get(id)?.sprites.delete(sprite);
        this.spriteAnims.delete(sprite);
    }

    /** Нарисовать (или перерисовать после setTile) одну ячейку карты. */
    private drawCell(tx: number, ty: number): void {
        const iso = this.iso;
        const id = this.data.tiles[ty * this.data.width + tx];
        const key = ty * this.data.width + tx;
        const old = this.cells.get(key);
        if (old?.ground) { this.unregisterAnim(old.ground); old.ground.destroy(); }
        if (old?.object) { this.unregisterAnim(old.object); old.object.destroy(); }
        const fresh: CellSprites = {};
        this.cells.set(key, fresh);

        const tall = this.tallSpecs.get(id);
        const p = isoToScreen(tx, ty, iso);

        // Земля: под высоким объектом рисуем его ground-тайл (или плейсхолдер).
        const groundBase = tall?.ground ?? id;
        const groundId = this.variantOf(groundBase, tx, ty);
        fresh.groundId = groundBase; // для матчинга анимаций — базовый id
        const groundTex = this.textures.get(groundId);
        if (groundTex) {
            const s = new Sprite(groundTex);
            s.anchor.set(0.5, 0);
            s.position.set(p.x, p.y);
            this.groundLayer.addChild(s);
            fresh.ground = s;
            this.registerAnim(s, groundBase);
        } 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(id) ? 0x4a3b2a : 0x2d5a27);
            this.groundLayer.addChild(g);
        }

        // Высокий объект: спрайт с якорем в центре ромба, иначе колонна.
        if (tall) {
            fresh.objectId = id;
            const objTex = this.textures.get(this.variantOf(id, tx, ty));
            if (objTex) {
                const s = new Sprite(objTex);
                s.anchor.set(0.5, 1);
                s.position.set(p.x, p.y + iso.tileH / 2);
                this.objectsLayer.addChild(s);
                fresh.object = s;
                this.registerAnim(s, id);
            } else {
                this.objectsLayer.addChild(this.drawPlaceholder(p.x, p.y + iso.tileH / 2, 1, 1, tall.height));
            }
        }
    }

    /**
     * Вариант id для клетки: детерминированный выбор по хэшу (без анимации).
     * Публичный — игре полезно знать, какой id фактически нарисован.
     */
    variantOf(id: number, tx: number, ty: number): number {
        const ids = this.variants.get(id);
        return ids ? ids[hashTile(tx, ty, id) % ids.length]! : id;
    }

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

    /** Тайл накрыт footprint'ом пропа (для LOS/снарядов: дом — препятствие). */
    isPropBlocked(x: number, y: number): boolean {
        return this.propBlocked.has(y * this.data.width + x);
    }

    /** Границы карты в мировых юнитах (для ограничения камеры). */
    get worldBounds(): { x: number; y: number; width: number; height: number } {
        return { x: 0, y: 0, width: this.data.width, height: this.data.height };
    }
}