/**
* Катсцены как данные: жанронезависимый рантайм (третий повтор паттерна
* «стек шагов» в игре — поднят в ядро). Шаг {dur, fn?, cam?}: dur — секунды
* ручного времени цикла (детерминизм гейтов), fn — разовое действие в
* начале шага, cam — цель камеры (глайд от точки старта шага). Рантайм
* про рендер не знает: камера — инъекция (get/set), мир снаружи замер
* (update звать вместо тика мира); по окончании снаружи возвращает камеру
* к герою (политика игры, не движка).
*/
/** Шаг катсцены: длительность, действие на старте, цель камеры. */
export interface CutsceneStep {
/** Длительность шага (сек ручного времени цикла). */
dur: number;
/** Разовое действие в начале шага. */
fn?: () => void;
/** Цель камеры (мир): глайд от точки старта шага. */
cam?: readonly [number, number, number];
}
/** Снапшот для моста: играет ли, шаг, таймер (числовой дебаг сцен). */
export interface CutsceneSnapshot {
active: boolean;
/** Индекс шага (0-базный) и общее число шагов (0 вне катсцены). */
step: number;
of: number;
/** Время внутри шага (сек). */
t: number;
}
/** Камера-носитель: точка старта глайда и установка цели. */
export interface CutsceneCamera {
get(): readonly [number, number, number];
set(p: readonly [number, number, number]): void;
}
export class CutsceneRunner {
private steps: readonly CutsceneStep[] = [];
private i = 0;
private t = 0;
/** Откуда глайдит камера (позиция на старте шага). */
private from: readonly [number, number, number] | null = null;
constructor(private readonly camera: CutsceneCamera) {}
/** Играет ли катсцена (пустой стек = нет). */
get active(): boolean {
return this.i < this.steps.length;
}
/** Суммарная длительность текущей катсцены (гейтам: сколько тиков ждать). */
get duration(): number {
return this.steps.reduce((s, st) => s + st.dur, 0);
}
/** Начать катсцену: первый шаг стартует сразу; прерывает играющую. */
start(steps: readonly CutsceneStep[]): void {
this.abort();
this.steps = steps;
this.i = 0;
this.t = 0;
this.from = null;
steps[0]?.fn?.(); // действие первого шага — сразу (посадка/тост)
}
/** Снять играющую катсцену (прерывание: переход области, новый диалог). */
abort(): void {
this.steps = [];
this.i = 0;
this.t = 0;
this.from = null;
}
/** Тик: таймер шага + глайд камеры; true — ещё играет. Глайд по времени
* ПОСЛЕ инкремента — на последнем тике шага камера достигает цели
* точно (k = 1). Действие шага может перезапустить катсцену
* (start/abort) — тогда этот тик уже принадлежит новой сцене. */
update(dt: number): boolean {
if (!this.active) return false;
const token = this.steps;
const step = this.steps[this.i]!;
this.t += dt;
if (step.cam) {
if (!this.from) this.from = this.camera.get();
const f = this.from;
const s = step.cam;
const k = Math.min(1, this.t / step.dur);
this.camera.set([f[0] + (s[0] - f[0]) * k, f[1] + (s[1] - f[1]) * k, f[2] + (s[2] - f[2]) * k]);
}
if (this.t < step.dur) return true;
this.i += 1;
this.t = 0;
this.from = null;
const next = this.steps[this.i];
if (next) next.fn?.();
else this.steps = []; // конец: active = false
return this.steps === token && this.active;
}
/** Снапшот для моста/гейтов (числовой). */
info(): CutsceneSnapshot {
return this.active
? { active: true, step: this.i, of: this.steps.length, t: this.t }
: { active: false, step: 0, of: 0, t: 0 };
}
}
/** Валидация каталога катсцен агентом (как validateDialogue): пустых нет,
* dur конечен и > 0, cam — конечная тройка. Возвращает список ошибок. */
export function validateCutscenes(scenes: Record<string, readonly CutsceneStep[]>): string[] {
const errs: string[] = [];
for (const [id, steps] of Object.entries(scenes)) {
if (!steps.length) errs.push(`${id}: катсцена пуста`);
steps.forEach((s, i) => {
if (!Number.isFinite(s.dur) || s.dur <= 0) errs.push(`${id}[${i}]: dur ${s.dur} — нужен конечный > 0`);
if (s.cam && s.cam.some((v) => !Number.isFinite(v))) errs.push(`${id}[${i}]: cam не конечная тройка`);
});
}
return errs;
}