diff --git a/docs/engine/cutscene.md b/docs/engine/cutscene.md index 077ccc0..df1c864 100644 --- a/docs/engine/cutscene.md +++ b/docs/engine/cutscene.md @@ -17,6 +17,9 @@ // Плавное перемещение камеры (в мировых юнитах) к точке: { kind: 'cameraMove', x: tower.x, y: tower.y, seconds: 1.2, camera: scene.camera }, + // Плавный зум камеры (seconds: 0/нет — мгновенно и параллельно ходу): + { kind: 'cameraZoom', camera: scene.camera, z: 2, seconds: 1 }, + // Плавное перемещение вьюхи (актёра, объекта) в локальных координатах родителя: { kind: 'moveView', view: milaView, x: exitPx.x, y: exitPx.y, seconds: 2 }, diff --git a/docs/engine/render.md b/docs/engine/render.md index e0c51f0..9265d79 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -58,6 +58,33 @@ Позиция округляется до целого пикселя (`apply`), поэтому арт не дрожит при движении. +### Зум + +`apply` ставит не только позицию, но и `scale` контейнера мира — камера владеет +масштабом целиком (по умолчанию 1 — ничего не меняется): + +```ts +camera.setZoom(2); // мгновенно (и пережать границы) +camera.zoomTo(2, 1); // плавно за 1 сек (анимация в update(dt)); + // промежуточные значения квантуются до 0.25, + // финал — ровно целевой зум +camera.zoomLevel; // текущий z +``` + +Инварианты зума: + +- **Игровые уровни — целые** (1 и 2): при целом z спрайты под `scale = z` + остаются пиксель-точными (nearest + pixelated). Дробные — только в анимации. +- **`toScreen` согласована с `apply` при любом z**: точка мира проецируется + туда же, где её отрисует worldRoot. Слои **вне** worldRoot (свет, UI-эффекты) + позиции берут из `toScreen` (уже с зумом), а вот их радиусы/размеры в px + умножают на `camera.zoomLevel` сами. +- **Потомки worldRoot** лежат в мировых px и масштабируются контейнером — их + не трогают; их `getBounds()` уже экранные. +- **Тряска не масштабируется** — амплитуда в экранных px остаётся экранными px. +- **Инверсия клика**: экранные px → мир = `(px − worldRoot.position) / zoom` + (см. `pointerToWorld` в игре). + «Мёртвая зона» — современное поведение камеры: пока цель внутри окна, камера неподвижна; у границ окна цель «толкает» камеру (каждая ось независимо). Окно — **экранное**, остаётся в виртуальных пикселях (мировые прямоугольники проецируются diff --git a/packages/engine/src/cutscene/CutsceneRunner.ts b/packages/engine/src/cutscene/CutsceneRunner.ts index 73443aa..415d009 100644 --- a/packages/engine/src/cutscene/CutsceneRunner.ts +++ b/packages/engine/src/cutscene/CutsceneRunner.ts @@ -26,6 +26,8 @@ | { kind: 'until'; predicate: () => boolean; label?: string; timeout?: number } /** Плавно переместить камеру (в юнитах) к точке. */ | { kind: 'cameraMove'; x: number; y: number; seconds: number; camera: Camera } + /** Плавно сменить зум камеры (seconds: 0 — мгновенно, параллелен ходу). */ + | { kind: 'cameraZoom'; camera: Camera; z: number; seconds?: number } /** Плавно переместить вьюху (актёра, объект) в локальных координатах её родителя. */ | { kind: 'moveView'; view: Movable; x: number; y: number; seconds: number; ease?: EaseFn } /** Поднять флаг прохождения (мгновенно). */ @@ -110,6 +112,10 @@ } }; break; + case 'cameraZoom': + step.camera.zoomTo(step.z, step.seconds ?? 0); + this.current = { seconds: step.seconds ?? 0 }; + break; case 'moveView': this.current = { seconds: step.seconds, diff --git a/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts b/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts index 85adaaf..6d9c9ac 100644 --- a/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts +++ b/packages/engine/src/cutscene/__tests__/CutsceneRunner.test.ts @@ -182,6 +182,33 @@ runner.skip(); expect(runner.active).toBe(false); }); + + it('cameraZoom ведёт зум камеры; seconds: 0 — мгновенно', () => { + const runner = new CutsceneRunner(); + const cam = cameraAt(0, 0); + const order: string[] = []; + runner.play([ + { kind: 'cameraZoom', camera: cam, z: 2, seconds: 1 }, + { kind: 'call', fn: () => order.push('after') } + ]); + runner.update(0.5); + cam.update(0.5); // анимацию зума тикает камера (движок — каждый шаг) + expect(cam.zoomLevel).toBeGreaterThan(1); + expect(cam.zoomLevel).toBeLessThan(2); + expect(order).toEqual([]); // зум ещё идёт — сцена не двинулась + runner.update(0.5); + cam.update(0.5); + expect(cam.zoomLevel).toBe(2); + expect(order).toEqual(['after']); + }); + + it('cameraZoom без seconds — параллельный шаг (не блокирует)', () => { + const runner = new CutsceneRunner(); + const cam = cameraAt(0, 0); + runner.play([{ kind: 'cameraZoom', camera: cam, z: 2 }]); + expect(cam.zoomLevel).toBe(2); + expect(runner.active).toBe(false); + }); }); describe('tween — applyEase', () => { diff --git a/packages/engine/src/render/Camera.ts b/packages/engine/src/render/Camera.ts index 832ec09..f3a1eda 100644 --- a/packages/engine/src/render/Camera.ts +++ b/packages/engine/src/render/Camera.ts @@ -1,5 +1,6 @@ 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'; @@ -10,6 +11,8 @@ * Границы прокрутки — мировой прямоугольник в юнитах (у карты: {0, 0, w, h}); * клэмп выполняется по его экранному bbox (проекция). * Поддерживает тряску (addShake) — движок вызывает update(dt) каждый тик. + * Зум: apply ставит scale контейнера мира; игровые уровни — целые (1/2), + * анимация промежуточно квантуется до 0.25 (пиксель-арт читается и в движении). */ export interface CameraBounds { x: number; @@ -18,6 +21,12 @@ height: number; } +/** Минимальный зум (сильнее отдалять нельзя — пустые края). */ +const ZOOM_MIN = 0.5; + +/** Шаг квантования анимации зума (финал — ровно целевой z). */ +const ZOOM_STEP = 0.25; + export class Camera { /** Центр взгляда, мировые юниты. */ x = 0; @@ -31,6 +40,8 @@ */ 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, @@ -38,21 +49,43 @@ 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 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; + 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) { - const w = screenToWorld(ex, ey, this.iso); + // Сдвиг в экране ex — это мировой сдвиг ex/z после масштаба. + const w = screenToWorld(ex / z, ey / z, this.iso); this.x += w.x; this.y += w.y; } @@ -84,27 +117,46 @@ 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) + o.x, this.originY(c.y) + o.y); + 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 (свет, эффекты), которым нужна проекция без лага на кадр. + * Потомки 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); - return { x: this.originX(c.x) + o.x + p.x, y: this.originY(c.y) + o.y + p.y }; + 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 { @@ -117,9 +169,10 @@ private clamp(): void { if (!this.bounds) return; + const z = this.zoom; const halfW = this.viewWidth / 2; const halfH = this.viewHeight / 2; - // Мировой прямоугольник -> экранный bbox; клэмпим проекцию центра. + // Мировой прямоугольник -> экранный bbox (×зум); клэмпим проекцию центра. const b = worldRectToScreen( this.bounds.x, this.bounds.y, @@ -128,13 +181,15 @@ 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); + 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; } diff --git a/packages/engine/src/render/__tests__/camera.test.ts b/packages/engine/src/render/__tests__/camera.test.ts index 0160672..82786d9 100644 --- a/packages/engine/src/render/__tests__/camera.test.ts +++ b/packages/engine/src/render/__tests__/camera.test.ts @@ -98,4 +98,89 @@ expect(view.x + p.x).toBeCloseTo(cam.toScreen(3.5, 5.5).x, 6); expect(view.y + p.y).toBeCloseTo(cam.toScreen(3.5, 5.5).y, 6); }); +}); + +describe('Camera zoom', () => { + it('apply ставит scale и позицию при z=2, точка мира проецируется на место', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + cam.setZoom(2); + const view = new Container(); + cam.apply(view); + expect(view.scale.x).toBe(2); + // Точка мира в worldRoot: p*zoom + позиция worldRoot (формула toScreen). + const wx = 3.5, wy = 5.5; + const p = worldToScreen(wx, wy); + expect(view.x + p.x * 2).toBeCloseTo(cam.toScreen(wx, wy).x, 6); + expect(view.y + p.y * 2).toBeCloseTo(cam.toScreen(wx, wy).y, 6); + // Центр взгляда — в центре экрана. + const c = worldToScreen(3, 5); + expect(view.x + c.x * 2).toBeCloseTo(240, 6); + expect(view.y + c.y * 2).toBeCloseTo(135, 6); + }); + + it('тряска не масштабируется зумом', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + cam.setZoom(2); + cam.addShake(4, 1); // offset фиксирован до update + const view = new Container(); + cam.apply(view); + // Та же камера без тряски — разница позиций и есть offset. + const calm = new Camera(480, 270); + calm.snap(3, 5); + calm.setZoom(2); + const calmView = new Container(); + calm.apply(calmView); + // Сдвиг от тряски одинаков в apply и toScreen (добавлен один раз). + expect(view.x - calmView.x).toBeCloseTo(cam.toScreen(3.5, 5.5).x - calm.toScreen(3.5, 5.5).x, 6); + expect(view.y - calmView.y).toBeCloseTo(cam.toScreen(3.5, 5.5).y - calm.toScreen(3.5, 5.5).y, 6); + }); + + it('clamp при z=2 держит прижатие к границе мира', () => { + const cam = new Camera(480, 270); + cam.bounds = { x: 0, y: 0, width: 64, height: 64 }; + cam.setZoom(2); + cam.snap(-1000, 500); + // bbox карты ×2: x = (0-64)*16*2 = -2048; центр прижат: -2048 + 480/2. + const c = worldToScreen(cam.x, cam.y); + expect(c.x * 2).toBeCloseTo(-2048 + 240, 6); + expect(c.y * 2).toBeCloseTo(135, 6); + }); + + it('zoomTo анимируется и квантуется, финал — ровно целевой зум', () => { + const cam = new Camera(480, 270); + cam.zoomTo(2, 1); + cam.update(0.5); + const mid = cam.zoomLevel; + expect(mid).toBeGreaterThan(1); + expect(mid).toBeLessThan(2); + expect(mid * 4).toBeCloseTo(Math.round(mid * 4), 6); // кратно 0.25 + cam.update(0.5); + expect(cam.zoomLevel).toBe(2); + expect(cam.zoomLevel * 4).toBeCloseTo(Math.round(cam.zoomLevel * 4), 6); + }); + + it('zoomTo с seconds=0 — мгновенно; setZoom сбрасывает анимацию', () => { + const cam = new Camera(480, 270); + cam.zoomTo(2, 0); + expect(cam.zoomLevel).toBe(2); + cam.zoomTo(1, 10); + cam.setZoom(1.5); + cam.update(1); + expect(cam.zoomLevel).toBe(1.5); // анимация отменена + }); + + it('dead zone при зуме: выход цели считается в экранных пикселях', () => { + const cam = new Camera(480, 270); + cam.snap(3, 5); + cam.setZoom(2); + cam.deadZonePx = { width: 160, height: 100 }; + // Экранный разнос цели 60px при зуме 2 — внутри окна: камера стоит. + const c = worldToScreen(3, 5); + const t = screenToWorld(c.x + 30, c.y + 15); // 30 мировых px = 60 экранных + cam.follow(t.x, t.y); + expect(cam.x).toBe(3); + expect(cam.y).toBe(5); + }); }); \ No newline at end of file