Newer
Older
rpg / packages / engine / src / render / Camera.ts
import { Container } from 'pixi.js';
import { Shake } from './shake';

/**
 * Камера виртуального разрешения: позиция — центр взгляда в мировых (виртуальных) пикселях.
 * Округляет смещение до целого пикселя, чтобы пиксель-арт не «дрожал».
 * Границы прокрутки — прямоугольник мира (может начинаться с отрицательных координат,
 * например у изометрической карты, «ромб» которой уходит в минус по X).
 * Поддерживает тряску (addShake) — движок вызывает update(dt) каждый тик.
 */
export interface CameraBounds {
    x: number;
    y: number;
    width: number;
    height: number;
}

export class Camera {
    x = 0;
    y = 0;
    bounds: CameraBounds | null = null;
    /**
     * «Мёртвая зона» (виртуальные границы экрана): пока цель внутри окна,
     * камера стоит; у края окна цель толкает камеру на величину выхода.
     * null — камера всегда центрируется на цели.
     */
    deadZone: { width: number; height: number } | null = null;
    private readonly shake = new Shake();

    constructor(
        private readonly viewWidth: number,
        private readonly viewHeight: number
    ) {}

    follow(targetX: number, targetY: number): void {
        const dz = this.deadZone;
        if (dz) {
            // Двигаем каждую ось только на величину выхода цели за окно.
            const hw = dz.width / 2;
            const hh = dz.height / 2;
            const dxMin = this.x - hw;
            const dxMax = this.x + hw;
            if (targetX < dxMin) this.x -= dxMin - targetX;
            else if (targetX > dxMax) this.x += targetX - dxMax;
            const dyMin = this.y - hh;
            const dyMax = this.y + hh;
            if (targetY < dyMin) this.y -= dyMin - targetY;
            else if (targetY > dyMax) this.y += targetY - dyMax;
        } else {
            this.x = targetX;
            this.y = targetY;
        }
        this.clamp();
    }

    /** Мгновенно поставить камеру на точку (спавн, телепорт, смена локации). */
    snap(targetX: number, targetY: number): void {
        const dz = this.deadZone;
        this.deadZone = null;
        this.follow(targetX, targetY);
        this.deadZone = dz;
    }

    /** Запустить толчок: амплитуда в пикселях, длительность в секундах. */
    addShake(strength: number, duration: number): void {
        this.shake.add(strength, duration);
    }

    clearShake(): void {
        this.shake.clear();
    }

    get shaking(): boolean {
        return this.shake.active;
    }

    /** Обновление тряски — движок вызывает каждый фиксированный шаг. */
    update(dt: number): void {
        this.shake.update(dt);
    }

    apply(container: Container): void {
        const o = this.shake.offset;
        const halfW = Math.floor(this.viewWidth / 2);
        const halfH = Math.floor(this.viewHeight / 2);
        container.position.set(
            halfW - Math.round(this.x) + o.x,
            halfH - Math.round(this.y) + o.y
        );
    }

    private clamp(): void {
        if (!this.bounds) return;
        const halfW = this.viewWidth / 2;
        const halfH = this.viewHeight / 2;
        const b = this.bounds;
        if (b.width > this.viewWidth) {
            this.x = Math.min(Math.max(this.x, b.x + halfW), b.x + b.width - halfW);
        } else {
            this.x = b.x + b.width / 2;
        }
        if (b.height > this.viewHeight) {
            this.y = Math.min(Math.max(this.y, b.y + halfH), b.y + b.height - halfH);
        } else {
            this.y = b.y + b.height / 2;
        }
    }
}