import {
findPath,
tileToWorld,
worldToScreen,
worldToTile,
type Container,
type CutsceneStep,
type IsometricTileMap,
type Vec2
} from '@rpg/engine';
/**
* Актёры кат-сцен: ходок по A*-пути для вьюхи NPC и фабрики шагов.
* Мир на время сцены на паузе — ходок без боя/расталкивания, тикается
* сценой независимо от раннера (исполнитель шага until).
*/
/** Скорость ходьбы актёра по умолчанию (юниты/сек) — чуть медленнее героя. */
const WALKER_SPEED = 1.4;
/** Таймаут ожидания ходьбы по умолчанию (сек) — страховка скипа/зависания. */
const WALK_TIMEOUT = 30;
/**
* Ходок: ведёт вьюху по пути A* в мировых юнитах, синхронизирует вью
* (округление до px — единственная точка мир→экран, как у героя).
*/
export class ActorWalker {
private pos: Vec2;
private waypoints: Vec2[] = [];
constructor(
private map: IsometricTileMap,
private view: Container,
startTile: { x: number; y: number },
/** Смена тайла (для depth-слоя сцены). */
private onTile?: (tile: Vec2) => void,
private speed = WALKER_SPEED
) {
this.pos = tileToWorld(startTile.x, startTile.y);
}
/** Идёт ли сейчас (шаг until должен ждать). */
get done(): boolean {
return this.waypoints.length === 0;
}
get position(): Vec2 {
return { x: this.pos.x, y: this.pos.y };
}
/** Построить путь к тайлу и пойти. Непроходимый тайл — остаётся на месте. */
walkTo(tile: { x: number; y: number }): void {
const from = worldToTileClamped(this.pos, this.map);
const path = findPath(this.map, from, tile, false);
this.waypoints = path ? path.map((t) => tileToWorld(t.x, t.y)) : [];
}
update(dt: number): void {
if (this.waypoints.length === 0) return;
const target = this.waypoints[0]!;
const dx = target.x - this.pos.x;
const dy = target.y - this.pos.y;
const dist = Math.hypot(dx, dy);
const step = this.speed * dt;
let crossedTile = false;
if (dist <= step) {
this.pos = { x: target.x, y: target.y };
this.waypoints.shift();
crossedTile = true;
} else {
this.pos = { x: this.pos.x + (dx / dist) * step, y: this.pos.y + (dy / dist) * step };
}
this.syncView();
if (crossedTile) this.onTile?.(worldToTileClamped(this.pos, this.map));
}
/** Вью-позиция: округление до px (вьюха лежит в worldRoot — мировые px). */
private syncView(): void {
const s = worldToScreen(this.pos.x, this.pos.y);
this.view.position.set(Math.round(s.x), Math.round(s.y));
}
}
/** Тайл под позицией (вне карты — ближайший краевой, как у героя). */
function worldToTileClamped(pos: Vec2, map: IsometricTileMap): Vec2 {
const w = map.data.width;
const h = map.data.height;
const t = worldToTile(pos.x, pos.y, w, h);
return (
t ?? {
x: Math.min(w - 1, Math.max(0, Math.floor(pos.x))),
y: Math.min(h - 1, Math.max(0, Math.floor(pos.y)))
}
);
}
/** Шаги «актёр идёт к тайлу»: запуск + ожидание прихода (со страховкой). */
export function npcWalkSteps(walker: ActorWalker, tile: Vec2, timeout = WALK_TIMEOUT): CutsceneStep[] {
return [
{ kind: 'call', fn: () => walker.walkTo(tile) },
{ kind: 'until', predicate: () => walker.done, label: `актёр к ${tile.x},${tile.y}`, timeout }
];
}
/** Шаг «вьюха плавно уходит в мировую точку» (глайд без пути — по прямой). */
export function npcMoveView(view: Container, toWorld: Vec2, seconds: number): CutsceneStep {
const p = worldToScreen(toWorld.x, toWorld.y);
return { kind: 'moveView', view, x: p.x, y: p.y, seconds };
}