Newer
Older
rpg / packages / engine / src / cutscene / __tests__ / CutsceneRunner.test.ts
import { describe, expect, it } from 'vitest';
import { GameState } from '../../core/GameState';
import { linear } from '../../core/easing';
import { Camera } from '../../render/Camera';
import { CutsceneRunner } from '../CutsceneRunner';
import { applyEase } from '../tween';

/** Продвинуть раннер: dt N раз. */
function tick(runner: CutsceneRunner, dt: number, times: number): void {
    for (let i = 0; i < times; i++) runner.update(dt);
}

/** Фиктивный «взгляд»: камера с заданным центром. */
function cameraAt(x: number, y: number): Camera {
    const cam = new Camera(480, 270);
    cam.x = x;
    cam.y = y;
    return cam;
}

describe('CutsceneRunner — последовательность шагов', () => {
    it('шаги выполняются по порядку, wait держит паузу', () => {
        const runner = new CutsceneRunner();
        const order: string[] = [];
        runner.play([
            { kind: 'call', fn: () => order.push('a') },
            { kind: 'wait', seconds: 0.5 },
            { kind: 'call', fn: () => order.push('b') }
        ]);
        // Первый шаг стартовал сразу, второй ещё ждёт.
        expect(order).toEqual(['a']);
        expect(runner.active).toBe(true);
        tick(runner, 0.1, 4); // 0.4с — всё ещё wait
        expect(order).toEqual(['a']);
        tick(runner, 0.2, 1); // 0.6с — wait кончился
        expect(order).toEqual(['a', 'b']);
        expect(runner.active).toBe(false);
    });

    it('шаг без seconds параллелен: следующий стартует сразу', () => {
        const runner = new CutsceneRunner();
        const order: string[] = [];
        runner.play([
            { kind: 'burst', fn: () => order.push('burst') },
            { kind: 'call', fn: () => order.push('call') }
        ]);
        // Шаги без длительности не блокируют: оба стартовали в том же тике.
        expect(order).toEqual(['burst', 'call']);
        expect(runner.active).toBe(false);
    });

    it('cameraMove плавно ведёт камеру к точке', () => {
        const runner = new CutsceneRunner();
        const cam = cameraAt(0, 0);
        runner.play([{ kind: 'cameraMove', x: 10, y: -20, seconds: 1, camera: cam }]);
        tick(runner, 0.25, 2); // половина пути
        expect(cam.x).toBeGreaterThan(0);
        expect(cam.x).toBeLessThan(10);
        expect(cam.y).toBeLessThan(0);
        tick(runner, 0.25, 3); // время вышло — точно в точке
        expect(cam.x).toBe(10);
        expect(cam.y).toBe(-20);
        expect(runner.active).toBe(false);
    });

    it('setFlag поднимает флаги, onComplete вызывается в конце', () => {
        const runner = new CutsceneRunner();
        const state = new GameState();
        let done = 0;
        runner.onComplete = () => done++;
        runner.play([
            { kind: 'setFlag', flag: 'flag1', state },
            { kind: 'setFlag', flag: 'flag2', state }
        ]);
        expect(state.hasFlag('flag1')).toBe(true);
        expect(state.hasFlag('flag2')).toBe(true);
        expect(done).toBe(1);
    });

    it('onStep наблюдает порядок шагов', () => {
        const seen: string[] = [];
        const runner = new CutsceneRunner((s) => seen.push(s.kind));
        runner.play([
            { kind: 'call', fn: () => undefined, seconds: 0.1 },
            { kind: 'wait', seconds: 0.1 }
        ]);
        // Первый шаг стартовал; второй — после его длительности.
        expect(seen).toEqual(['call']);
        tick(runner, 0.2, 1);
        expect(seen).toEqual(['call', 'wait']);
    });

    it('until ждёт внешнее событие и завершается по предикату', () => {
        const runner = new CutsceneRunner();
        const order: string[] = [];
        let ready = false;
        runner.play([
            { kind: 'until', predicate: () => ready, label: 'событие' },
            { kind: 'call', fn: () => order.push('after') }
        ]);
        tick(runner, 0.1, 5); // событие не наступило — стоим на месте
        expect(order).toEqual([]);
        expect(runner.active).toBe(true);
        ready = true;
        runner.update(0.1);
        expect(order).toEqual(['after']);
        expect(runner.active).toBe(false);
    });

    it('until с уже выполненным предикатом не блокирует', () => {
        const runner = new CutsceneRunner();
        const order: string[] = [];
        runner.play([
            { kind: 'until', predicate: () => true },
            { kind: 'call', fn: () => order.push('next') }
        ]);
        expect(order).toEqual(['next']);
        expect(runner.active).toBe(false);
    });

    it('until с timeout завершается принудительно по истечении', () => {
        const runner = new CutsceneRunner();
        let done = 0;
        runner.onComplete = () => done++;
        runner.play([{ kind: 'until', predicate: () => false, timeout: 0.3 }]);
        tick(runner, 0.1, 2); // 0.2с — ещё ждём
        expect(runner.active).toBe(true);
        tick(runner, 0.1, 1); // 0.3с — timeout
        expect(runner.active).toBe(false);
        expect(done).toBe(1);
    });
});

describe('tween — applyEase', () => {
    it('интерполирует точку по кривой easing', () => {
        const from = { x: 0, y: 0 };
        const to = { x: 10, y: -20 };
        const mid = applyEase(from, to, 0.5, linear);
        expect(mid.x).toBeCloseTo(5);
        expect(mid.y).toBeCloseTo(-10);
    });

    it('клэмпит k за пределы 0..1', () => {
        const end = applyEase({ x: 0, y: 0 }, { x: 8, y: 4 }, 2, linear);
        expect(end.x).toBe(8);
        expect(end.y).toBe(4);
        const start = applyEase({ x: 0, y: 0 }, { x: 8, y: 4 }, -1, linear);
        expect(start.x).toBe(0);
    });
});