import { Container } from 'pixi.js';
import { Shake } from './shake';
import { DEFAULT_ISO, type IsoLayout, screenToWorld, worldRectToScreen, worldToScreen } from '../math/iso';
/**
* Камера виртуального разрешения: позиция — центр взгляда в мировых юнитах
* (1 юнит = 1 тайл). Округляет проекцию до целого пикселя (apply — единственная
* точка перевода мир→экран), чтобы пиксель-арт не «дрожал».
* Границы прокрутки — мировой прямоугольник в юнитах (у карты: {0, 0, w, h});
* клэмп выполняется по его экранному bbox (проекция).
* Поддерживает тряску (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 — камера всегда центрируется на цели.
*/
deadZonePx: { width: number; height: number } | null = null;
private readonly shake = new Shake();
constructor(
private readonly viewWidth: number,
private readonly viewHeight: number,
private readonly iso: IsoLayout = DEFAULT_ISO
) {}
follow(targetX: number, targetY: number): void {
const dz = this.deadZonePx;
if (dz) {
// Окно живёт на экране: сравниваем проекции, двигаем каждую экранную
// ось только на величину выхода цели за окно.
const c = worldToScreen(this.x, this.y, this.iso);
const t = worldToScreen(targetX, targetY, this.iso);
const ex = t.x < c.x - dz.width / 2 ? t.x - (c.x - dz.width / 2)
: t.x > c.x + dz.width / 2 ? t.x - (c.x + dz.width / 2)
: 0;
const ey = t.y < c.y - dz.height / 2 ? t.y - (c.y - dz.height / 2)
: t.y > c.y + dz.height / 2 ? t.y - (c.y + dz.height / 2)
: 0;
if (ex !== 0 || ey !== 0) {
const w = screenToWorld(ex, ey, 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);
}
apply(container: Container): void {
const o = this.shake.offset;
const c = worldToScreen(this.x, this.y, this.iso);
const halfW = Math.floor(this.viewWidth / 2);
const halfH = Math.floor(this.viewHeight / 2);
container.position.set(
halfW - Math.round(c.x) + o.x,
halfH - Math.round(c.y) + o.y
);
}
private clamp(): void {
if (!this.bounds) return;
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 px = b.width > this.viewWidth
? Math.min(Math.max(c.x, b.x + halfW), b.x + b.width - halfW)
: b.x + b.width / 2;
const py = b.height > this.viewHeight
? Math.min(Math.max(c.y, b.y + halfH), b.y + b.height - halfH)
: b.y + b.height / 2;
const w = screenToWorld(px, py, this.iso);
this.x = w.x;
this.y = w.y;
}
}