Newer
Older
rpg / apps / game / src / systems / PlayerController.ts
import {
    IsometricTileMap,
    findPath,
    isoToScreen,
    screenToIso,
    screenToIsoExact,
    moveTo,
    FrameAnimation,
    Container,
    Sprite,
    Texture,
    DEFAULT_ISO,
    type Vec2
} from '@rpg/engine';

/** Кадры героя по направлениям. side смотрит влево; вправо — флип. */
export interface HeroTextures {
    down: [Texture, Texture];
    up: [Texture, Texture];
    side: [Texture, Texture];
}

type Facing = 'down' | 'up' | 'left' | 'right';

/**
 * Управление героем: путь по клику (A*), плавное движение по маршруту,
 * покадровая анимация ходьбы по направлению движения.
 */
export class PlayerController {
    readonly view: Container;

    private sprite: Sprite;
    private anim: FrameAnimation;
    private facing: Facing = 'down';
    private textures: HeroTextures;

    /** Позиция в виртуальных пикселях (источник истины, ноги героя). */
    private pos: Vec2;
    private path: { x: number; y: number }[] = [];
    private waypoint: Vec2 | null = null;
    /** Скорость в виртуальных пикселях/сек. */
    private speed = 56;

    constructor(
        private map: IsometricTileMap,
        textures: HeroTextures,
        startTile: { x: number; y: number },
        private onStep?: () => void
    ) {
        this.textures = textures;

        this.view = new Container();
        this.sprite = new Sprite(textures.down[0]);
        this.sprite.anchor.set(0.5, 1); // ноги в точке позиции
        this.view.addChild(this.sprite);
        this.anim = new FrameAnimation(this.sprite, textures.down, 6, true);

        const p = isoToScreen(startTile.x, startTile.y);
        this.pos = { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 };
        this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y));
    }

    /** Текущее направление взгляда (для боя: куда бьёт конус). */
    get dir(): 'down' | 'up' | 'left' | 'right' {
        return this.facing;
    }

    /** Вектор направления взгляда в экранных координатах. */
    get dirVector(): Vec2 {
        switch (this.facing) {
            case 'up':
                return { x: 0, y: -1 };
            case 'left':
                return { x: -1, y: 0 };
            case 'right':
                return { x: 1, y: 0 };
            default:
                return { x: 0, y: 1 };
        }
    }

    /** Кликом по миру выбран тайл — строим путь A* и начинаем движение.
     *  Клик за краем карты ведёт к ближайшему краевому тайлу (камера прижата границей). */
    onWorldClick(worldX: number, worldY: number): void {
        let tile = screenToIsoExact(
            worldX,
            worldY,
            this.map.data.width,
            this.map.data.height,
            DEFAULT_ISO
        );
        if (!tile) {
            const approx = screenToIso(worldX, worldY, DEFAULT_ISO);
            tile = {
                x: Math.min(this.map.data.width - 1, Math.max(0, approx.x)),
                y: Math.min(this.map.data.height - 1, Math.max(0, approx.y))
            };
        }
        const path = findPath(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 {
        if (!this.waypoint) return;
        this.pos = moveTo(this.pos, this.waypoint, this.speed * dt);
        if (this.pos.x === this.waypoint.x && this.pos.y === this.waypoint.y) {
            this.onStep?.(); // ступили на новый тайл
            this.advanceWaypoint();
            return;
        }
        this.faceMovement();
        this.anim.update(dt);
        this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y));
    }

    get moving(): boolean {
        return this.waypoint !== null;
    }

    /** Текущий тайл героя. */
    currentTile(): { x: number; y: number } {
        return (
            screenToIsoExact(
                this.pos.x,
                this.pos.y,
                this.map.data.width,
                this.map.data.height,
                DEFAULT_ISO
            ) ?? { 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 step = this.speed * dt;
        const nx = this.pos.x + (dir.x / len) * step;
        const ny = this.pos.y + (dir.y / len) * step;
        // Двигаемся только если новая точка внутри карты и не в стене
        const tile = screenToIsoExact(nx, ny, this.map.data.width, this.map.data.height, DEFAULT_ISO);
        if (tile && this.map.isWalkable(tile.x, tile.y)) {
            this.setFacing(
                Math.abs(dir.x) >= Math.abs(dir.y) ? (dir.x >= 0 ? 'right' : 'left') : dir.y >= 0 ? 'down' : 'up'
            );
            this.pos = { x: nx, y: ny };
            this.anim.update(dt);
            this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y));
        } else {
            this.stop();
        }
    }

    /** Позиция героя (ноги) в виртуальных пикселях. */
    get position(): Vec2 {
        return { x: this.pos.x, y: this.pos.y };
    }

    /** Прервать текущий путь (например, при уроне). */
    stop(): void {
        this.path = [];
        this.waypoint = null;
    }

    /** Мгновенно переместить героя в тайл (респаун, переходы между локациями). */
    teleportTo(tile: { x: number; y: number }): void {
        this.stop();
        const p = isoToScreen(tile.x, tile.y);
        this.pos = { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 };
        this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y));
    }

    /** Отброс: сдвиг позиции с проверкой проходимости (для боя). */
    applyKnockback(dir: Vec2, dist: number): void {
        const nx = this.pos.x + dir.x * dist;
        const ny = this.pos.y + dir.y * dist;
        const tile = screenToIsoExact(nx, ny, this.map.data.width, this.map.data.height, DEFAULT_ISO);
        if (tile && this.map.isWalkable(tile.x, tile.y)) {
            this.pos = { x: nx, y: ny };
            this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y));
        }
        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;
        this.setFacing(
            Math.abs(dx) >= Math.abs(dy) ? (dx >= 0 ? 'right' : 'left') : dy >= 0 ? 'down' : 'up'
        );
    }

    /** Сменить направление взгляда (кадры + флип спрайта). */
    private setFacing(facing: Facing): void {
        if (facing === this.facing) return;
        this.facing = facing;
        const frames =
            facing === 'down' ? this.textures.down : facing === 'up' ? this.textures.up : this.textures.side;
        this.anim.setFrames([...frames], false);
        this.sprite.scale.x = facing === 'right' ? -1 : 1;
    }

    private advanceWaypoint(): void {
        const next = this.path.shift();
        if (next) {
            const p = isoToScreen(next.x, next.y);
            this.waypoint = { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 };
        } else {
            this.waypoint = null;
        }
    }
}