diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 8ea3483..6998ddb 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -157,7 +157,7 @@ }, 'scene:skipCutscene': (d) => { if (!d.cutscene.active) return false; - while (d.cutscene.active) d.cutscene.update(0.5); + d.cutscene.skip(); // until форсируются — скип конечен даже на шаге-ожидании return true; }, 'scene:synthesize': (d, a) => { diff --git a/docs/engine/cutscene.md b/docs/engine/cutscene.md index b171860..077ccc0 100644 --- a/docs/engine/cutscene.md +++ b/docs/engine/cutscene.md @@ -17,6 +17,9 @@ // Плавное перемещение камеры (в мировых юнитах) к точке: { kind: 'cameraMove', x: tower.x, y: tower.y, seconds: 1.2, camera: scene.camera }, + // Плавное перемещение вьюхи (актёра, объекта) в локальных координатах родителя: + { kind: 'moveView', view: milaView, x: exitPx.x, y: exitPx.y, seconds: 2 }, + // Разовый эффект (звук, кольцо резонанса, вспышка частиц): { kind: 'burst', fn: () => scene.ringAt(tower), seconds: 1 }, @@ -51,8 +54,20 @@ - **Пауза геймплея** — ответственность сцены: в `update` проверять `runner.active`, обновлять раннер и выходить (см. `LocationScene.update`). Исполнитель шага `until` (например, ходок актёра) должен тикаться сценой независимо от раннера. -- **Движение камеры** — по `sineInOut`; точка назначения достигается точно. - Интерполяция — чистая `applyEase(from, to, k, ease)` из `cutscene/tween`. +- **Движение камеры и вьюх** — по `sineInOut` (у `moveView` можно свой `ease`); + точка назначения достигается точно. Интерполяция — чистая + `applyEase(from, to, k, ease)` из `cutscene/tween`. Координаты `moveView` — + **локальные px родителя вьюхи**: для актёра в `worldRoot` сцена сама переводит + мировые координаты (`worldToScreen`). Движок не знает ни Pixi, ни актёров — + шагу подходит любой объект с `position: {x, y}` (`Movable`). + +## Скип + +`runner.skip()` дожимает сцену до конца немедленно: шаги-движения ставят вьюхи/камеру +в конечные точки, `until` **форсируются** (скип конечен даже на шаге-ожидании), все +`call`/`burst` выполняются. Агентный мост (`scene:skipCutscene`) пользуется именно им. +Следствие: `until` в катсцене должен быть достижим при обычном проигрывании — скип +не обязан ждать реального события, он его имитирует. ## Наблюдение за шагами diff --git a/packages/engine/src/cutscene/CutsceneRunner.ts b/packages/engine/src/cutscene/CutsceneRunner.ts index 4fdb80e..73443aa 100644 --- a/packages/engine/src/cutscene/CutsceneRunner.ts +++ b/packages/engine/src/cutscene/CutsceneRunner.ts @@ -11,6 +11,11 @@ * Сцена на время сцены ставит геймплей на паузу — раннер только сообщает `active`. */ +/** Всё, что можно двигать шагом moveView: структурно — любой Container. */ +export interface Movable { + position: Vec2; +} + export type CutsceneStep = /** Подождать N секунд (ничего не делая). */ | { kind: 'wait'; seconds: number } @@ -21,6 +26,8 @@ | { 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 } /** Разовый эффект (звук, кольцо, вспышка частиц). */ @@ -34,6 +41,8 @@ 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 }; } /** Плавность движения камеры по умолчанию. */ @@ -43,6 +52,8 @@ private queue: CutsceneStep[] = []; private current: RunningStep | null = null; private t = 0; + /** Внутри skip(): until форсируются, сцена обязана дойти до конца. */ + private skipping = false; /** Вызывается, когда все шаги дошли до конца. */ onComplete: (() => void) | null = null; @@ -99,6 +110,17 @@ } }; 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 }; @@ -118,7 +140,8 @@ this.t += dt; const current = this.current; // Ожидание события: предикат сработал — шаг кончен (timeout ловит «>=» ниже). - if (current.until && current.until.predicate()) { + // Скип форсирует любые until: катсцена обязана кончиться. + if (current.until && (this.skipping || current.until.predicate())) { this.t = 0; this.next(); return; @@ -129,9 +152,27 @@ 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; + } } \ No newline at end of file diff --git a/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts b/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts index 11daae7..85adaaf 100644 --- a/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts +++ b/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts @@ -129,6 +129,59 @@ expect(runner.active).toBe(false); expect(done).toBe(1); }); + + it('moveView плавно ведёт вьюху в локальных координатах родителя', () => { + const runner = new CutsceneRunner(); + const view = { position: { x: 0, y: 0 } }; + runner.play([{ kind: 'moveView', view, x: 40, y: -10, seconds: 1 }]); + tick(runner, 0.25, 2); // половина пути + expect(view.position.x).toBeGreaterThan(0); + expect(view.position.x).toBeLessThan(40); + expect(view.position.y).toBeLessThan(0); + tick(runner, 0.25, 3); // время вышло — точно в точке + expect(view.position.x).toBe(40); + expect(view.position.y).toBe(-10); + expect(runner.active).toBe(false); + }); + + it('moveView с кастомным ease', () => { + const runner = new CutsceneRunner(); + const view = { position: { x: 0, y: 0 } }; + runner.play([{ kind: 'moveView', view, x: 10, y: 0, seconds: 1, ease: (t) => t * t }]); + runner.update(0.5); + expect(view.position.x).toBeCloseTo(2.5); // quadIn(0.5) = 0.25 + }); + + it('skip дожимает сцену до конца: движение — в конечные точки', () => { + const runner = new CutsceneRunner(); + const cam = cameraAt(0, 0); + const view = { position: { x: 0, y: 0 } }; + const order: string[] = []; + runner.onComplete = () => order.push('done'); + runner.play([ + { kind: 'moveView', view, x: 12, y: 6, seconds: 5 }, + { kind: 'cameraMove', x: 3, y: 3, seconds: 5, camera: cam }, + { kind: 'call', fn: () => order.push('call') } + ]); + runner.skip(); + expect(order).toEqual(['call', 'done']); + expect(view.position.x).toBe(12); + expect(view.position.y).toBe(6); + expect(cam.x).toBe(3); + expect(cam.y).toBe(3); + expect(runner.active).toBe(false); + }); + + it('skip форсирует until (иначе скип висел бы навсегда)', () => { + const runner = new CutsceneRunner(); + runner.play([ + { kind: 'call', fn: () => undefined }, + { kind: 'until', predicate: () => false }, // событие никогда не наступит + { kind: 'call', fn: () => undefined, seconds: 0.2 } + ]); + runner.skip(); + expect(runner.active).toBe(false); + }); }); describe('tween — applyEase', () => { diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 47f46be..a32a22e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -58,7 +58,7 @@ type InventoryChangeKind } from './inventory/Inventory'; // cutscene -export { CutsceneRunner, type CutsceneStep } from './cutscene/CutsceneRunner'; +export { CutsceneRunner, type CutsceneStep, type Movable } from './cutscene/CutsceneRunner'; export { applyEase } from './cutscene/tween'; // scene