Newer
Older
rpg / packages / engine / src / map / __tests__ / isometricMap.test.ts
import { describe, it, expect } from 'vitest';
import { Container, Texture } from 'pixi.js';
import { IsometricTileMap, type TileMapData } from '../IsometricTileMap';
import { IsoDepthLayer } from '../../render/IsoDepthLayer';
import { isoToScreen } from '../../math/iso';

function makeData(): TileMapData {
    return {
        width: 4,
        height: 4,
        tiles: new Array(16).fill(0),
        blocked: [2],
        tall: { 3: { height: 24, ground: 0 } }
    };
}

describe('IsometricTileMap.setTile', () => {
    it('обновляет данные и проходимость ячейки', () => {
        const map = new IsometricTileMap(makeData(), new Map());
        expect(map.isWalkable(1, 1)).toBe(true);

        map.setTile(1, 1, 3); // высокий объект
        expect(map.data.tiles[1 * 4 + 1]).toBe(3);
        expect(map.isWalkable(1, 1)).toBe(true); // 3 не в blocked

        map.setTile(1, 1, 2); // блокирующий id
        expect(map.data.tiles[1 * 4 + 1]).toBe(2);
        expect(map.isWalkable(1, 1)).toBe(false);
    });

    it('игнорирует координаты за границей карты', () => {
        const map = new IsometricTileMap(makeData(), new Map());
        expect(() => map.setTile(-1, 0, 2)).not.toThrow();
        expect(() => map.setTile(4, 0, 2)).not.toThrow();
        expect(() => map.setTile(0, 4, 2)).not.toThrow();
        // данные не изменились
        expect(map.data.tiles.every((t) => t === 0)).toBe(true);
    });
});

describe('IsometricTileMap.setTallDepthLayer', () => {
    it('tall-вьюхи живут в depth-слое с глубиной tx+ty — герой за деревом прячется', () => {
        const map = new IsometricTileMap(makeData(), new Map());
        const layer = new IsoDepthLayer();
        const hero = new Container();
        layer.add(hero, 1, 4); // юг дерева: глубина 5
        map.setTallDepthLayer(layer);

        map.setTile(1, 3, 3); // tall-объект: вьюха сразу в слое, глубина 4
        const tree = layer.children.find((c) => c !== hero);
        expect(tree?.parent).toBe(layer);
        expect(tree?.zIndex).toBe(1 + 3);

        layer.sortableChildren = true; // север дерева (глубина 3) — дерево поверх героя
        hero.zIndex = 1 + 2;
        const orderBehind = layer.children.slice().sort((a, b) => a.zIndex - b.zIndex);
        expect(orderBehind.indexOf(hero)).toBeLessThan(orderBehind.indexOf(tree!));

        hero.zIndex = 1 + 4; // юг (глубина 5) — герой поверх дерева
        const orderFront = layer.children.slice().sort((a, b) => a.zIndex - b.zIndex);
        expect(orderFront.indexOf(tree!)).toBeLessThan(orderFront.indexOf(hero));
    });

    it('setTile убирает вьюху из слоя, setTallDepthLayer(null) возвращает в карту', () => {
        const map = new IsometricTileMap(makeData(), new Map());
        const layer = new IsoDepthLayer();
        map.setTallDepthLayer(layer);
        map.setTile(2, 2, 3);
        expect(layer.children.length).toBe(1);

        map.setTile(2, 2, 0); // tall больше нет — вьюха уничтожена
        expect(layer.children.length).toBe(0);

        map.setTile(2, 2, 3);
        map.setTallDepthLayer(null); // назад в objectsLayer карты
        const objectsLayer = map.view.children.at(-1)!;
        expect(objectsLayer.children.length).toBe(1);
        expect(layer.children.length).toBe(0);
    });
});

