From fede13d64a384385f7ff689db7f3dfb7a739f057 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Sat, 21 Mar 2026 11:49:39 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20ZoomMouseScene=20with=20zoom-?= =?UTF-8?q?to-mouse=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements scroll correction after cam.setZoom() so the world point under the mouse stays fixed. Formula accounts for Phaser's center-based zoom: scrollX += (mouseX - cw/2) * (1/zBefore - 1/zAfter). Tab switches between the two test scenes in both directions. Also fixes centerWorld formula in ZoomTestScene overlay and logs. --- src/test/ZoomMouseScene.ts | 364 +++++++++++++++++++++++++++++++++++++ src/test/ZoomTestScene.ts | 18 +- src/test/main.ts | 3 +- 3 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 src/test/ZoomMouseScene.ts diff --git a/src/test/ZoomMouseScene.ts b/src/test/ZoomMouseScene.ts new file mode 100644 index 0000000..45ae984 --- /dev/null +++ b/src/test/ZoomMouseScene.ts @@ -0,0 +1,364 @@ +import Phaser from 'phaser' +import { TILE_SIZE } from '../config' + +const GRID_TILES = 500 // world size in tiles +const MIN_ZOOM = 0.25 +const MAX_ZOOM = 4.0 +const ZOOM_STEP = 0.1 +const MARKER_EVERY = 10 // small crosshair every N tiles +const LABEL_EVERY = 50 // coordinate label every N tiles +const CAMERA_SPEED = 400 // px/s +const SNAPSHOT_EVERY = 2000 // ms between periodic log snapshots + +/** + * Second test scene: zoom-to-mouse behavior. + * After each zoom step, scrollX/Y is corrected so the world point + * under the mouse stays at the same screen position. + * + * Formula: + * newScrollX = scrollX + (mouseX - screenW/2) * (1/zoomBefore - 1/zoomAfter) + * newScrollY = scrollY + (mouseY - screenH/2) * (1/zoomBefore - 1/zoomAfter) + * + * Controls: Scroll wheel to zoom, WASD / Arrow keys to pan, Tab to switch scene. + */ +export class ZoomMouseScene extends Phaser.Scene { + private logText!: Phaser.GameObjects.Text + private hudCamera!: Phaser.Cameras.Scene2D.Camera + private worldObjects: Phaser.GameObjects.GameObject[] = [] + private hudObjects: Phaser.GameObjects.GameObject[] = [] + private keys!: { + up: Phaser.Input.Keyboard.Key + down: Phaser.Input.Keyboard.Key + left: Phaser.Input.Keyboard.Key + right: Phaser.Input.Keyboard.Key + w: Phaser.Input.Keyboard.Key + s: Phaser.Input.Keyboard.Key + a: Phaser.Input.Keyboard.Key + d: Phaser.Input.Keyboard.Key + tab: Phaser.Input.Keyboard.Key + } + private snapshotTimer = 0 + + constructor() { + super({ key: 'ZoomMouse' }) + } + + create(): void { + fetch('/api/log', { method: 'DELETE' }) + this.writeLog('scene_start', { scene: 'ZoomMouse', tileSize: TILE_SIZE, gridTiles: GRID_TILES }) + + this.drawGrid() + this.setupCamera() + this.setupInput() + this.createHUD() + this.setupCameras() + } + + /** + * Draws the static world grid into world space. + * All objects are registered in worldObjects for HUD-camera exclusion. + */ + private drawGrid(): void { + const worldPx = GRID_TILES * TILE_SIZE + const g = this.add.graphics() + this.worldObjects.push(g) + + g.fillStyle(0x111318) + g.fillRect(0, 0, worldPx, worldPx) + + g.lineStyle(1, 0x222233, 0.5) + for (let i = 0; i <= GRID_TILES; i++) { + const p = i * TILE_SIZE + g.lineBetween(p, 0, p, worldPx) + g.lineBetween(0, p, worldPx, p) + } + + for (let tx = 0; tx <= GRID_TILES; tx += MARKER_EVERY) { + for (let ty = 0; ty <= GRID_TILES; ty += MARKER_EVERY) { + const px = tx * TILE_SIZE + const py = ty * TILE_SIZE + const isLabel = tx % LABEL_EVERY === 0 && ty % LABEL_EVERY === 0 + const color = isLabel ? 0xffff00 : 0x44aaff + const arm = isLabel ? 10 : 6 + + g.lineStyle(1, color, isLabel ? 1.0 : 0.7) + g.lineBetween(px - arm, py, px + arm, py) + g.lineBetween(px, py - arm, px, py + arm) + + g.fillStyle(color, 1.0) + g.fillCircle(px, py, isLabel ? 2.5 : 1.5) + } + } + + for (let tx = 0; tx <= GRID_TILES; tx += LABEL_EVERY) { + for (let ty = 0; ty <= GRID_TILES; ty += LABEL_EVERY) { + const label = this.add.text( + tx * TILE_SIZE + 4, + ty * TILE_SIZE + 4, + `${tx},${ty}`, + { fontSize: '9px', color: '#aaddff', fontFamily: 'monospace' } + ).setDepth(1) + this.worldObjects.push(label) + } + } + + g.lineStyle(2, 0xff8844, 1.0) + g.strokeRect(0, 0, worldPx, worldPx) + } + + /** + * Sets camera bounds and centers the view on the world. + */ + private setupCamera(): void { + const cam = this.cameras.main + const worldPx = GRID_TILES * TILE_SIZE + cam.setBounds(0, 0, worldPx, worldPx) + cam.scrollX = worldPx / 2 - cam.width / 2 + cam.scrollY = worldPx / 2 - cam.height / 2 + } + + /** + * Registers scroll wheel zoom with mouse-anchor correction and keyboard keys. + * After cam.setZoom(), scrollX/Y is adjusted so the world point under the + * mouse stays at the same screen position. + */ + private setupInput(): void { + const cam = this.cameras.main + const kb = this.input.keyboard! + + this.keys = { + up: kb.addKey(Phaser.Input.Keyboard.KeyCodes.UP), + down: kb.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN), + left: kb.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT), + right: kb.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT), + w: kb.addKey(Phaser.Input.Keyboard.KeyCodes.W), + s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S), + a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A), + d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), + tab: kb.addKey(Phaser.Input.Keyboard.KeyCodes.TAB), + } + + // Prevent Tab from switching browser focus + ;(this.keys.tab as unknown as { preventDefault: boolean }).preventDefault = true + + this.keys.tab.on('down', () => { + this.scene.start('ZoomTest') + }) + + this.input.on('wheel', ( + ptr: Phaser.Input.Pointer, + _objs: unknown, + _dx: number, + dy: number + ) => { + const zoomBefore = cam.zoom + const scrollXBefore = cam.scrollX + const scrollYBefore = cam.scrollY + + const newZoom = Phaser.Math.Clamp(zoomBefore - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + cam.setZoom(newZoom) + + // Correct scroll so the world point under the mouse stays fixed. + // Phaser zooms from screen center, so the offset from center determines the shift. + const cw = cam.width + const ch = cam.height + const factor = 1 / zoomBefore - 1 / newZoom + cam.scrollX += (ptr.x - cw / 2) * factor + cam.scrollY += (ptr.y - ch / 2) * factor + + // Clamp to world bounds + const worldPx = GRID_TILES * TILE_SIZE + cam.scrollX = Phaser.Math.Clamp(cam.scrollX, 0, worldPx - cw / cam.zoom) + cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, worldPx - ch / cam.zoom) + + setTimeout(() => { + this.writeLog('zoom', { + direction: dy > 0 ? 'out' : 'in', + zoomBefore: +zoomBefore.toFixed(4), + zoomAfter: +cam.zoom.toFixed(4), + scrollX_before: +scrollXBefore.toFixed(2), + scrollY_before: +scrollYBefore.toFixed(2), + scrollX_after: +cam.scrollX.toFixed(2), + scrollY_after: +cam.scrollY.toFixed(2), + scrollX_delta: +(cam.scrollX - scrollXBefore).toFixed(2), + scrollY_delta: +(cam.scrollY - scrollYBefore).toFixed(2), + mouseScreen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) }, + mouseWorld_before: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) }, + centerWorld_after: { + x: +(cam.scrollX + cam.width / 2).toFixed(2), + y: +(cam.scrollY + cam.height / 2).toFixed(2), + }, + vpTiles_after: { + w: +((cam.width / cam.zoom) / TILE_SIZE).toFixed(3), + h: +((cam.height / cam.zoom) / TILE_SIZE).toFixed(3), + }, + }) + }, 0) + }) + } + + /** + * Creates all HUD elements: log overlay and screen-center crosshair. + * All objects are registered in hudObjects for main-camera exclusion. + */ + private createHUD(): void { + const w = this.scale.width + const h = this.scale.height + + const cross = this.add.graphics() + const arm = 16 + cross.lineStyle(1, 0xff2222, 0.9) + cross.lineBetween(w / 2 - arm, h / 2, w / 2 + arm, h / 2) + cross.lineBetween(w / 2, h / 2 - arm, w / 2, h / 2 + arm) + cross.fillStyle(0xff2222, 1.0) + cross.fillCircle(w / 2, h / 2, 2) + this.hudObjects.push(cross) + + this.logText = this.add.text(10, 10, '', { + fontSize: '13px', + color: '#e8e8e8', + backgroundColor: '#000000bb', + padding: { x: 10, y: 8 }, + lineSpacing: 3, + fontFamily: 'monospace', + }).setDepth(100) + this.hudObjects.push(this.logText) + } + + /** + * Adds a dedicated HUD camera (zoom=1, no scroll) and separates + * world objects from HUD objects so neither camera renders both layers. + */ + private setupCameras(): void { + this.hudCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height) + this.hudCamera.setScroll(0, 0) + this.hudCamera.setZoom(1) + + this.cameras.main.ignore(this.hudObjects) + this.hudCamera.ignore(this.worldObjects) + } + + update(_time: number, delta: number): void { + this.handleKeyboard(delta) + this.updateOverlay() + + this.snapshotTimer += delta + if (this.snapshotTimer >= SNAPSHOT_EVERY) { + this.snapshotTimer = 0 + this.writeSnapshot() + } + } + + /** + * Moves camera with WASD / arrow keys at CAMERA_SPEED px/s (world space). + * @param delta - Frame delta in milliseconds + */ + private handleKeyboard(delta: number): void { + const cam = this.cameras.main + const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom + const worldPx = GRID_TILES * TILE_SIZE + + let dx = 0, dy = 0 + if (this.keys.left.isDown || this.keys.a.isDown) dx -= speed + if (this.keys.right.isDown || this.keys.d.isDown) dx += speed + if (this.keys.up.isDown || this.keys.w.isDown) dy -= speed + if (this.keys.down.isDown || this.keys.s.isDown) dy += speed + + if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 } + + cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldPx - cam.width / cam.zoom) + cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldPx - cam.height / cam.zoom) + } + + /** + * Recomputes and renders all diagnostic values to the HUD overlay each frame. + * centerWorld uses the corrected formula: scrollX + screenWidth/2. + */ + private updateOverlay(): void { + const cam = this.cameras.main + const ptr = this.input.activePointer + + const vpWidthPx = cam.width / cam.zoom + const vpHeightPx = cam.height / cam.zoom + const vpWidthTiles = vpWidthPx / TILE_SIZE + const vpHeightTiles = vpHeightPx / TILE_SIZE + + // Phaser zooms from screen center, so visual center = scrollX + screenWidth/2 + const centerWorldX = cam.scrollX + cam.width / 2 + const centerWorldY = cam.scrollY + cam.height / 2 + const centerTileX = Math.floor(centerWorldX / TILE_SIZE) + const centerTileY = Math.floor(centerWorldY / TILE_SIZE) + + const mouseTileX = Math.floor(ptr.worldX / TILE_SIZE) + const mouseTileY = Math.floor(ptr.worldY / TILE_SIZE) + const renderer = this.game.renderer.type === Phaser.WEBGL ? 'WebGL' : 'Canvas' + + const lines = [ + '── ZOOM TEST [Zoom-to-Mouse] ──', + '', + `Zoom: ${cam.zoom.toFixed(4)}`, + `scrollX / scrollY: ${cam.scrollX.toFixed(2)} / ${cam.scrollY.toFixed(2)}`, + '', + `Viewport (screen): ${cam.width} × ${cam.height} px`, + `Viewport (world): ${vpWidthPx.toFixed(2)} × ${vpHeightPx.toFixed(2)} px`, + `Viewport (tiles): ${vpWidthTiles.toFixed(3)} × ${vpHeightTiles.toFixed(3)}`, + '', + `Center world: ${centerWorldX.toFixed(2)}, ${centerWorldY.toFixed(2)}`, + `Center tile: ${centerTileX}, ${centerTileY}`, + '', + `Mouse screen: ${ptr.x.toFixed(1)}, ${ptr.y.toFixed(1)}`, + `Mouse world: ${ptr.worldX.toFixed(2)}, ${ptr.worldY.toFixed(2)}`, + `Mouse tile: ${mouseTileX}, ${mouseTileY}`, + '', + `Canvas: ${this.scale.width} × ${this.scale.height} px`, + `TILE_SIZE: ${TILE_SIZE} px`, + `roundPixels: ${(this.game.renderer.config as Record)['roundPixels']}`, + `Renderer: ${renderer}`, + '', + '[Scroll] Zoom [WASD / ↑↓←→] Pan [Tab] → Default', + ] + + this.logText.setText(lines) + } + + /** + * Writes a periodic full-state snapshot to the log. + */ + private writeSnapshot(): void { + const cam = this.cameras.main + const ptr = this.input.activePointer + const vpW = cam.width / cam.zoom + const vpH = cam.height / cam.zoom + + this.writeLog('snapshot', { + zoom: +cam.zoom.toFixed(4), + scrollX: +cam.scrollX.toFixed(2), + scrollY: +cam.scrollY.toFixed(2), + vpScreen: { w: cam.width, h: cam.height }, + vpWorld: { w: +vpW.toFixed(2), h: +vpH.toFixed(2) }, + vpTiles: { w: +((vpW / TILE_SIZE).toFixed(3)), h: +((vpH / TILE_SIZE).toFixed(3)) }, + centerWorld: { + x: +(cam.scrollX + cam.width / 2).toFixed(2), + y: +(cam.scrollY + cam.height / 2).toFixed(2), + }, + mouse: { + screen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) }, + world: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) }, + }, + }) + } + + /** + * POSTs a structured log entry to the Vite dev server middleware. + * @param event - Event type label + * @param data - Payload object to serialize as JSON + */ + private writeLog(event: string, data: Record): void { + const entry = JSON.stringify({ t: Date.now(), event, ...data }) + fetch('/api/log', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: entry, + }).catch(() => { /* swallow if dev server not running */ }) + } +} diff --git a/src/test/ZoomTestScene.ts b/src/test/ZoomTestScene.ts index ea78a36..1bf589d 100644 --- a/src/test/ZoomTestScene.ts +++ b/src/test/ZoomTestScene.ts @@ -31,6 +31,7 @@ export class ZoomTestScene extends Phaser.Scene { s: Phaser.Input.Keyboard.Key a: Phaser.Input.Keyboard.Key d: Phaser.Input.Keyboard.Key + tab: Phaser.Input.Keyboard.Key } private snapshotTimer = 0 @@ -136,8 +137,14 @@ export class ZoomTestScene extends Phaser.Scene { s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S), a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A), d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), + tab: kb.addKey(Phaser.Input.Keyboard.KeyCodes.TAB), } + ;(this.keys.tab as unknown as { preventDefault: boolean }).preventDefault = true + this.keys.tab.on('down', () => { + this.scene.start('ZoomMouse') + }) + this.input.on('wheel', ( ptr: Phaser.Input.Pointer, _objs: unknown, @@ -264,8 +271,9 @@ export class ZoomTestScene extends Phaser.Scene { const vpHeightPx = cam.height / cam.zoom const vpWidthTiles = vpWidthPx / TILE_SIZE const vpHeightTiles = vpHeightPx / TILE_SIZE - const centerWorldX = cam.scrollX + vpWidthPx / 2 - const centerWorldY = cam.scrollY + vpHeightPx / 2 + // Phaser zooms from screen center: visual center = scrollX + screenWidth/2 + const centerWorldX = cam.scrollX + cam.width / 2 + const centerWorldY = cam.scrollY + cam.height / 2 const mouseTileX = Math.floor(ptr.worldX / TILE_SIZE) const mouseTileY = Math.floor(ptr.worldY / TILE_SIZE) const centerTileX = Math.floor(centerWorldX / TILE_SIZE) @@ -294,7 +302,7 @@ export class ZoomTestScene extends Phaser.Scene { `roundPixels: ${(this.game.renderer.config as Record)['roundPixels']}`, `Renderer: ${renderer}`, '', - '[Scroll] Zoom [WASD / ↑↓←→] Pan', + '[Scroll] Zoom [WASD / ↑↓←→] Pan [Tab] → Mouse', ] this.logText.setText(lines) @@ -317,8 +325,8 @@ export class ZoomTestScene extends Phaser.Scene { vpWorld: { w: +vpW.toFixed(2), h: +vpH.toFixed(2) }, vpTiles: { w: +((vpW / TILE_SIZE).toFixed(3)), h: +((vpH / TILE_SIZE).toFixed(3)) }, centerWorld: { - x: +(cam.scrollX + vpW / 2).toFixed(2), - y: +(cam.scrollY + vpH / 2).toFixed(2), + x: +(cam.scrollX + cam.width / 2).toFixed(2), + y: +(cam.scrollY + cam.height / 2).toFixed(2), }, mouse: { screen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) }, diff --git a/src/test/main.ts b/src/test/main.ts index 2aa42ff..9b849d6 100644 --- a/src/test/main.ts +++ b/src/test/main.ts @@ -1,12 +1,13 @@ import Phaser from 'phaser' import { ZoomTestScene } from './ZoomTestScene' +import { ZoomMouseScene } from './ZoomMouseScene' const config: Phaser.Types.Core.GameConfig = { type: Phaser.AUTO, width: window.innerWidth, height: window.innerHeight, backgroundColor: '#0d1a0d', - scene: [ZoomTestScene], + scene: [ZoomTestScene, ZoomMouseScene], scale: { mode: Phaser.Scale.RESIZE, autoCenter: Phaser.Scale.CENTER_BOTH,