Newer
Older
rpg / packages / engine / src / render / Camera.ts
import { Container } from 'pixi.js';
import { Shake } from './shake';
import { sineInOut, type EaseFn } from '../core/easing';
import { DEFAULT_ISO, type IsoLayout, screenToWorld, worldRectToScreen, worldToScreen } from '../math/iso';
import type { Vec2 } from '../math/Vec2';

/**
 * Камера виртуального разрешения: позиция — центр взгляда в мировых юнитах
 * (1 юнит = 1 тайл). Округляет проекцию до целого пикселя (apply — единственная
 * точка перевода мир→экран), чтобы пиксель-арт не «дрожал».
 * Границы прокрутки — мировой прямоугольник в юнитах (у карты: {0, 0, w, h});
 * клэмп выполняется по его экранному bbox (проекция).
 * Поддерживает тряску (addShake) — движок вызывает update(dt) каждый тик.
 * Зум: apply ставит scale контейнера мира; игровые уровни — целые (1/2),
 * анимация промежуточно квантуется до 0.25 (пиксель-арт читается и в движении).
 */
export interface CameraBounds {
    x: number;
    y: number;
    width: number;
    height: number;
}

/** Минимальный зум (сильнее отдалять нельзя — пустые края). */
const ZOOM_MIN = 0.5;

/** Шаг квантования анимации зума (финал — ровно целевой z). */
const ZOOM_STEP = 0.25;

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

    constructor(
        private readonly viewWidth: number,
        private readonly viewHeight: number,
        private readonly iso: IsoLayout = DEFAULT_ISO
    ) {}

    /** Текущий зум (1 — без масштабирования). */
    get zoomLevel(): number {
        return this.zoom;
    }

    /** Мгновенно поставить зум (и пережать границы). */
    setZoom(z: number): void {
        this.zoom = Math.max(ZOOM_MIN, z);
        this.zoomAnim = null;
        this.clamp();
    }

    /** Плавный переход к зуму (сек); промежуточные значения квантуются до 0.25. */
    zoomTo(z: number, seconds: number, ease: EaseFn = sineInOut): void {
        const target = Math.max(ZOOM_MIN, z);
        if (seconds <= 0 || target === this.zoom) {
            this.setZoom(target);
            return;
        }
        this.zoomAnim = { from: this.zoom, to: target, t: 0, seconds, ease };
    }

    follow(targetX: number, targetY: number): void {
        const dz = this.deadZonePx;
        if (dz) {
            // Окно живёт на экране: сравниваем экранный разнос проекций
            // (умножен на зум), двигаем каждую ось на величину выхода за окно.
            const z = this.zoom;
            const c = worldToScreen(this.x, this.y, this.iso);
            const t = worldToScreen(targetX, targetY, this.iso);
            const dxs = (t.x - c.x) * z;
            const dys = (t.y - c.y) * z;
            const ex = dxs < -dz.width / 2 ? dxs + dz.width / 2 : dxs > dz.width / 2 ? dxs - dz.width / 2 : 0;
            const ey = dys < -dz.height / 2 ? dys + dz.height / 2 : dys > dz.height / 2 ? dys - dz.height / 2 : 0;
            if (ex !== 0 || ey !== 0) {
                // Сдвиг в экране ex — это мировой сдвиг ex/z после масштаба.
                const w = screenToWorld(ex / z, ey / z, this.iso);
                this.x += w.x;
                this.y += w.y;
            }
        } else {
            this.x = targetX;
            this.y = targetY;
        }
        this.clamp();
    }

    /** Мгновенно поставить камеру на точку (спавн, телепорт, смена локации). */
    snap(targetX: number, targetY: number): void {
        const dz = this.deadZonePx;
        this.deadZonePx = null;
        this.follow(targetX, targetY);
        this.deadZonePx = 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);
        const anim = this.zoomAnim;
        if (!anim) return;
        anim.t += dt;
        const k = Math.min(1, anim.t / anim.seconds);
        if (k >= 1) {
            this.setZoom(anim.to); // финал — ровно целевой зум
            return;
        }
        const raw = anim.from + (anim.to - anim.from) * anim.ease(k);
        const quantized = Math.round(raw / ZOOM_STEP) * ZOOM_STEP;
        if (quantized !== this.zoom) {
            this.zoom = Math.max(ZOOM_MIN, quantized);
            this.clamp();
        }
    }

    /** Позиция и масштаб контейнера мира (единственная точка мир→экран). */
    apply(container: Container): void {
        const o = this.shake.offset;
        const c = worldToScreen(this.x, this.y, this.iso);
        container.position.set(this.originX(c.x * this.zoom) + o.x, this.originY(c.y * this.zoom) + o.y);
        if (container.scale.x !== this.zoom) container.scale.set(this.zoom);
    }

    /**
     * Экранная позиция мировой точки с учётом камеры, зума и тряски — то, куда
     * точка реально спроецируется следующим кадром (без округления). Для слоёв
     * вне worldRoot (свет, эффекты), которым нужна проекция без лага на кадр.
     * Потомки worldRoot лежат в мировых px и масштабируются контейнером сами —
     * формула совпадает и для них.
     */
    toScreen(wx: number, wy: number): Vec2 {
        const o = this.shake.offset;
        const c = worldToScreen(this.x, this.y, this.iso);
        const p = worldToScreen(wx, wy, this.iso);
        const z = this.zoom;
        return { x: this.originX(c.x * z) + o.x + p.x * z, y: this.originY(c.y * z) + o.y + p.y * z };
    }

    private originX(cProjX: number): number {
        return Math.floor(this.viewWidth / 2) - Math.round(cProjX);
    }

    private originY(cProjY: number): number {
        return Math.floor(this.viewHeight / 2) - Math.round(cProjY);
    }

    private clamp(): void {
        if (!this.bounds) return;
        const z = this.zoom;
        const halfW = this.viewWidth / 2;
        const halfH = this.viewHeight / 2;
        // Мировой прямоугольник -> экранный bbox (×зум); клэмпим проекцию центра.
        const b = worldRectToScreen(
            this.bounds.x,
            this.bounds.y,
            this.bounds.width,
            this.bounds.height,
            this.iso
        );
        const c = worldToScreen(this.x, this.y, this.iso);
        const cx = c.x * z;
        const cy = c.y * z;
        const px = b.width * z > this.viewWidth
            ? Math.min(Math.max(cx, b.x * z + halfW), (b.x + b.width) * z - halfW)
            : (b.x + b.width / 2) * z;
        const py = b.height * z > this.viewHeight
            ? Math.min(Math.max(cy, b.y * z + halfH), (b.y + b.height) * z - halfH)
            : (b.y + b.height / 2) * z;
        const w = screenToWorld(px / z, py / z, this.iso);
        this.x = w.x;
        this.y = w.y;
    }
}