describe('IsometricTileMap.variants', () => {
    it('вариант выбирается по хэшу координат и стабилен при setTile', () => {
        const map = new IsometricTileMap(makeDataWithVariants(), new Map());

        const variant = map.variantOf(0, 3, 2);
        expect([0, 1, 2]).toContain(variant);
        // второй экземпляр той же карты — тот же выбор (детерминизм)
        const again = new IsometricTileMap(makeDataWithVariants(), new Map());
        expect(again.variantOf(0, 3, 2)).toBe(variant);

        // setTile других id не меняет выбор для id с вариантами
        map.setTile(1, 1, 2);
        expect(map.variantOf(0, 3, 2)).toBe(variant);
        // setTile на клетке с id из variants — вариант той же клетки стабилен
        map.setTile(3, 2, 2);
        map.setTile(3, 2, 0);
        expect(map.variantOf(0, 3, 2)).toBe(variant);
    });

    it('setTileVariants задаёт таблицу рантаймом, пустой массив — no-op', () => {
        const map = new IsometricTileMap(makeData(), new Map());
        expect(map.variantOf(0, 2, 2)).toBe(0); // без вариантов id как есть
        map.setTileVariants(0, [0, 5]);
        // выбор детерминирован и покидает только варианты
        const v22 = map.variantOf(0, 2, 2);
        const v32 = map.variantOf(0, 3, 2);
        expect([0, 5]).toContain(v22);
        expect([0, 5]).toContain(v32);
        // выбор стабилен
        expect(map.variantOf(0, 2, 2)).toBe(v22);
        expect(map.variantOf(0, 3, 2)).toBe(v32);
        map.setTileVariants(0, []); // пустой массив — no-op (таблица не сбрасывается)
        expect(map.variantOf(0, 2, 2)).toBe(v22);
        // id без вариантов рисуется как есть
        expect(map.variantOf(2, 3, 2)).toBe(2);
    });

    it('анимация сочетается с вариантами: клетки анимируются по фактическому id', () => {
        // id 3 — высокий объект (tall в makeData): с вариантами [3, 5].
        const data = makeData();
        data.tiles = new Array(16).fill(3);
        const map = new IsometricTileMap(data, new Map([[3, Texture.WHITE]]));
        map.setTileVariants(3, [3, 5]);
        // Клетки получили варианты: у части клеток фактический id 5.
        const resolved = new Set([map.variantOf(3, 0, 0), map.variantOf(3, 1, 0)]);
        // Текстуры вариантных клеток отсутствуют — плейсхолдеры; важна семантика
        // variantOf: анимация больше не снимает варианты.
        expect(resolved.size).toBeGreaterThan(0);
        map.setTileAnimation(3, [Texture.WHITE, Texture.EMPTY], 4);
        expect(map.variantOf(3, 0, 0)).not.toBe(3); // вариант не снят
        // Анимация фактического id (в т.ч. вариантного) регистрирует кадры.
        map.setTileAnimation(5, [Texture.WHITE, Texture.EMPTY], 4);
        map.update(0.5);
        // Тик не бросает и не ломает клетки (дым-тест семантики).
    });
});

function makeDataWithVariants(): TileMapData {
    return { ...makeData(), variants: { 0: [0, 1, 2] } };
}

