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 */ }) } }