Newer
Older
rpg / v2 / apps / game / src / interactables.ts
/**
 * Рантайм интерактивных объектов области (перенос v1 1:1): флаги
 * used:<id>, применение эффектов реакции через sink (сцена подставляет
 * сумку/тост/сюжетное событие).
 */
import { usedFlag } from './data/ids';
import {
    resolveInteractable,
    type InteractableDef,
    type InteractCtx,
    type InteractPick,
    type InteractResponse,
} from './data/interactables';
import type { GameState } from './state';

/** Эффекты реакции — мост между чистым резолвером и игрой. */
export interface InteractSink {
    hasItem(id: string): boolean;
    give(id: string, count: number): void;
    showToast(text: string): void;
    /** Сюжетный эффект реакции (custom): сцена запускает событие. */
    onCustom?(name: string): void;
    /**
     * Одноразовый объект «поднят» (once, реакция ok): сцена убирает проп
     * с поля. Вызывается после apply.
     */
    onConsumed?(def: InteractableDef, response: InteractResponse): void;
}

export class Interactables {
    constructor(
        private defs: InteractableDef[],
        private state: GameState,
        private sink: InteractSink,
    ) {}

    /** Объект на тайле (или null). */
    defAt(x: number, y: number): InteractableDef | null {
        return this.defs.find((d) => d.tile.x === x && d.tile.y === y) ?? null;
    }

    /** Одноразовый объект уже использован (флаг used:<id>). */
    isUsed(id: string): boolean {
        return this.state.hasFlag(usedFlag(id));
    }

    /** Взаимодействие: выбор реакции + применение эффектов. */
    tryInteract(def: InteractableDef): InteractPick {
        const ctx: InteractCtx = {
            hasFlag: (f) => this.state.hasFlag(f),
            hasItem: (i) => this.sink.hasItem(i),
            getVar: (id) => this.state.getNumber(id),
            used: (id) => this.isUsed(id),
        };
        const pick = resolveInteractable(def, ctx);
        if (pick?.ok) {
            this.apply(def, pick.response);
            if (def.once) this.sink.onConsumed?.(def, pick.response);
        }
        return pick;
    }

    /** Применить эффекты реакции (порядок: флаги → вар → предмет → тост). */
    private apply(def: InteractableDef, r: InteractResponse): void {
        for (const f of r.setFlags ?? []) this.state.setFlag(f);
        for (const f of r.clearFlags ?? []) this.state.clearFlag(f);
        if (r.setVar) this.state.setVar(r.setVar.id, r.setVar.value);
        if (r.addVar) this.state.setVar(r.addVar.id, this.state.getNumber(r.addVar.id) + r.addVar.by);
        if (r.gives) this.sink.give(r.gives, r.count ?? 1);
        if (def.once) this.state.setFlag(usedFlag(def.id));
        if (r.custom) this.sink.onCustom?.(r.custom);
        if (r.toast) {
            // Тост с прогрессом (как у тонов): «Тон (2/3)».
            const n = r.toast.varKey !== undefined ? this.state.getNumber(r.toast.varKey) : null;
            const progress =
                n !== null && r.toast.goal !== undefined ? ` (${Math.min(n, r.toast.goal)}/${r.toast.goal})` : '';
            this.sink.showToast(r.toast.text + progress);
        } else {
            this.sink.showToast(r.text);
        }
    }
}