import type {
AgentHost,
EngineSnapshot,
Invariant,
SceneAgent,
SnapshotLayer,
WaitForOptions
} from './types';
import { mergeInvariants } from './invariants';
/**
* Движковый агентный мост: снапшоты, детерминированные шаги, инъекция ввода.
* Никогда не бросает: каждый доступ — через safe() с try/catch, ошибка
* становится слоем снапшота `{ error }`.
*/
export class EngineAgent {
constructor(
private host: AgentHost,
/** Фабрика сцены-агента: сцена может отсутствовать (меню, переход). */
private sceneAgent: () => SceneAgent | null
) {}
/** Полный снапшот: движковый слой + контентный слой текущей сцены. */
snapshot(): SnapshotLayer {
const base = this.safe('snapshot', (): EngineSnapshot => {
const h = this.host;
return {
tick: h.tickCount,
fps: h.fixedStep > 0 ? Math.round((1 / h.fixedStep) * 100) / 100 : 0,
fixedStep: h.fixedStep,
scenes: [h.scenes.current?.constructor.name ?? null].filter(
(s): s is string => s !== null
),
transitioning: h.scenes.transitioning,
camera: {
x: num(h.camera.x),
y: num(h.camera.y),
shaking: h.camera.shaking
},
pointer: this.pointerLayer()
};
}) as SnapshotLayer;
const scene = this.sceneAgent();
if (scene) {
const layer = this.safe('scene.snapshot', () => scene.agentSnapshot());
if (layer) Object.assign(base, layer);
}
return base;
}
/** Нарушенные инварианты: движковые + сценические. */
invariants(): Invariant[] {
const engine: Invariant[] = this.safe('invariants', () => {
const cam = this.host.camera;
return mergeInvariants(
checkNum('camera.x', cam.x, 'engine/camera'),
checkNum('camera.y', cam.y, 'engine/camera'),
this.pointerInvariants()
);
}) ?? [];
const scene = this.sceneAgent();
const sceneInvs = scene
? (this.safe('scene.invariants', () => scene.agentInvariants()) ?? [])
: [];
return [...engine, ...(Array.isArray(sceneInvs) ? sceneInvs : [])];
}
/** n фиксированных шагов без реального ожидания (детерминированно). */
step(n = 1, opts?: { render?: boolean }): { tick: number } {
const render = opts?.render ?? false;
// Серия целиком в ручном режиме: реальный rAF-цикл между шагами не
// тикает и не очищает «just pressed» инъекции середины серии.
try {
this.host.stepTicks(n, render);
} catch {
return { tick: this.host.tickCount };
}
return { tick: this.host.tickCount };
}
/**
* Крутить шаги, пока предикат не станет истинным (или лимит шагов).
* pred вызывается после каждого шага на полном снапшоте.
*/
async waitFor(
pred: (s: SnapshotLayer) => boolean,
opts?: WaitForOptions
): Promise<{ ok: boolean; snapshot: SnapshotLayer; ticks: number }> {
const limit = opts?.timeoutTicks ?? 600;
const render = opts?.render ?? false;
for (let i = 0; i < limit; i++) {
this.step(1, { render });
let s: SnapshotLayer;
try {
s = this.snapshot();
} catch {
continue;
}
let ok = false;
try {
ok = pred(s);
} catch {
ok = false;
}
if (ok) return { ok: true, snapshot: s, ticks: i + 1 };
}
return { ok: false, snapshot: this.snapshot(), ticks: limit };
}
/** Ввод в виртуальных пикселях: эквивалент pointerdown/up на канвасе. */
/**
* Клик в виртуальных пикселях. Инъекция и один шаг логики — атомарно
* (один JS-тик): между evaluate rAF-кадр успел бы очистить «just pressed».
*/
tapVirtual(vx: number, vy: number): void {
this.safe('tapVirtual', () => {
this.host.input.injectPointerDown(vx, vy);
this.host.input.injectPointerUp(vx, vy);
this.host.stepTick();
});
}
/** Действие через маппинг действий (isActionJustPressed внутри этого же тика). */
press(action: string, holdTicks = 1): void {
this.safe('press', () => {
this.host.input.injectAction(action);
this.host.stepTick(); // действие видно сцене в этом же тике
for (let i = 1; i < holdTicks; i++) this.host.stepTick();
this.host.input.injectActionRelease(action);
});
}
/** Сырой код клавиши (e.code) — атомарно с одним шагом логики. */
key(code: string): void {
this.safe('key', () => {
this.host.input.injectKeyCode(code);
this.host.stepTick();
this.host.input.injectKeyCodeUp(code);
});
}
/**
* Реальный DOM-клик по канвасу (виртуальные px → CSS): нужен для Pixi-кнопок,
* если сцена слушает DOM напрямую. В геймплее предпочитай tapVirtual/tapTile.
*/
uiTap(vx: number, vy: number): void {
this.safe('uiTap', () => {
const canvas = document.querySelector('canvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const sx = rect.left + (vx / this.host.virtualWidth) * rect.width;
const sy = rect.top + (vy / this.host.virtualHeight) * rect.height;
canvas.dispatchEvent(new MouseEvent('pointerdown', {
clientX: sx, clientY: sy, bubbles: true
}));
canvas.dispatchEvent(new MouseEvent('pointerup', {
clientX: sx, clientY: sy, bubbles: true
}));
});
}
/** Команда сцены (whitelist в реализации SceneAgent). */
command(name: string, args?: unknown): unknown {
const scene = this.sceneAgent();
if (!scene?.agentCommand) return null;
return this.safe('command', () => scene.agentCommand!(name, args as never)) ?? null;
}
// --- внутреннее ---
private pointerLayer(): SnapshotLayer {
const p = this.host.input.getPointer();
return {
x: num(p.x), y: num(p.y), down: p.down,
justPressed: p.justPressed, downTicks: p.downTicks
};
}
private pointerInvariants(): Invariant[] {
const p = this.host.input.getPointer();
return mergeInvariants(
checkNum('pointer.x', p.x, 'engine/pointer'),
checkNum('pointer.y', p.y, 'engine/pointer')
);
}
/** Выполнить fn, поймав исключение: ошибка -> слой { error }, а не бросок. */
private safe<T>(label: string, fn: () => T): T | null {
try {
return fn();
} catch (e) {
return { error: `${label}: ${e instanceof Error ? e.message : String(e)}` } as unknown as T;
}
}
}
function num(v: number): number {
return Number.isFinite(v) ? Math.round(v * 1000) / 1000 : String(v) as unknown as number;
}
function checkNum(name: string, v: number, where: string): Invariant | null {
if (!Number.isFinite(v)) {
return { id: 'pos-nan', severity: 'error', message: `${name} = ${v}`, where };
}
return null;
}