Newer
Older
rpg / packages / engine / src / cutscene / CutsceneRunner.ts
import { sineInOut, type EaseFn } from '../core/easing';
import { GameState } from '../core/GameState';
import type { Camera } from '../render/Camera';
import type { Vec2 } from '../math/Vec2';
import { applyEase } from './tween';

/**
 * Раннер кат-сцен: view-агностичная последовательность шагов.
 * Каждый шаг при старте выполняет свой эффект; раннер ждёт его длительность
 * (0 — шаг «параллельный», следующий стартует сразу) и переходит к следующему.
 * Сцена на время сцены ставит геймплей на паузу — раннер только сообщает `active`.
 */

/** Всё, что можно двигать шагом moveView: структурно — любой Container. */
export interface Movable {
    position: Vec2;
}

export type CutsceneStep =
    /** Подождать N секунд (ничего не делая). */
    | { kind: 'wait'; seconds: number }
    /** Ждать внешнее событие (диалог, приход актёра): пока predicate() не true.
     *  timeout — принудительное завершение шага (сек); без него шаг может
     *  длиться вечно — скип/агентный мост на таком шаге не повиснет только
     *  если событие реально наступит. */
    | { kind: 'until'; predicate: () => boolean; label?: string; timeout?: number }
    /** Плавно переместить камеру (в юнитах) к точке. */
    | { kind: 'cameraMove'; x: number; y: number; seconds: number; camera: Camera }
    /** Плавно переместить вьюху (актёра, объект) в локальных координатах её родителя. */
    | { kind: 'moveView'; view: Movable; x: number; y: number; seconds: number; ease?: EaseFn }
    /** Поднять флаг прохождения (мгновенно). */
    | { kind: 'setFlag'; flag: string; state: GameState }
    /** Разовый эффект (звук, кольцо, вспышка частиц). */
    | { kind: 'burst'; fn: () => void; seconds?: number }
    /** Произвольный вызов (диалог, посадка, тост и т.п.). */
    | { kind: 'call'; fn: () => void; seconds?: number };

interface RunningStep {
    seconds: number;
    /** Ожидание внешнего события: шаг кончается, когда предикат стал true. */
    until?: { predicate: () => boolean };
    /** Движение камеры: from -> to по easing. */
    cameraMove?: { from: Vec2; to: Vec2; camera: Camera };
    /** Движение вьюхи: from -> to по easing. */
    moveView?: { from: Vec2; to: Vec2; view: Movable; ease: EaseFn };
}

/** Плавность движения камеры по умолчанию. */
const EASE: EaseFn = sineInOut;

export class CutsceneRunner {
    private queue: CutsceneStep[] = [];
    private current: RunningStep | null = null;
    private t = 0;
    /** Внутри skip(): until форсируются, сцена обязана дойти до конца. */
    private skipping = false;
    /** Вызывается, когда все шаги дошли до конца. */
    onComplete: (() => void) | null = null;

    constructor(
        /** Наблюдатель: вызывается при старте каждого шага (лог, отладка). */
        private onStep?: (step: CutsceneStep) => void
    ) {}

    /** Запустить последовательность шагов (немедленно). */
    play(steps: CutsceneStep[]): void {
        this.queue = [...steps];
        this.t = 0;
        this.next();
    }

    /** Идёт ли сейчас сцена (геймплей на паузе). */
    get active(): boolean {
        return this.current !== null;
    }

    /** Секунд прошло с начала текущего шага. */
    get time(): number {
        return this.t;
    }

    private next(): void {
        const step = this.queue.shift();
        if (!step) {
            this.current = null;
            this.onComplete?.();
            return;
        }
        this.onStep?.(step);
        switch (step.kind) {
            case 'wait':
                this.current = { seconds: step.seconds };
                break;
            case 'until': {
                // Предикат уже выполнен — шаг не блокирует (секунды 0 → сразу дальше).
                const done = step.predicate();
                this.current = {
                    seconds: done ? 0 : (step.timeout ?? Infinity),
                    until: done ? undefined : { predicate: step.predicate }
                };
                break;
            }
            case 'cameraMove':
                this.current = {
                    seconds: step.seconds,
                    cameraMove: {
                        from: { x: step.camera.x, y: step.camera.y },
                        to: { x: step.x, y: step.y },
                        camera: step.camera
                    }
                };
                break;
            case 'moveView':
                this.current = {
                    seconds: step.seconds,
                    moveView: {
                        from: { x: step.view.position.x, y: step.view.position.y },
                        to: { x: step.x, y: step.y },
                        view: step.view,
                        ease: step.ease ?? EASE
                    }
                };
                break;
            case 'setFlag':
                step.state.setFlag(step.flag);
                this.current = { seconds: 0 };
                break;
            case 'burst':
            case 'call':
                step.fn();
                this.current = { seconds: step.seconds ?? 0 };
                break;
        }
        // Параллельный шаг (0 секунд) — сразу к следующему.
        if (this.current.seconds <= 0) this.next();
    }

    update(dt: number): void {
        if (!this.current) return;
        this.t += dt;
        const current = this.current;
        // Ожидание события: предикат сработал — шаг кончен (timeout ловит «>=» ниже).
        // Скип форсирует любые until: катсцена обязана кончиться.
        if (current.until && (this.skipping || current.until.predicate())) {
            this.t = 0;
            this.next();
            return;
        }
        const move = current.cameraMove;
        if (move) {
            const p = applyEase(move.from, move.to, this.t / current.seconds, EASE);
            move.camera.x = p.x;
            move.camera.y = p.y;
        }
        const viewMove = current.moveView;
        if (viewMove) {
            const p = applyEase(viewMove.from, viewMove.to, this.t / current.seconds, viewMove.ease);
            viewMove.view.position.x = p.x;
            viewMove.view.position.y = p.y;
        }
        if (this.t >= current.seconds) {
            this.t = 0;
            this.next();
        }
    }

    /** Дожать сцену до конца немедленно: until форсируются, движение — в конечные точки. */
    skip(): void {
        if (!this.current) return;
        this.skipping = true;
        // Лимит — защита от шага, который при скипе порождает шаги бесконечно.
        for (let i = 0; i < 1000 && this.current; i++) {
            const remain = this.current.until ? 0 : this.current.seconds - this.t;
            this.update(Math.max(0, remain) + 0.001);
        }
        this.skipping = false;
    }
}