describe('IsometricTileMap.decor', () => {
    it('decorLayer между землёй и объектами, декор из данных рисуется', () => {
        const data = makeData();
        data.decor = [{ id: 7, x: 1, y: 2, dx: 2, dy: -1 }];
        const map = new IsometricTileMap(data, new Map());
        const layers = map.view.children;
        expect(layers.length).toBe(3);
        // порядок детей = порядок отрисовки: ground -> decor -> objects
        const s = map.addDecor(0, 0, Texture.WHITE)!;
        expect(s.parent).toBe(layers[1]);
        expect(s.anchor.x).toBe(0.5);
        // смещение dx/dy — от центра ромба
        const shifted = map.addDecor(2, 1, Texture.WHITE, { dx: 3, dy: -2 })!;
        const c = isoToScreen(2, 1, map.iso);
        expect(shifted.position.x).toBe(c.x + 3);
        expect(shifted.position.y).toBe(c.y + map.iso.tileH / 2 - 2);
    });

    it('addDecor кладёт один спрайт на тайл, повторный — заменяет; clearDecor убирает', () => {
        const map = new IsometricTileMap(makeData(), new Map());
        const first = map.addDecor(1, 1, Texture.WHITE)!;
        const second = map.addDecor(1, 1, Texture.WHITE)!; // замена, а не второй спрайт
        map.addDecor(2, 1, Texture.WHITE);
        expect(first.parent).toBeNull(); // прежний спрайт убран
        expect(second.parent).not.toBeNull();
        expect(map.data.decor).toBeUndefined();

        map.clearDecor(1, 1);
        map.clearDecor(1, 1); // повторный clear — no-op
        map.clearDecor(9, 9); // чужой тайл — no-op
        expect(second.parent).toBeNull();
    });

    it('addDecor за границей карты игнорируется; setTile декор не трогает', () => {
        const data = makeData();
        data.decor = [{ id: 7, x: 2, y: 2 }];
        const map = new IsometricTileMap(data, new Map());
        expect(map.addDecor(-1, 0, Texture.WHITE)).toBeUndefined();
        expect(map.addDecor(4, 0, Texture.WHITE)).toBeUndefined();

        map.setTile(2, 2, 0); // перерисовка ячейки не должна задеть декор
        map.setTile(2, 2, 2);
        expect(() => map.clearDecor(2, 2)).not.toThrow();
    });
});

describe('IsometricTileMap.cullToRect', () => {
    it('прячет тайлы вне прямоугольника видимости, null возвращает всё', () => {
        const data = makeData();
        delete data.tall; // без tall-объектов минимальный запас по краям
        const map = new IsometricTileMap(data, new Map());
        expect(map.isTileVisible(0, 0)).toBe(true); // до куллинга видно всё

        // окно вокруг центра тайла (3,3) размером в полтайла
        const c = isoToScreen(3, 3, map.iso);
        map.cullToRect({
            x: c.x - map.iso.tileW / 4,
            y: c.y - map.iso.tileH / 4,
            width: map.iso.tileW / 2,
            height: map.iso.tileH / 2
        });
        expect(map.isTileVisible(3, 3)).toBe(true);
        expect(map.isTileVisible(2, 2)).toBe(true); // сосед — внутри запаса
        expect(map.isTileVisible(0, 0)).toBe(false); // дальний угол спрятан
        expect(map.isTileVisible(3, 0)).toBe(false);

        map.cullToRect(null); // сброс — снова видно всё
        expect(map.isTileVisible(0, 0)).toBe(true);
    });
});

describe('IsometricTileMap.props', () => {
    it('блокирует весь footprint и нормализует размеры', () => {
        const data = makeData();
        data.props = [{ id: 7, x: 1, y: 1, w: 2, h: 2 }];
        const map = new IsometricTileMap(data, new Map());

        expect(map.props).toEqual([{ id: 7, x: 1, y: 1, w: 2, h: 2 }]);
        // весь footprint 2×2 непроходим, вокруг — проходим
        expect(map.isWalkable(1, 1)).toBe(false);
        expect(map.isWalkable(2, 2)).toBe(false);
        expect(map.isWalkable(0, 1)).toBe(true);
        expect(map.isWalkable(1, 3)).toBe(true);
        // спрайт пропа отдан сцене для depth-слоя
        expect(map.propViews).toHaveLength(1);
        expect(map.propViews[0]!.prop).toBe(map.props[0]);
    });

    it('footprint по умолчанию 1×1', () => {
        const data = makeData();
        data.props = [{ id: 7, x: 2, y: 0 }];
        const map = new IsometricTileMap(data, new Map());
        expect(map.props[0]!.w).toBe(1);
        expect(map.props[0]!.h).toBe(1);
        expect(map.isWalkable(2, 0)).toBe(false);
        expect(map.isWalkable(3, 0)).toBe(true);
    });

    it('setTile внутри footprint не снимает блокировку', () => {
        const data = makeData();
        data.props = [{ id: 7, x: 1, y: 1 }];
        const map = new IsometricTileMap(data, new Map());
        map.setTile(1, 1, 0);
        expect(map.isWalkable(1, 1)).toBe(false);
    });
});