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;
}
}