Newer
Older
rpg / apps / game / src / agent / snapshot.ts
import { gridOf, ENGINE_SNAPSHOT_KEYS, type SnapshotLayer, type JsonValue, type TileMapData } from '@rpg/engine';

/**
 * Контентный слой снапшота агентного моста. Чистые функции — тестируются
 * в Vitest без браузера; LocationScene/GameAgent подставляют живые данные.
 */

/** Top-level ключи полного снапшота игры: движковый слой + сцена + слой игры. */
export const AGENT_SNAPSHOT_KEYS: readonly string[] = [
    ...ENGINE_SNAPSHOT_KEYS,
    // слой сцены локации (LocationSnapshot)
    'scene',
    'area',
    'areaName',
    'hero',
    'enemies',
    'npcs',
    'transitions',
    'interactables',
    'collision',
    'dialogue',
    'cutscene',
    'lastToast',
    // слой игры (gameLayer)
    'flags',
    'vars',
    'inventory'
];

/** Герой. */
export interface HeroSnapshot {
    tile: { x: number; y: number };
    pos: { x: number; y: number };
    hp: number;
    maxHp: number;
    facing: string;
    moving: boolean;
    invuln: boolean;
    inHazard: string | null;
}

/** Враг. */
export interface EnemySnapshot {
    kind: string;
    state: string | null;
    hp: number;
    pos: { x: number; y: number };
    asleep: boolean;
    dead: boolean;
}

/** NPC. */
export interface NpcSnapshot {
    id: string;
    name: string;
    tile: { x: number; y: number };
    met: boolean;
}

/** Активный диалог (или null). */
export interface DialogueSnapshot {
    id: string | null;
    nodeId: string | null;
    speaker: string | null;
    text: string | null;
    /** Настроение реплики (презентационное; на геймплей не влияет). */
    mood: string | null;
    /** Свободные метки узла — для фильтров проверок. */
    tags: string[];
    choices: string[];
    /** Показанные узлы по порядку — как диалог был пройден. */
    path: string[];
    waitingForChoice: boolean;
}

/** Переход (дверь/выход/портал) — из данных локации. */
export interface TransitionSnapshot {
    tile: { x: number; y: number };
    to: string;
    /** Как срабатывает: шагом на тайл или кликом. */
    trigger: 'step' | 'click';
    label: string | null;
}

/** Интерактивный объект области (used — для once-объектов). */
export interface InteractableSnapshot {
    id: string;
    tile: { x: number; y: number };
    label: string | null;
    used: boolean;
}

/** Карта коллизий для агента: стены/вода по тайлам + крупные пропы. */
export interface CollisionSnapshot {
    width: number;
    height: number;
    /** Проходимость по тайлам, построчно: 0 — свободен, 1 — блок (включая footprint пропов). */
    blocked: number[];
    /** Крупные объекты с footprint'ом (дома и т.п.). */
    props: { x: number; y: number; w: number; h: number }[];
}

/** Контентный слой сцены локации. */
export interface LocationSnapshot {
    scene: 'location';
    area: string;
    areaName: string;
    hero: HeroSnapshot;
    enemies: EnemySnapshot[];
    npcs: NpcSnapshot[];
    transitions: TransitionSnapshot[];
    interactables: InteractableSnapshot[];
    collision: CollisionSnapshot;
    dialogue: DialogueSnapshot | null;
    cutscene: { active: boolean } | null;
    /** Последний тост (текст + тик) — единственный канал текста реакций. */
    lastToast: { text: string; tick: number } | null;
}

/** Полный снапшот игры: движковый слой + слои игры. */
export interface GameSnapshot {
    [k: string]: JsonValue | unknown;
    /** Флаги прохождения. */
    flags: string[];
    /** Переменные прохождения. */
    vars: Record<string, number | string | boolean>;
    /** Сумка. */
    inventory: { id: string; count: number }[];
}

/** Округление до 3 знаков — JSON читабельнее, точность в юнитах не теряется. */
function r(v: number): number {
    return Math.round(v * 1000) / 1000;
}

/** Слой карты коллизий: blocked по тайлам (gridOf учитывает footprint пропов). */
export function collisionLayer(data: TileMapData): SnapshotLayer {
    const grid = gridOf(data);
    const blocked: number[] = [];
    for (let y = 0; y < data.height; y++) {
        for (let x = 0; x < data.width; x++) {
            blocked.push(grid.isWalkable(x, y) ? 0 : 1);
        }
    }
    const collision: CollisionSnapshot = {
        width: data.width,
        height: data.height,
        blocked,
        props: (data.props ?? []).map((p) => ({ x: p.x, y: p.y, w: p.w ?? 1, h: p.h ?? 1 }))
    };
    return { collision: collision as unknown as JsonValue };
}

/** Слой героя. */
export function heroLayer(o: HeroSnapshot): SnapshotLayer {
    return {
        hero: {
            tile: { x: o.tile.x, y: o.tile.y },
            pos: { x: r(o.pos.x), y: r(o.pos.y) },
            hp: o.hp,
            maxHp: o.maxHp,
            facing: o.facing,
            moving: o.moving,
            invuln: o.invuln,
            inHazard: o.inHazard
        }
    };
}

/** Слой врагов. */
export function enemiesLayer(enemies: EnemySnapshot[]): SnapshotLayer {
    return {
        enemies: enemies.map((e) => ({
            kind: e.kind,
            state: e.state,
            hp: e.hp,
            pos: { x: r(e.pos.x), y: r(e.pos.y) },
            asleep: e.asleep,
            dead: e.dead
        })) as unknown as JsonValue[]
    };
}

/** Слой NPC. */
export function npcsLayer(npcs: NpcSnapshot[]): SnapshotLayer {
    return {
        npcs: npcs.map((n) => ({
            id: n.id,
            name: n.name,
            tile: { x: n.tile.x, y: n.tile.y },
            met: n.met
        })) as unknown as JsonValue[]
    };
}

/** Слой диалога. */
export function dialogueLayer(d: DialogueSnapshot | null): SnapshotLayer {
    return { dialogue: d ? (d as unknown as JsonValue) : null };
}

/** Слой уровня игры (вне сцен). */
export function gameLayer(o: {
    flags: string[];
    vars: Record<string, number | string | boolean>;
    inventory: { id: string; count: number }[];
}): GameSnapshot {
    return {
        flags: [...o.flags],
        vars: { ...o.vars },
        inventory: o.inventory.map((s) => ({ id: s.id, count: s.count }))
    };
}