diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index b639220..9cb8e68 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -178,18 +178,11 @@ this.game.renderer.uiRoot.addChild(name); this.locationLabel = name; - // Камера: следим за героем; ромб карты уходит в минус по X — - // границы начинаются от его западного угла. - const size = this.map.screenSize; - this.camera.bounds = { - x: -size.width / 2, - y: 0, - width: size.width, - height: size.height - }; + // Камера: следим за героем; границы — мировой прямоугольник карты (юниты). + this.camera.bounds = this.map.worldBounds; // Камера-«окно»: герой ходит в центральной зоне свободно, у виртуальной // границы экрана толкает камеру (движение остаётся плавным). - this.camera.deadZone = { width: 180, height: 120 }; + this.camera.deadZonePx = { width: 180, height: 120 }; this.updateCameraFollow(true); this.hint = new Container(); diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index 267f6b0..64f87fb 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -168,4 +168,9 @@ const halfH = (this.iso.tileH / 2) * (this.data.width + this.data.height); return { width: halfW, height: halfH }; } + + /** Границы карты в мировых юнитах (для ограничения камеры). */ + get worldBounds(): { x: number; y: number; width: number; height: number } { + return { x: 0, y: 0, width: this.data.width, height: this.data.height }; + } } \ No newline at end of file diff --git a/packages/engine/src/render/Camera.ts b/packages/engine/src/render/Camera.ts index 5ac98c8..30ad7a1 100644 --- a/packages/engine/src/render/Camera.ts +++ b/packages/engine/src/render/Camera.ts @@ -1,11 +1,13 @@ import { Container } from 'pixi.js'; import { Shake } from './shake'; +import { DEFAULT_ISO, type IsoLayout, screenToWorld, worldRectToScreen, worldToScreen } from '../math/iso'; /** - * Камера виртуального разрешения: позиция — центр взгляда в мировых (виртуальных) пикселях. - * Округляет смещение до целого пикселя, чтобы пиксель-арт не «дрожал». - * Границы прокрутки — прямоугольник мира (может начинаться с отрицательных координат, - * например у изометрической карты, «ромб» которой уходит в минус по X). + * Камера виртуального разрешения: позиция — центр взгляда в мировых юнитах + * (1 юнит = 1 тайл). Округляет проекцию до целого пикселя (apply — единственная + * точка перевода мир→экран), чтобы пиксель-арт не «дрожал». + * Границы прокрутки — мировой прямоугольник в юнитах (у карты: {0, 0, w, h}); + * клэмп выполняется по его экранному bbox (проекция). * Поддерживает тряску (addShake) — движок вызывает update(dt) каждый тик. */ export interface CameraBounds { @@ -16,36 +18,43 @@ } export class Camera { + /** Центр взгляда, мировые юниты. */ x = 0; y = 0; + /** Мировой прямоугольник прокрутки, юниты. */ bounds: CameraBounds | null = null; /** - * «Мёртвая зона» (виртуальные границы экрана): пока цель внутри окна, - * камера стоит; у края окна цель толкает камеру на величину выхода. + * «Мёртвая зона» (виртуальные границы экрана), экранные пиксели: пока цель + * внутри окна, камера стоит; у края окна цель толкает камеру на величину выхода. * null — камера всегда центрируется на цели. */ - deadZone: { width: number; height: number } | 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 viewHeight: number, + private readonly iso: IsoLayout = DEFAULT_ISO ) {} follow(targetX: number, targetY: number): void { - const dz = this.deadZone; + const dz = this.deadZonePx; 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; + // Окно живёт на экране: сравниваем проекции, двигаем каждую экранную + // ось только на величину выхода цели за окно. + 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; @@ -55,13 +64,13 @@ /** Мгновенно поставить камеру на точку (спавн, телепорт, смена локации). */ snap(targetX: number, targetY: number): void { - const dz = this.deadZone; - this.deadZone = null; + const dz = this.deadZonePx; + this.deadZonePx = null; this.follow(targetX, targetY); - this.deadZone = dz; + this.deadZonePx = dz; } - /** Запустить толчок: амплитуда в пикселях, длительность в секундах. */ + /** Запустить толчок: амплитуда в экранных пикселях, длительность в секундах. */ addShake(strength: number, duration: number): void { this.shake.add(strength, duration); } @@ -81,11 +90,12 @@ 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(this.x) + o.x, - halfH - Math.round(this.y) + o.y + halfW - Math.round(c.x) + o.x, + halfH - Math.round(c.y) + o.y ); } @@ -93,16 +103,23 @@ 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; - } + // Мировой прямоугольник -> экранный 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; } } \ No newline at end of file diff --git a/packages/engine/src/render/__tests__/camera.test.ts b/packages/engine/src/render/__tests__/camera.test.ts index e200271..c18d212 100644 --- a/packages/engine/src/render/__tests__/camera.test.ts +++ b/packages/engine/src/render/__tests__/camera.test.ts @@ -1,48 +1,78 @@ import { describe, it, expect } from 'vitest'; +import { Container } from 'pixi.js'; import { Camera } from '../Camera'; +import { screenToWorld, worldToScreen } from '../../math/iso'; + +/** Смещение цели от центра камеры в экранных пикселях: мировая точка. */ +function targetAtScreenOffset(cam: Camera, dxPx: number, dyPx: number): { x: number; y: number } { + const c = worldToScreen(cam.x, cam.y); + return screenToWorld(c.x + dxPx, c.y + dyPx); +} describe('Camera dead zone', () => { it('без dead zone — камера центрируется на цели', () => { const cam = new Camera(480, 270); - cam.follow(100, 50); - expect(cam.x).toBe(100); - expect(cam.y).toBe(50); + cam.follow(3, 5); + expect(cam.x).toBe(3); + expect(cam.y).toBe(5); }); it('цель внутри окна — камера стоит', () => { const cam = new Camera(480, 270); - cam.snap(240, 135); - cam.deadZone = { width: 160, height: 100 }; - cam.follow(240 + 60, 135 + 30); // внутри окна - expect(cam.x).toBe(240); - expect(cam.y).toBe(135); + cam.snap(3, 5); + cam.deadZonePx = { width: 160, height: 100 }; + // Цель на 60 px правее и 30 px ниже центра экрана — внутри окна. + const t = targetAtScreenOffset(cam, 60, 30); + cam.follow(t.x, t.y); + expect(cam.x).toBe(3); + expect(cam.y).toBe(5); }); it('цель толкает камеру ровно на величину выхода', () => { const cam = new Camera(480, 270); - cam.snap(240, 135); - cam.deadZone = { width: 160, height: 100 }; - cam.follow(240 + 100, 135 - 70); // выход 20 и 20 за окно - expect(cam.x).toBe(260); - expect(cam.y).toBe(115); + cam.snap(3, 5); + cam.deadZonePx = { width: 160, height: 100 }; + // Выход за окно: 20 px вправо и 20 px вверх. + const t = targetAtScreenOffset(cam, 100, -70); + cam.follow(t.x, t.y); + // Центр проекции сместился ровно на (20, −20) экранных пикселей. + const c0 = worldToScreen(3, 5); + const c = worldToScreen(cam.x, cam.y); + expect(c.x - c0.x).toBeCloseTo(20, 6); + expect(c.y - c0.y).toBeCloseTo(-20, 6); }); it('snap ставит камеру на точку, минуя окно', () => { const cam = new Camera(480, 270); - cam.deadZone = { width: 160, height: 100 }; - cam.snap(300, 200); - expect(cam.x).toBe(300); - expect(cam.y).toBe(200); - // Окно сохранено: дальнейшее движение снова с dead zone. - cam.follow(300 + 50, 200); - expect(cam.x).toBe(300); + cam.deadZonePx = { width: 160, height: 100 }; + cam.snap(5, 2); + expect(cam.x).toBe(5); + expect(cam.y).toBe(2); + // Окно сохранено: цель чуть правее центра — камера не сдвигается. + const t = targetAtScreenOffset(cam, 50, 0); + cam.follow(t.x, t.y); + expect(cam.x).toBe(5); }); +}); +describe('Camera bounds (мировые юниты)', () => { it('границы мира держат камеру прижатой', () => { const cam = new Camera(480, 270); - cam.bounds = { x: -400, y: -120, width: 800, height: 400 }; + cam.bounds = { x: 0, y: 0, width: 64, height: 64 }; cam.snap(-1000, 500); // за границей по обеим осям - expect(cam.x).toBe(-160); // -400 + 480/2 - expect(cam.y).toBe(145); // -120 + 400 - 270/2 + // bbox карты: x = (0 - 64)*16, y = 0; клэмп по проекции центра. + const c = worldToScreen(cam.x, cam.y); + expect(c.x).toBeCloseTo(-1024 + 240, 6); + expect(c.y).toBeCloseTo(135, 6); + }); + + it('apply ставит worldRoot по округлённой проекции', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + const view = new Container(); + cam.apply(view); + const applied = worldToScreen(cam.x, cam.y); + expect(view.x).toBe(240 - Math.round(applied.x)); + expect(view.y).toBe(135 - Math.round(applied.y)); }); }); \ No newline at end of file