diff --git a/docs/engine/cutscene.md b/docs/engine/cutscene.md index 42b46a4..b171860 100644 --- a/docs/engine/cutscene.md +++ b/docs/engine/cutscene.md @@ -25,6 +25,9 @@ // Просто пауза: { kind: 'wait', seconds: 0.5 }, + + // Ждать внешнее событие (закрылся диалог, актёр дошёл, таймер игры): + { kind: 'until', predicate: () => !scene.dialogueActive, label: 'диалог' }, ]; const runner = new CutsceneRunner(); @@ -39,9 +42,17 @@ - **Параллельные шаги.** Шаг с `seconds: 0` (или `undefined` у `burst`/`call`) не блокирует: следующий стартует в том же тике. Так делают «проверку воздуха» — кольцо и звук в один кадр с продолжением сцены. +- **Ожидание события.** `until` держит шаг, пока `predicate()` не вернёт true + (проверка каждый тик; уже выполненный предикат шаг не блокирует). Опциональный + `timeout` (сек) завершает шаг принудительно — без него шаг может длиться вечно, + поэтому `until` без timeout ставьте только на события, которые гарантированно + наступят (закрытие диалога, приход актёра). Типовая пара «диалог посреди сцены»: + `{ kind: 'call', fn: () => scene.talkTo(npc) }` + `{ kind: 'until', predicate: () => !scene.dialogueActive }`. - **Пауза геймплея** — ответственность сцены: в `update` проверять `runner.active`, - обновлять раннер и выходить (см. `LocationScene.update`). + обновлять раннер и выходить (см. `LocationScene.update`). Исполнитель шага + `until` (например, ходок актёра) должен тикаться сценой независимо от раннера. - **Движение камеры** — по `sineInOut`; точка назначения достигается точно. + Интерполяция — чистая `applyEase(from, to, k, ease)` из `cutscene/tween`. ## Наблюдение за шагами diff --git a/packages/engine/src/cutscene/CutsceneRunner.ts b/packages/engine/src/cutscene/CutsceneRunner.ts index 7879d38..4fdb80e 100644 --- a/packages/engine/src/cutscene/CutsceneRunner.ts +++ b/packages/engine/src/cutscene/CutsceneRunner.ts @@ -2,6 +2,7 @@ import { GameState } from '../core/GameState'; import type { Camera } from '../render/Camera'; import type { Vec2 } from '../math/Vec2'; +import { applyEase } from './tween'; /** * Раннер кат-сцен: view-агностичная последовательность шагов. @@ -13,6 +14,11 @@ 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 } /** Поднять флаг прохождения (мгновенно). */ @@ -24,6 +30,8 @@ interface RunningStep { seconds: number; + /** Ожидание внешнего события: шаг кончается, когда предикат стал true. */ + until?: { predicate: () => boolean }; /** Движение камеры: from -> to по easing. */ cameraMove?: { from: Vec2; to: Vec2; camera: Camera }; } @@ -72,6 +80,15 @@ 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, @@ -99,14 +116,20 @@ update(dt: number): void { if (!this.current) return; this.t += dt; - const move = this.current.cameraMove; - if (move) { - const k = Math.min(1, this.t / this.current.seconds); - const e = EASE(k); - move.camera.x = move.from.x + (move.to.x - move.from.x) * e; - move.camera.y = move.from.y + (move.to.y - move.from.y) * e; + const current = this.current; + // Ожидание события: предикат сработал — шаг кончен (timeout ловит «>=» ниже). + if (current.until && current.until.predicate()) { + this.t = 0; + this.next(); + return; } - if (this.t >= this.current.seconds) { + 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; + } + if (this.t >= current.seconds) { this.t = 0; this.next(); } diff --git a/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts b/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts index b0e4618..11daae7 100644 --- a/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts +++ b/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts @@ -1,7 +1,9 @@ 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 { @@ -87,4 +89,62 @@ 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); + }); }); \ No newline at end of file diff --git a/packages/engine/src/cutscene/tween.ts b/packages/engine/src/cutscene/tween.ts new file mode 100644 index 0000000..baba792 --- /dev/null +++ b/packages/engine/src/cutscene/tween.ts @@ -0,0 +1,13 @@ +import type { EaseFn } from '../core/easing'; +import type { Vec2 } from '../math/Vec2'; + +/** + * Чистая математика перемещений кат-сцен: интерполяция точки по easing. + * Общая для cameraMove и moveView — движение камеры и вьюх считается одинаково. + */ + +/** Точка на кривой from→to в доле k (0..1), прошедшей через ease. */ +export function applyEase(from: Vec2, to: Vec2, k: number, ease: EaseFn): Vec2 { + const e = ease(Math.min(1, Math.max(0, k))); + return { x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e }; +} \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index d66fe78..47f46be 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -59,6 +59,7 @@ } from './inventory/Inventory'; // cutscene export { CutsceneRunner, type CutsceneStep } from './cutscene/CutsceneRunner'; +export { applyEase } from './cutscene/tween'; // scene export { SceneManager, type Scene, type SceneTransition } from './scene/SceneManager';