import {
IsometricTileMap,
findPath,
worldToScreen,
screenToWorld,
worldNorm,
worldToTile,
tileToWorld,
moveTowardsW,
moveCircle,
circleFits,
separateCircles,
SpriteAnimator,
Container,
Sprite,
Texture,
type Grid,
type SceneRegistry,
type Vec2
} from '@rpg/engine';
import { PLAYER_COMBAT } from './combat/stats';
/** Максимальный радиус тел, из которых выталкивается герой (NPC — 0.35, запас). */
const BODY_PROBE = 0.5;
/** Кадры героя по направлениям. side смотрит влево; вправо — флип. */
export interface HeroTextures {
down: [Texture, Texture];
up: [Texture, Texture];
side: [Texture, Texture];
}
type Facing = 'down' | 'up' | 'left' | 'right';
/**
* Управление героем: путь по клику (A*), плавное движение по маршруту,
* покадровая анимация ходьбы по направлению движения.
* Позиция — в мировых юнитах (1 юнит = 1 тайл, ноги героя).
*/
export class PlayerController {
readonly view: Container;
/** Именованные клипы: walk_* на ходу, idle_* — стоп-кадр направления. */
readonly animator: SpriteAnimator;
private sprite: Sprite;
private facing: Facing = 'down';
/** Позиция в мировых юнитах (источник истины, ноги героя). */
private pos: Vec2;
private path: { x: number; y: number }[] = [];
private waypoint: Vec2 | null = null;
/** Скорость в мировых юнитах/сек. */
private speed = 1.75;
/** Множитель скорости (зоны наката без маски — 0.5). Меняется сценой каждый кадр. */
speedMul = 1;
/** Реестр объектов сцены (тела NPC) — задаётся сценой после создания. */
private bodies: SceneRegistry | null = null;
constructor(
private map: IsometricTileMap,
textures: HeroTextures,
startTile: { x: number; y: number },
private onStep?: () => void
) {
this.view = new Container();
this.sprite = new Sprite(textures.down[0]);
this.sprite.anchor.set(0.5, 1); // ноги в точке позиции
this.view.addChild(this.sprite);
this.animator = new SpriteAnimator(this.sprite, {
walk_down: { frames: textures.down, fps: 6 },
walk_up: { frames: textures.up, fps: 6 },
walk_side: { frames: textures.side, fps: 6 },
idle_down: { frames: [textures.down[0]], loop: 'once' },
idle_up: { frames: [textures.up[0]], loop: 'once' },
idle_side: { frames: [textures.side[0]], loop: 'once' }
}, 'idle_down');
this.pos = tileToWorld(startTile.x, startTile.y);
this.syncView();
}
/** Текущее направление взгляда (для боя: куда бьёт конус). */
get dir(): 'down' | 'up' | 'left' | 'right' {
return this.facing;
}
/** Подключить реестр тел — герой не проходит сквозь NPC. */
setBodies(registry: SceneRegistry | null): void {
this.bodies = registry;
}
/** Grid путей (тайлы NPC заняты) — без него A* ведёт сквозь тело. */
private walkGrid: Grid | null = null;
setWalkGrid(grid: Grid | null): void {
this.walkGrid = grid;
}
/** Вытолкнуть себя из тел (каждый актор двигает только себя). */
private separateBodies(): void {
if (!this.bodies) return;
for (const hit of this.bodies.near(this.pos, PLAYER_COMBAT.radius + BODY_PROBE, 'npc')) {
separateCircles(this.pos, PLAYER_COMBAT.radius, hit.pos, hit.radius);
}
}
/** Вектор направления взгляда в мировых юнитах (экранные оси — мировые диагонали). */
get dirVector(): Vec2 {
switch (this.facing) {
case 'up':
return { x: -1, y: -1 };
case 'left':
return { x: -1, y: 1 };
case 'right':
return { x: 1, y: -1 };
default:
return { x: 1, y: 1 };
}
}
/** Кликом по миру (юниты) выбран тайл — строим путь A* и начинаем движение.
* Клик за краем карты ведёт к ближайшему краевому тайлу (камера прижата границей). */
onWorldClick(worldX: number, worldY: number): void {
const w = this.map.data.width;
const h = this.map.data.height;
const clicked = worldToTile(worldX, worldY, w, h);
const tile = clicked ?? {
x: Math.min(w - 1, Math.max(0, Math.floor(worldX))),
y: Math.min(h - 1, Math.max(0, Math.floor(worldY)))
};
const path = findPath(this.walkGrid ?? this.map, this.currentTile(), tile, false);
if (path && path.length > 0) {
this.followPath(path);
}
}
/** Пойти по готовому пути тайлов (например, к краю занятого NPC/объектом тайла). */
followPath(path: { x: number; y: number }[]): void {
if (path.length === 0) return;
this.path = path;
this.advanceWaypoint();
}
update(dt: number): void {
// Тела: даже стоячий герой выталкивается (враг мог втолкнуть его в NPC).
this.separateBodies();
if (!this.waypoint) {
this.playIdle();
return;
}
this.pos = moveTowardsW(this.pos, this.waypoint, this.speed * this.speedMul * dt);
if (this.pos.x === this.waypoint.x && this.pos.y === this.waypoint.y) {
this.onStep?.(); // ступили на новый тайл
this.advanceWaypoint();
this.syncView(); // вью на точном прибытии (иначе кадр отстаёт)
return;
}
this.faceMovement();
this.animator.resume();
this.animator.play(this.walkClip());
this.animator.update(dt);
this.syncView();
}
get moving(): boolean {
return this.waypoint !== null;
}
/** Текущий тайл героя. */
currentTile(): { x: number; y: number } {
return (
worldToTile(this.pos.x, this.pos.y, this.map.data.width, this.map.data.height) ?? { x: 0, y: 0 }
);
}
/** Прямое движение (тач-джойстик/стик): экранный вектор -1..1, движение с проверкой стен. */
moveFree(dir: Vec2, dt: number): void {
const len = Math.hypot(dir.x, dir.y);
if (len < 0.01) {
this.stop();
return;
}
// Экранная ось джойстика -> мировое направление (экранные оси — мировые диагонали).
const w = screenToWorld(dir.x, dir.y);
const n = worldNorm(w.x, w.y);
const step = this.speed * this.speedMul * dt;
// Круг тела скользит вдоль стен (оси раздельно).
const moved = moveCircle(this.map, this.pos, { x: n.x * step, y: n.y * step }, PLAYER_COMBAT.radius);
this.separateBodies();
if (moved) {
// Поворот — по экранным компонентам смещения (кадры down/up/side).
const proj = worldToScreen(n.x, n.y);
this.setFacing(
Math.abs(proj.x) >= Math.abs(proj.y) ? (proj.x >= 0 ? 'right' : 'left') : proj.y >= 0 ? 'down' : 'up'
);
this.animator.resume();
this.animator.play(this.walkClip());
this.animator.update(dt);
this.syncView();
} else {
this.stop();
}
}
/** Позиция героя (ноги) в мировых юнитах. */
get position(): Vec2 {
return { x: this.pos.x, y: this.pos.y };
}
/** Текущий кадр спрайта (для дебага анимации). */
get currentTexture(): Texture {
return this.sprite.texture;
}
/** Прервать текущий путь (например, при уроне). */
stop(): void {
this.path = [];
this.waypoint = null;
}
/** Имя клипа ходьбы по текущему направлению (side — и для right: флип). */
private walkClip(): string {
return `walk_${this.facing === 'left' || this.facing === 'right' ? 'side' : this.facing}`;
}
/** Стоп-кадр текущего направления (без «застыла на произвольном кадре ходьбы»). */
private playIdle(): void {
this.animator.play(`idle_${this.facing === 'left' || this.facing === 'right' ? 'side' : this.facing}`);
}
/** Мгновенно переместить героя в тайл (респаун, переходы между локациями). */
teleportTo(tile: { x: number; y: number }): void {
this.stop();
this.pos = tileToWorld(tile.x, tile.y);
this.syncView();
}
/** Отброс: сдвиг позиции, только если тело помещается (для боя), юниты. */
applyKnockback(dir: Vec2, dist: number): void {
const nx = this.pos.x + dir.x * dist;
const ny = this.pos.y + dir.y * dist;
if (circleFits(this.map, { x: nx, y: ny }, PLAYER_COMBAT.radius)) {
this.pos.x = nx;
this.pos.y = ny;
this.syncView();
}
this.stop();
}
/** Направление — по доминирующей экранной оси движения к следующей точке пути. */
private faceMovement(): void {
if (!this.waypoint) return;
const dx = this.waypoint.x - this.pos.x;
const dy = this.waypoint.y - this.pos.y;
const proj = worldToScreen(dx, dy);
this.setFacing(
Math.abs(proj.x) >= Math.abs(proj.y) ? (proj.x >= 0 ? 'right' : 'left') : proj.y >= 0 ? 'down' : 'up'
);
}
/** Сменить направление взгляда (кадры + флип спрайта). */
private setFacing(facing: Facing): void {
if (facing === this.facing) return;
this.facing = facing;
this.sprite.scale.x = facing === 'right' ? -1 : 1;
}
private advanceWaypoint(): void {
const next = this.path.shift();
this.waypoint = next ? tileToWorld(next.x, next.y) : null;
}
/** Вью-позиция: единственная точка перевода мир→экран (округление до 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));
}
}