diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts index bd178ef..f5adcc6 100644 --- a/apps/game/src/agent/GameAgent.ts +++ b/apps/game/src/agent/GameAgent.ts @@ -60,6 +60,8 @@ newGame(): Promise; /** id текущей локации (null — сцена не локация). */ currentArea(): string | null; + /** Масштаб и позиция worldRoot (проверки зума). */ + worldRootView(): { scale: number; position: { x: number; y: number } }; } /** Действие «листать диалог» (маппится и на Space, и на Enter). */ @@ -93,6 +95,12 @@ ); } + /** Масштаб и позиция worldRoot (проверки зума из tools/checks/zoom.mjs). */ + worldRootView(): { scale: number; position: { x: number; y: number } } { + const root = this.game.worldRoot; + return { scale: root.scale.x, position: { x: root.position.x, y: root.position.y } }; + } + snapshot(): GameSnapshot { const s = this.engineAgent.snapshot() as GameSnapshot; // Слой игры поверх движкового и сценического. @@ -167,11 +175,12 @@ return { ok: false, snapshot: this.snapshot(), ticks: limit }; } - /** Клик по тайлу: юниты -> экран (виртуальные px) -> инъекция указателя. */ + /** Клик по тайлу: юниты -> экран (виртуальные px, с зумом) -> инъекция. */ tapTile(tx: number, ty: number): void { const w = worldToScreen(tx + 0.5, ty + 0.5); - const root = this.game.worldRoot.position; - this.tapVirtual(w.x + root.x, w.y + root.y); + const root = this.game.worldRoot; + const z = root.scale.x; + this.tapVirtual(w.x * z + root.position.x, w.y * z + root.position.y); } tapVirtual(vx: number, vy: number): void { @@ -284,7 +293,8 @@ runDialogue: (timeoutTicks) => agent.runDialogue(timeoutTicks), pickChoiceByText: (text, timeoutTicks) => agent.pickChoiceByText(text, timeoutTicks), newGame: () => agent.newGame(), - currentArea: () => agent.currentArea() + currentArea: () => agent.currentArea(), + worldRootView: () => agent.worldRootView() }; (window as unknown as { __agent?: AgentApi }).__agent = api; return api; diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index 46b3aac..75bff31 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -24,6 +24,7 @@ inventory: ['KeyI'], debug: ['F3'], debugChar: ['KeyP'], + debugZoom: ['KeyZ'], // отладка зума: уровни 1 <-> 2 (целые — пиксель-точность) up: ['KeyW', 'ArrowUp'], down: ['KeyS', 'ArrowDown'], left: ['KeyA', 'ArrowLeft'], diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index a897638..d914a1c 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -328,6 +328,7 @@ objects: this.objects, disarmTile, worldRootOffset: () => this.game.renderer.worldRoot.position, + worldZoom: () => this.game.renderer.worldRoot.scale.x, pixelTile: (px, py) => this.pixelTile(px, py), callbacks: { showToast: (t) => this.showToast(t), @@ -537,6 +538,11 @@ if (input.isActionJustPressed('debugChar')) { this.charDebug.view.visible = !this.charDebug.view.visible; } + if (input.isActionJustPressed('debugZoom')) { + // Отладка зума: целые уровни 1 <-> 2; snap перекрывает dead zone. + this.camera.setZoom(this.camera.zoomLevel > 1 ? 1 : 2); + this.updateCameraFollow(true); + } // --- ввод боя: тап = короткий удар, удержание = заряд резонанса --- if (input.isActionJustPressed('attack')) { @@ -640,10 +646,16 @@ this.cullMap(); } - /** Culling тайлов: окно камеры в px слоя карты (экран − позиция мира). */ + /** Culling тайлов: окно камеры в px слоя карты (экран − позиция мира, /зум). */ private cullMap(): void { - const wp = this.game.renderer.worldRoot.position; - this.map.cullToRect({ x: -wp.x, y: -wp.y, width: Game.VIRTUAL_W, height: Game.VIRTUAL_H }); + const root = this.game.renderer.worldRoot; + const z = root.scale.x; + this.map.cullToRect({ + x: -root.position.x / z, + y: -root.position.y / z, + width: Game.VIRTUAL_W / z, + height: Game.VIRTUAL_H / z + }); } /** Экранные координаты центра ромба тайла (с Origin карты). */ @@ -681,7 +693,8 @@ (tile) => this.area.transitions.some( (tr) => tr.tile.x === tile.x && tr.tile.y === tile.y && tr.trigger === 'click' - ) || this.interactables.defAt(tile.x, tile.y) !== null + ) || this.interactables.defAt(tile.x, tile.y) !== null, + this.game.renderer.worldRoot.scale.x ); } diff --git a/apps/game/src/systems/ClickRouting.ts b/apps/game/src/systems/ClickRouting.ts index d3da199..c4f2d40 100644 --- a/apps/game/src/systems/ClickRouting.ts +++ b/apps/game/src/systems/ClickRouting.ts @@ -91,11 +91,12 @@ } /** - * Указатель (виртуальные px) -> точка мира (юниты): учёт смещения worldRoot. + * Указатель (виртуальные px) -> точка мира (юниты): учёт смещения worldRoot + * и зума камеры (экранные px сжаты в мировые делением на z). * Общий хелпер клик-роутинга и пиксельного тайла. */ -export function pointerToWorld(px: number, py: number, offset: Vec2): Vec2 { - return screenToWorld(px - offset.x, py - offset.y); +export function pointerToWorld(px: number, py: number, offset: Vec2, zoom = 1): Vec2 { + return screenToWorld((px - offset.x) / zoom, (py - offset.y) / zoom); } /** @@ -111,10 +112,12 @@ offset: Vec2, bodies: PixelBody[], data: TileMapData, - isClickable: (tile: { x: number; y: number }) => boolean + isClickable: (tile: { x: number; y: number }) => boolean, + zoom = 1 ): { x: number; y: number } | null { // Тела вьюх (без подписей — они тянутся на тайл выше): границы в - // глобальных виртуальных px (рендер всегда 480×270). + // глобальных виртуальных px (рендер всегда 480×270) — уже с зумом + // (getBounds считает масштаб worldRoot), сами не масштабируются. for (const b of bodies) { const { x, y, width, height } = b.bounds; if (px >= x && px <= x + width && py >= y && py <= y + height) return b.tile; @@ -122,7 +125,7 @@ // Высокий тайл: тело стоит на (tx,ty), а занимает на экране (tx-1,ty-1). // Ремап — только если на базовом тайле есть что кликать (дверь, объект): // иначе клик по «телу дерева» уводил бы героя к непроходимому тайлу. - const p = pointerToWorld(px, py, offset); + const p = pointerToWorld(px, py, offset, zoom); const t = worldToTile(p.x, p.y, data.width, data.height); if (!t || t.x + 1 >= data.width || t.y + 1 >= data.height) return null; const base = { x: t.x + 1, y: t.y + 1 }; @@ -163,6 +166,8 @@ disarmTile: { x: number; y: number } | null; /** Смещение worldRoot (виртуальные px) — перевод клика в юниты. */ worldRootOffset(): Vec2; + /** Текущий зум камеры (scale worldRoot); нет — 1. */ + worldZoom?(): number; /** * Тайл по пикселям указателя (виртуальные px) или null. Сцена отдаёт * тайл объекта, чьё ТЕЛО (bbox вьюхи / высокий спрайт) накрыто кликом: @@ -198,8 +203,8 @@ /** Клик по миру (виртуальные px указателя). */ handleWorldClick(px: number, py: number): void { - // Координаты указателя (виртуальные px) -> мировые юниты (учёт камеры). - const p = pointerToWorld(px, py, this.deps.worldRootOffset()); + // Координаты указателя (виртуальные px) -> мировые юниты (камера + зум). + const p = pointerToWorld(px, py, this.deps.worldRootOffset(), this.deps.worldZoom?.() ?? 1); const clicked = worldToTile(p.x, p.y, this.deps.map.data.width, this.deps.map.data.height); const probe = (tile: { x: number; y: number } | null) => ({ world: p, diff --git a/apps/game/src/systems/Lighting.ts b/apps/game/src/systems/Lighting.ts index 2550874..d32cd7f 100644 --- a/apps/game/src/systems/Lighting.ts +++ b/apps/game/src/systems/Lighting.ts @@ -149,13 +149,14 @@ next.add(id); const world = tileToWorld(pos.x + 0.5, pos.y + 0.5); const s = this.deps.camera.toScreen(world.x, world.y); + // Свет живёт в lightRoot (вне worldRoot): радиус масштабируем зумом. this.deps.lighting.upsertLight({ id, x: s.x, y: s.y, color: def.color ?? 0xf2b45a, intensity: def.intensity ?? 0.9, - radius: unitsToPx(def.radius ?? 3), + radius: unitsToPx(def.radius ?? 3) * this.deps.camera.zoomLevel, flicker: def.flicker ?? 0, seed: seedOf(id) }); @@ -199,7 +200,7 @@ id, x: s.x, y: s.y, - radius: unitsToPx(HAZARD_SPOT_RADIUS), + radius: unitsToPx(HAZARD_SPOT_RADIUS) * this.deps.camera.zoomLevel, alpha: HAZARD_SPOT_ALPHA }); } diff --git a/apps/game/src/systems/combat/CombatFlow.ts b/apps/game/src/systems/combat/CombatFlow.ts index abe001b..f2d0966 100644 --- a/apps/game/src/systems/combat/CombatFlow.ts +++ b/apps/game/src/systems/combat/CombatFlow.ts @@ -82,7 +82,7 @@ y: ls.y, color: 0xf2b45a, intensity: 0.45, - radius: unitsToPx(1.5), + radius: unitsToPx(1.5) * deps.camera.zoomLevel, spec: { attack: 0.02, decay: 0.2 } }); }) @@ -177,7 +177,7 @@ y: ls.y, color: 0xd99a32, intensity: 0.6, - radius: unitsToPx(2.5), + radius: unitsToPx(2.5) * d.camera.zoomLevel, spec: { attack: 0.05, decay: 0.45 } }); d.lighting.pulseAmbient({ color: 0xd99a32, peak: 0.12, spec: { attack: 0.05, decay: 0.5 } }); diff --git a/apps/game/tools/checks/zoom.mjs b/apps/game/tools/checks/zoom.mjs new file mode 100644 index 0000000..f997e0d --- /dev/null +++ b/apps/game/tools/checks/zoom.mjs @@ -0,0 +1,57 @@ +/** + * Сценарий zoom — зум камеры: клики, ходьба и диалог не ломаются при z=2. + * Проверки в Звенце (там NPC): 1) клавиша debugZoom: worldRoot.scale = 2; + * 2) tapTile по NPC при зуме открывает диалог (инверсия клика /z); + * 3) walkTo при зуме доходит (окно клика и culling /z); + * 4) возврат на z=1: масштаб как до зума. + * Запуск: node tools/agent.mjs run tools/checks/zoom.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'zoom', + async (t) => { + const { c } = t; + const worldRoot = () => t.ctx.page.evaluate(() => window.__agent.worldRootView()); + await c.run('включение зума: scale=2', async () => { + await t.boot(); + await t.sleepEnemies(); + await t.goToArea('zvenets', { x: 26, y: 14 }); + const before = await worldRoot(); + c.expect(before.scale === 1, 'стартовый зум не 1', before.scale); + await t.ctx.agent.press('debugZoom'); + // scale ставится в Camera.apply на рендер-кадре — даём кадры. + await t.ctx.agent.step(3, { render: true }); + const after = await worldRoot(); + c.expect(after.scale === 2, 'после debugZoom scale не 2', after.scale); + return null; + }); + await c.run('tapTile по NPC при зуме открывает диалог', async () => { + const s = await t.ctx.agent.snapshot(); + const npc = s.npcs?.[0]; + c.expect(!!npc, 'в области нет NPC', s.npcs); + // Сцена сама подводит героя и открывает диалог (как в smoke-act1). + await t.ctx.agent.tapTile(npc.tile.x, npc.tile.y); + const d = await t.ctx.agent.runDialogue(900); + c.expect(d, 'диалог с NPC при зуме не открылся'); + return null; + }); + await c.run('walkTo при зуме доходит до цели', async () => { + const s = await t.ctx.agent.snapshot(); + const target = { x: s.hero.tile.x + 3, y: s.hero.tile.y + 3 }; + const walked = await t.ctx.agent.walkTo(target.x, target.y, { timeoutTicks: 1500 }); + c.expect(walked, `walkTo(${target.x},${target.y}) при зуме не дошёл`); + return null; + }); + await c.run('возврат на z=1: масштаб как до зума', async () => { + await t.ctx.agent.press('debugZoom'); + await t.ctx.agent.step(3, { render: true }); + const after = await worldRoot(); + c.expect(after.scale === 1, 'повторный debugZoom не вернул scale=1', after.scale); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file