From 715278ae78f96905880db1796c9758d6c208737d Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 19:19:44 +0000 Subject: [PATCH 01/21] =?UTF-8?q?=E2=9C=A8=20zoom=20to=20mouse=20pointer?= =?UTF-8?q?=20+=20middle-click=20pan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scroll wheel now zooms toward the mouse cursor instead of screen center - Middle mouse button held: pan camera by dragging - Both actions respect current zoom level --- src/systems/CameraSystem.ts | 49 ++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index 3eeea17..4a7ec2c 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -23,6 +23,9 @@ export class CameraSystem { } private saveTimer = 0 private readonly SAVE_TICK = 2000 + private middlePanActive = false + private lastPanX = 0 + private lastPanY = 0 constructor(scene: Phaser.Scene, adapter: LocalAdapter) { this.scene = scene @@ -49,10 +52,48 @@ export class CameraSystem { d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), } - // Scroll wheel zoom - this.scene.input.on('wheel', (_ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { - const zoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) - cam.setZoom(zoom) + // Scroll wheel zoom — zoom toward mouse pointer position + this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { + const oldZoom = cam.zoom + const newZoom = Phaser.Math.Clamp(oldZoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + if (newZoom === oldZoom) return + + // World point under mouse before zoom + const worldX = cam.scrollX + ptr.x / oldZoom + const worldY = cam.scrollY + ptr.y / oldZoom + + cam.setZoom(newZoom) + + // Adjust scroll so the same world point stays under the mouse + cam.scrollX = worldX - ptr.x / newZoom + cam.scrollY = worldY - ptr.y / newZoom + }) + + // Middle-click pan: start on button down + this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => { + if (ptr.middleButtonDown()) { + this.middlePanActive = true + this.lastPanX = ptr.x + this.lastPanY = ptr.y + } + }) + + // Middle-click pan: move camera while held + this.scene.input.on('pointermove', (ptr: Phaser.Input.Pointer) => { + if (!this.middlePanActive) return + const dx = (ptr.x - this.lastPanX) / cam.zoom + const dy = (ptr.y - this.lastPanY) / cam.zoom + cam.scrollX -= dx + cam.scrollY -= dy + this.lastPanX = ptr.x + this.lastPanY = ptr.y + }) + + // Middle-click pan: stop on button release + this.scene.input.on('pointerup', (ptr: Phaser.Input.Pointer) => { + if (this.middlePanActive && !ptr.middleButtonDown()) { + this.middlePanActive = false + } }) } From fa41075c55b3d1d55cb1db197b590169eb5a851a Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 19:19:53 +0000 Subject: [PATCH 02/21] =?UTF-8?q?=F0=9F=93=9D=20update=20CHANGELOG=20for?= =?UTF-8?q?=20Issue=20#5=20mouse=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1eee5f..7bb8722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Villagers are now called **Nisse** throughout the UI (panel, controls hint, stockpile display, context menu, spawn message) ### Added +- Scroll wheel now zooms toward the mouse cursor position instead of the screen center +- Middle mouse button held: pan the camera by dragging - Right-click context menu: suppresses browser default, shows Build and Nisse actions in the game world - Initial project setup: Phaser 3 + TypeScript + Vite - Core scenes: `BootScene`, `GameScene`, `UIScene` From f0065a0cda520d807ecd9176851c3e70d20964bb Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 19:29:53 +0000 Subject: [PATCH 03/21] =?UTF-8?q?=F0=9F=90=9B=20fix=20zoom-to-mouse=20usin?= =?UTF-8?q?g=20getWorldPoint=20diff=20instead=20of=20manual=20formula?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index 4a7ec2c..2b765c9 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -54,19 +54,20 @@ export class CameraSystem { // Scroll wheel zoom — zoom toward mouse pointer position this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { - const oldZoom = cam.zoom - const newZoom = Phaser.Math.Clamp(oldZoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) - if (newZoom === oldZoom) return + const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + if (newZoom === cam.zoom) return - // World point under mouse before zoom - const worldX = cam.scrollX + ptr.x / oldZoom - const worldY = cam.scrollY + ptr.y / oldZoom + // Sample the world point under the mouse BEFORE the zoom change + const before = cam.getWorldPoint(ptr.x, ptr.y) cam.setZoom(newZoom) - // Adjust scroll so the same world point stays under the mouse - cam.scrollX = worldX - ptr.x / newZoom - cam.scrollY = worldY - ptr.y / newZoom + // Sample the same screen position AFTER zoom — it now maps to a different world point + const after = cam.getWorldPoint(ptr.x, ptr.y) + + // Shift scroll by the difference so the original world point snaps back under the mouse + cam.scrollX -= after.x - before.x + cam.scrollY -= after.y - before.y }) // Middle-click pan: start on button down From b5130169bd45a58b5221a2b0aabb843eca5d9cf0 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 19:39:15 +0000 Subject: [PATCH 04/21] =?UTF-8?q?=F0=9F=90=9B=20fix=20zoom:=20center=20wor?= =?UTF-8?q?ld=20point=20under=20mouse,=20then=20zoom=20to=20center?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index 2b765c9..3c079f8 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -52,22 +52,16 @@ export class CameraSystem { d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), } - // Scroll wheel zoom — zoom toward mouse pointer position + // Scroll wheel zoom — centers the world point under the mouse, then zooms this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) if (newZoom === cam.zoom) return - // Sample the world point under the mouse BEFORE the zoom change - const before = cam.getWorldPoint(ptr.x, ptr.y) - + // Move the world point under the mouse to the viewport center, then zoom toward it + const wp = cam.getWorldPoint(ptr.x, ptr.y) + cam.scrollX = wp.x - cam.width / (2 * newZoom) + cam.scrollY = wp.y - cam.height / (2 * newZoom) cam.setZoom(newZoom) - - // Sample the same screen position AFTER zoom — it now maps to a different world point - const after = cam.getWorldPoint(ptr.x, ptr.y) - - // Shift scroll by the difference so the original world point snaps back under the mouse - cam.scrollX -= after.x - before.x - cam.scrollY -= after.y - before.y }) // Middle-click pan: start on button down From 216c70dbd9c3de378a41e4e355af6b18ab092505 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 20:15:13 +0000 Subject: [PATCH 05/21] =?UTF-8?q?=F0=9F=90=9B=20zoom-to-mouse:=20use=20ptr?= =?UTF-8?q?.worldX/Y=20+=20set=20scroll=20after=20setZoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index 3c079f8..a463c75 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -52,16 +52,19 @@ export class CameraSystem { d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), } - // Scroll wheel zoom — centers the world point under the mouse, then zooms + // Scroll wheel zoom — zoom toward mouse pointer this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) if (newZoom === cam.zoom) return - // Move the world point under the mouse to the viewport center, then zoom toward it - const wp = cam.getWorldPoint(ptr.x, ptr.y) - cam.scrollX = wp.x - cam.width / (2 * newZoom) - cam.scrollY = wp.y - cam.height / (2 * newZoom) + // ptr.worldX/Y is pre-computed by Phaser and represents the world position under the pointer + const worldX = ptr.worldX + const worldY = ptr.worldY + + // Apply zoom first, then reposition scroll so worldX stays under ptr.x cam.setZoom(newZoom) + cam.scrollX = worldX - ptr.x / newZoom + cam.scrollY = worldY - ptr.y / newZoom }) // Middle-click pan: start on button down From cccfd9ba731ca536ce746b657ca841c12bb06817 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 20:21:49 +0000 Subject: [PATCH 06/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20revert=20zoom=20to?= =?UTF-8?q?=20simple=20center=20zoom,=20remove=20mouse=20targeting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index a463c75..a41b456 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -52,19 +52,10 @@ export class CameraSystem { d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), } - // Scroll wheel zoom — zoom toward mouse pointer - this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { + // Scroll wheel zoom + this.scene.input.on('wheel', (_ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) - if (newZoom === cam.zoom) return - - // ptr.worldX/Y is pre-computed by Phaser and represents the world position under the pointer - const worldX = ptr.worldX - const worldY = ptr.worldY - - // Apply zoom first, then reposition scroll so worldX stays under ptr.x cam.setZoom(newZoom) - cam.scrollX = worldX - ptr.x / newZoom - cam.scrollY = worldY - ptr.y / newZoom }) // Middle-click pan: start on button down From 0e4c7c96ee5acdb695b7b23e0d48cf7b31d4e0ac Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 20:34:32 +0000 Subject: [PATCH 07/21] =?UTF-8?q?=F0=9F=90=9B=20debug:=20log=20mouse+cente?= =?UTF-8?q?r=20on=20zoom,=20draw=20red=20cross=20at=20viewport=20center?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index a41b456..51df78c 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -26,6 +26,7 @@ export class CameraSystem { private middlePanActive = false private lastPanX = 0 private lastPanY = 0 + private debugCross?: Phaser.GameObjects.Graphics constructor(scene: Phaser.Scene, adapter: LocalAdapter) { this.scene = scene @@ -52,10 +53,22 @@ export class CameraSystem { d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), } + // Debug cross at viewport center (fixed to screen, not world) + this.debugCross = this.scene.add.graphics() + this.debugCross.setScrollFactor(0).setDepth(999) + this.debugCross.lineStyle(2, 0xff0000, 1) + const cx = cam.width / 2 + const cy = cam.height / 2 + this.debugCross.lineBetween(cx - 12, cy, cx + 12, cy) + this.debugCross.lineBetween(cx, cy - 12, cx, cy + 12) + // Scroll wheel zoom - this.scene.input.on('wheel', (_ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { + this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) cam.setZoom(newZoom) + const centerX = cam.scrollX + cam.width / (2 * cam.zoom) + const centerY = cam.scrollY + cam.height / (2 * cam.zoom) + console.log(`[zoom] ptr.x=${ptr.x.toFixed(0)} ptr.y=${ptr.y.toFixed(0)} | ptr.worldX=${ptr.worldX.toFixed(0)} ptr.worldY=${ptr.worldY.toFixed(0)} | center=(${centerX.toFixed(0)}, ${centerY.toFixed(0)}) zoom=${cam.zoom.toFixed(2)}`) }) // Middle-click pan: start on button down From fb4abb72568183018fa8ad316b2687f5de497d11 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 20:37:11 +0000 Subject: [PATCH 08/21] =?UTF-8?q?=F0=9F=90=9B=20zoom-to-mouse:=20ptr.world?= =?UTF-8?q?X/Y=20formula,=20debug=20log=20still=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index 51df78c..3e1d873 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -62,13 +62,16 @@ export class CameraSystem { this.debugCross.lineBetween(cx - 12, cy, cx + 12, cy) this.debugCross.lineBetween(cx, cy - 12, cx, cy + 12) - // Scroll wheel zoom + // Scroll wheel zoom — zoom toward mouse pointer this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + if (newZoom === cam.zoom) return cam.setZoom(newZoom) + cam.scrollX = ptr.worldX - ptr.x / newZoom + cam.scrollY = ptr.worldY - ptr.y / newZoom const centerX = cam.scrollX + cam.width / (2 * cam.zoom) const centerY = cam.scrollY + cam.height / (2 * cam.zoom) - console.log(`[zoom] ptr.x=${ptr.x.toFixed(0)} ptr.y=${ptr.y.toFixed(0)} | ptr.worldX=${ptr.worldX.toFixed(0)} ptr.worldY=${ptr.worldY.toFixed(0)} | center=(${centerX.toFixed(0)}, ${centerY.toFixed(0)}) zoom=${cam.zoom.toFixed(2)}`) + console.log(`[zoom] ptr=(${ptr.x.toFixed(0)},${ptr.y.toFixed(0)}) world=(${ptr.worldX.toFixed(0)},${ptr.worldY.toFixed(0)}) center=(${centerX.toFixed(0)},${centerY.toFixed(0)}) zoom=${cam.zoom.toFixed(2)}`) }) // Middle-click pan: start on button down From d354a26a8038f52eeaf039f86b0c392a7dea8446 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 20:39:53 +0000 Subject: [PATCH 09/21] =?UTF-8?q?=F0=9F=90=9B=20fix=20zoom-to-mouse:=20cap?= =?UTF-8?q?ture=20worldX/Y=20before=20setZoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index 3e1d873..ca995ef 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -66,9 +66,11 @@ export class CameraSystem { this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) if (newZoom === cam.zoom) return + const worldX = ptr.worldX // capture BEFORE zoom changes the value + const worldY = ptr.worldY cam.setZoom(newZoom) - cam.scrollX = ptr.worldX - ptr.x / newZoom - cam.scrollY = ptr.worldY - ptr.y / newZoom + cam.scrollX = worldX - ptr.x / newZoom + cam.scrollY = worldY - ptr.y / newZoom const centerX = cam.scrollX + cam.width / (2 * cam.zoom) const centerY = cam.scrollY + cam.height / (2 * cam.zoom) console.log(`[zoom] ptr=(${ptr.x.toFixed(0)},${ptr.y.toFixed(0)}) world=(${ptr.worldX.toFixed(0)},${ptr.worldY.toFixed(0)}) center=(${centerX.toFixed(0)},${centerY.toFixed(0)}) zoom=${cam.zoom.toFixed(2)}`) From 6de4c1cbb9b39fd84c5096b7a755365d169cd916 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 20:45:18 +0000 Subject: [PATCH 10/21] =?UTF-8?q?=F0=9F=90=9B=20zoom-to-mouse:=20track=20w?= =?UTF-8?q?orld=20coords=20on=20pointermove,=20avoid=20ptr.worldX=20getter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index ca995ef..7d507fd 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -26,6 +26,10 @@ export class CameraSystem { private middlePanActive = false private lastPanX = 0 private lastPanY = 0 + private mouseScreenX = 0 + private mouseScreenY = 0 + private mouseWorldX = 0 + private mouseWorldY = 0 private debugCross?: Phaser.GameObjects.Graphics constructor(scene: Phaser.Scene, adapter: LocalAdapter) { @@ -62,18 +66,24 @@ export class CameraSystem { this.debugCross.lineBetween(cx - 12, cy, cx + 12, cy) this.debugCross.lineBetween(cx, cy - 12, cx, cy + 12) + // Track mouse world position on every move so we have a stable value for zoom + this.scene.input.on('pointermove', (ptr: Phaser.Input.Pointer) => { + this.mouseScreenX = ptr.x + this.mouseScreenY = ptr.y + this.mouseWorldX = cam.scrollX + ptr.x / cam.zoom + this.mouseWorldY = cam.scrollY + ptr.y / cam.zoom + }) + // Scroll wheel zoom — zoom toward mouse pointer - this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { + this.scene.input.on('wheel', (_ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) if (newZoom === cam.zoom) return - const worldX = ptr.worldX // capture BEFORE zoom changes the value - const worldY = ptr.worldY cam.setZoom(newZoom) - cam.scrollX = worldX - ptr.x / newZoom - cam.scrollY = worldY - ptr.y / newZoom + cam.scrollX = this.mouseWorldX - this.mouseScreenX / newZoom + cam.scrollY = this.mouseWorldY - this.mouseScreenY / newZoom const centerX = cam.scrollX + cam.width / (2 * cam.zoom) const centerY = cam.scrollY + cam.height / (2 * cam.zoom) - console.log(`[zoom] ptr=(${ptr.x.toFixed(0)},${ptr.y.toFixed(0)}) world=(${ptr.worldX.toFixed(0)},${ptr.worldY.toFixed(0)}) center=(${centerX.toFixed(0)},${centerY.toFixed(0)}) zoom=${cam.zoom.toFixed(2)}`) + console.log(`[zoom] screen=(${this.mouseScreenX.toFixed(0)},${this.mouseScreenY.toFixed(0)}) world=(${this.mouseWorldX.toFixed(0)},${this.mouseWorldY.toFixed(0)}) center=(${centerX.toFixed(0)},${centerY.toFixed(0)}) zoom=${cam.zoom.toFixed(2)}`) }) // Middle-click pan: start on button down From 6fa3ae4465808cb33286dde98375221324c63d18 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 20:57:46 +0000 Subject: [PATCH 11/21] =?UTF-8?q?=F0=9F=90=9B=20fix=20debug=20cross:=20wor?= =?UTF-8?q?ld-space=20position=20+=20counter-scale,=20tracks=20viewport=20?= =?UTF-8?q?center=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index 7d507fd..68018c8 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -57,14 +57,12 @@ export class CameraSystem { d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), } - // Debug cross at viewport center (fixed to screen, not world) + // Debug cross — positioned in world space at viewport center, updated each frame this.debugCross = this.scene.add.graphics() - this.debugCross.setScrollFactor(0).setDepth(999) + this.debugCross.setDepth(999) this.debugCross.lineStyle(2, 0xff0000, 1) - const cx = cam.width / 2 - const cy = cam.height / 2 - this.debugCross.lineBetween(cx - 12, cy, cx + 12, cy) - this.debugCross.lineBetween(cx, cy - 12, cx, cy + 12) + this.debugCross.lineBetween(-12, 0, 12, 0) + this.debugCross.lineBetween(0, -12, 0, 12) // Track mouse world position on every move so we have a stable value for zoom this.scene.input.on('pointermove', (ptr: Phaser.Input.Pointer) => { @@ -136,6 +134,15 @@ export class CameraSystem { cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldW - cam.width / cam.zoom) cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldH - cam.height / cam.zoom) + // Keep debug cross at viewport center in world space, counter-scale so it stays 1px + if (this.debugCross) { + this.debugCross.setPosition( + cam.scrollX + cam.width / (2 * cam.zoom), + cam.scrollY + cam.height / (2 * cam.zoom), + ) + this.debugCross.setScale(1 / cam.zoom) + } + // Periodically save camera center as "player position" this.saveTimer += delta if (this.saveTimer >= this.SAVE_TICK) { From 0011bc9877348ffce76208ddfef664f2cad933c9 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 21:06:14 +0000 Subject: [PATCH 12/21] =?UTF-8?q?=F0=9F=90=9B=20fix=20debug=20cross:=20cle?= =?UTF-8?q?ar+redraw=20each=20frame=20at=20world-space=20center,=20no=20tr?= =?UTF-8?q?ansforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index 68018c8..c8ab1e6 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -57,12 +57,9 @@ export class CameraSystem { d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), } - // Debug cross — positioned in world space at viewport center, updated each frame + // Debug cross — redrawn every frame in world space, no transforms needed this.debugCross = this.scene.add.graphics() this.debugCross.setDepth(999) - this.debugCross.lineStyle(2, 0xff0000, 1) - this.debugCross.lineBetween(-12, 0, 12, 0) - this.debugCross.lineBetween(0, -12, 0, 12) // Track mouse world position on every move so we have a stable value for zoom this.scene.input.on('pointermove', (ptr: Phaser.Input.Pointer) => { @@ -134,13 +131,15 @@ export class CameraSystem { cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldW - cam.width / cam.zoom) cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldH - cam.height / cam.zoom) - // Keep debug cross at viewport center in world space, counter-scale so it stays 1px + // Redraw debug cross every frame at viewport center in world space if (this.debugCross) { - this.debugCross.setPosition( - cam.scrollX + cam.width / (2 * cam.zoom), - cam.scrollY + cam.height / (2 * cam.zoom), - ) - this.debugCross.setScale(1 / cam.zoom) + const cx = cam.scrollX + cam.width / (2 * cam.zoom) + const cy = cam.scrollY + cam.height / (2 * cam.zoom) + const arm = 12 / cam.zoom // 12 screen pixels at any zoom + this.debugCross.clear() + this.debugCross.lineStyle(2 / cam.zoom, 0xff0000, 1) + this.debugCross.lineBetween(cx - arm, cy, cx + arm, cy) + this.debugCross.lineBetween(cx, cy - arm, cx, cy + arm) } // Periodically save camera center as "player position" From 34220818b08583e9431e5471805eb3519c58ad40 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 21:09:13 +0000 Subject: [PATCH 13/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20revert=20zoom=20to?= =?UTF-8?q?=20center-only,=20keep=20middle-click=20pan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/CameraSystem.ts | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index c8ab1e6..a41b456 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -26,11 +26,6 @@ export class CameraSystem { private middlePanActive = false private lastPanX = 0 private lastPanY = 0 - private mouseScreenX = 0 - private mouseScreenY = 0 - private mouseWorldX = 0 - private mouseWorldY = 0 - private debugCross?: Phaser.GameObjects.Graphics constructor(scene: Phaser.Scene, adapter: LocalAdapter) { this.scene = scene @@ -57,28 +52,10 @@ export class CameraSystem { d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), } - // Debug cross — redrawn every frame in world space, no transforms needed - this.debugCross = this.scene.add.graphics() - this.debugCross.setDepth(999) - - // Track mouse world position on every move so we have a stable value for zoom - this.scene.input.on('pointermove', (ptr: Phaser.Input.Pointer) => { - this.mouseScreenX = ptr.x - this.mouseScreenY = ptr.y - this.mouseWorldX = cam.scrollX + ptr.x / cam.zoom - this.mouseWorldY = cam.scrollY + ptr.y / cam.zoom - }) - - // Scroll wheel zoom — zoom toward mouse pointer + // Scroll wheel zoom this.scene.input.on('wheel', (_ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) - if (newZoom === cam.zoom) return cam.setZoom(newZoom) - cam.scrollX = this.mouseWorldX - this.mouseScreenX / newZoom - cam.scrollY = this.mouseWorldY - this.mouseScreenY / newZoom - const centerX = cam.scrollX + cam.width / (2 * cam.zoom) - const centerY = cam.scrollY + cam.height / (2 * cam.zoom) - console.log(`[zoom] screen=(${this.mouseScreenX.toFixed(0)},${this.mouseScreenY.toFixed(0)}) world=(${this.mouseWorldX.toFixed(0)},${this.mouseWorldY.toFixed(0)}) center=(${centerX.toFixed(0)},${centerY.toFixed(0)}) zoom=${cam.zoom.toFixed(2)}`) }) // Middle-click pan: start on button down @@ -131,17 +108,6 @@ export class CameraSystem { cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldW - cam.width / cam.zoom) cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldH - cam.height / cam.zoom) - // Redraw debug cross every frame at viewport center in world space - if (this.debugCross) { - const cx = cam.scrollX + cam.width / (2 * cam.zoom) - const cy = cam.scrollY + cam.height / (2 * cam.zoom) - const arm = 12 / cam.zoom // 12 screen pixels at any zoom - this.debugCross.clear() - this.debugCross.lineStyle(2 / cam.zoom, 0xff0000, 1) - this.debugCross.lineBetween(cx - arm, cy, cx + arm, cy) - this.debugCross.lineBetween(cx, cy - arm, cx, cy + arm) - } - // Periodically save camera center as "player position" this.saveTimer += delta if (this.saveTimer >= this.SAVE_TICK) { From 007d5b3feebcf99c48d882e0a342eff82f457b53 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Sat, 21 Mar 2026 11:16:39 +0000 Subject: [PATCH 14/21] =?UTF-8?q?=E2=9C=A8=20add=20ZoomTestScene=20with=20?= =?UTF-8?q?Phaser=20default=20zoom=20for=20analysis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate test environment at /test.html (own Vite entry, own Phaser instance). ZoomTestScene renders a 50×50 tile grid with crosshair markers and a live HUD overlay showing zoom, scroll, viewport in px and tiles, mouse world/screen/tile coords, and renderer info. Zoom uses plain cam.setZoom() — no mouse tracking — to observe Phaser's default center-anchor behavior. --- src/test/ZoomTestScene.ts | 239 ++++++++++++++++++++++++++++++++++++++ src/test/main.ts | 21 ++++ test.html | 16 +++ vite.config.ts | 11 +- 4 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 src/test/ZoomTestScene.ts create mode 100644 src/test/main.ts create mode 100644 test.html diff --git a/src/test/ZoomTestScene.ts b/src/test/ZoomTestScene.ts new file mode 100644 index 0000000..4468841 --- /dev/null +++ b/src/test/ZoomTestScene.ts @@ -0,0 +1,239 @@ +import Phaser from 'phaser' +import { TILE_SIZE } from '../config' + +const GRID_TILES = 50 // world size in tiles +const MIN_ZOOM = 0.25 +const MAX_ZOOM = 4.0 +const ZOOM_STEP = 0.1 +const MARKER_EVERY = 5 // small crosshair every N tiles +const LABEL_EVERY = 10 // coordinate label every N tiles +const CAMERA_SPEED = 400 // px/s + +/** + * First test scene: observes pure Phaser default zoom behavior. + * No custom scroll compensation — cam.setZoom() only, zoom anchors to camera center. + * + * Controls: Scroll wheel to zoom, WASD / Arrow keys to pan. + */ +export class ZoomTestScene extends Phaser.Scene { + private logText!: Phaser.GameObjects.Text + 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 + } + + constructor() { + super({ key: 'ZoomTest' }) + } + + create(): void { + this.drawGrid() + this.setupCamera() + this.setupInput() + this.createOverlay() + } + + /** + * Draws the static world grid into world space. + * - Faint tile lines on every tile boundary + * - Small green crosshairs every MARKER_EVERY tiles + * - Yellow labeled crosshairs every LABEL_EVERY tiles + * - Red world border + */ + private drawGrid(): void { + const worldPx = GRID_TILES * TILE_SIZE + const g = this.add.graphics() + + // Background fill + g.fillStyle(0x111811) + g.fillRect(0, 0, worldPx, worldPx) + + // Tile grid lines + g.lineStyle(1, 0x223322, 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) + } + + // Crosshair markers + 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 : 0x00ff88 + 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) + } + } + + // Coordinate labels at LABEL_EVERY intersections + for (let tx = 0; tx <= GRID_TILES; tx += LABEL_EVERY) { + for (let ty = 0; ty <= GRID_TILES; ty += LABEL_EVERY) { + this.add.text( + tx * TILE_SIZE + 4, + ty * TILE_SIZE + 4, + `${tx},${ty}`, + { fontSize: '9px', color: '#ffff88', fontFamily: 'monospace' } + ).setDepth(1) + } + } + + // World border + g.lineStyle(2, 0xff4444, 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 and stores keyboard key references. + * Zoom uses cam.setZoom() only — pure Phaser default, anchors to camera center. + */ + 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), + } + + this.input.on('wheel', ( + _ptr: Phaser.Input.Pointer, + _objs: unknown, + _dx: number, + dy: number + ) => { + const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + cam.setZoom(newZoom) + }) + } + + /** + * Creates the fixed HUD text overlay (scroll factor 0 = screen space). + */ + private createOverlay(): void { + this.logText = this.add.text(10, 10, '', { + fontSize: '13px', + color: '#e8e8e8', + backgroundColor: '#000000bb', + padding: { x: 10, y: 8 }, + lineSpacing: 3, + fontFamily: 'monospace', + }) + .setScrollFactor(0) + .setDepth(100) + } + + update(_time: number, delta: number): void { + this.handleKeyboard(delta) + this.updateOverlay() + } + + /** + * 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. + */ + private updateOverlay(): void { + const cam = this.cameras.main + const ptr = this.input.activePointer + + // Viewport size in world pixels (what is actually visible) + const vpWidthPx = cam.width / cam.zoom + const vpHeightPx = cam.height / cam.zoom + + // Viewport size in tiles + const vpWidthTiles = vpWidthPx / TILE_SIZE + const vpHeightTiles = vpHeightPx / TILE_SIZE + + // Camera center in world coords + const centerWorldX = cam.scrollX + vpWidthPx / 2 + const centerWorldY = cam.scrollY + vpHeightPx / 2 + + // Tile under mouse + const mouseTileX = Math.floor(ptr.worldX / TILE_SIZE) + const mouseTileY = Math.floor(ptr.worldY / TILE_SIZE) + + // Tile at camera center + const centerTileX = Math.floor(centerWorldX / TILE_SIZE) + const centerTileY = Math.floor(centerWorldY / TILE_SIZE) + + const renderer = this.game.renderer.type === Phaser.WEBGL ? 'WebGL' : 'Canvas' + + const lines = [ + '── ZOOM TEST [Phaser default] ──', + '', + `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', + ] + + this.logText.setText(lines) + } +} diff --git a/src/test/main.ts b/src/test/main.ts new file mode 100644 index 0000000..2aa42ff --- /dev/null +++ b/src/test/main.ts @@ -0,0 +1,21 @@ +import Phaser from 'phaser' +import { ZoomTestScene } from './ZoomTestScene' + +const config: Phaser.Types.Core.GameConfig = { + type: Phaser.AUTO, + width: window.innerWidth, + height: window.innerHeight, + backgroundColor: '#0d1a0d', + scene: [ZoomTestScene], + scale: { + mode: Phaser.Scale.RESIZE, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + render: { + pixelArt: false, + antialias: true, + roundPixels: true, + }, +} + +new Phaser.Game(config) diff --git a/test.html b/test.html new file mode 100644 index 0000000..1eebe4a --- /dev/null +++ b/test.html @@ -0,0 +1,16 @@ + + + + + + Game — Test Scenes + + + + + + diff --git a/vite.config.ts b/vite.config.ts index b6ab531..6d5059d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vite' +import { resolve } from 'path' export default defineConfig({ server: { @@ -7,6 +8,12 @@ export default defineConfig({ }, build: { outDir: 'dist', - assetsInlineLimit: 0 - } + assetsInlineLimit: 0, + rollupOptions: { + input: { + main: resolve(__dirname, 'index.html'), + test: resolve(__dirname, 'test.html'), + }, + }, + }, }) From 7c130763b51678de07671ebc0588ef8300f36702 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Sat, 21 Mar 2026 11:19:54 +0000 Subject: [PATCH 15/21] =?UTF-8?q?=E2=9C=A8=20add=20file=20logging=20via=20?= =?UTF-8?q?Vite=20middleware=20to=20ZoomTestScene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite dev server gets a /api/log middleware (POST appends to game-test.log, DELETE clears it). ZoomTestScene writes a zoom event with before/after state on every scroll, plus a full snapshot every 2 seconds. Log entries are newline-delimited JSON. --- src/test/ZoomTestScene.ts | 102 +++++++++++++++++++++++++++++++++++--- vite.config.ts | 30 ++++++++++- 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/test/ZoomTestScene.ts b/src/test/ZoomTestScene.ts index 4468841..f848663 100644 --- a/src/test/ZoomTestScene.ts +++ b/src/test/ZoomTestScene.ts @@ -1,17 +1,19 @@ import Phaser from 'phaser' import { TILE_SIZE } from '../config' -const GRID_TILES = 50 // world size in tiles -const MIN_ZOOM = 0.25 -const MAX_ZOOM = 4.0 -const ZOOM_STEP = 0.1 -const MARKER_EVERY = 5 // small crosshair every N tiles -const LABEL_EVERY = 10 // coordinate label every N tiles -const CAMERA_SPEED = 400 // px/s +const GRID_TILES = 50 // world size in tiles +const MIN_ZOOM = 0.25 +const MAX_ZOOM = 4.0 +const ZOOM_STEP = 0.1 +const MARKER_EVERY = 5 // small crosshair every N tiles +const LABEL_EVERY = 10 // coordinate label every N tiles +const CAMERA_SPEED = 400 // px/s +const SNAPSHOT_EVERY = 2000 // ms between periodic log snapshots /** * First test scene: observes pure Phaser default zoom behavior. * No custom scroll compensation — cam.setZoom() only, zoom anchors to camera center. + * Logs zoom events and periodic snapshots to /api/log (written to game-test.log). * * Controls: Scroll wheel to zoom, WASD / Arrow keys to pan. */ @@ -27,12 +29,17 @@ export class ZoomTestScene extends Phaser.Scene { a: Phaser.Input.Keyboard.Key d: Phaser.Input.Keyboard.Key } + private snapshotTimer = 0 constructor() { super({ key: 'ZoomTest' }) } create(): void { + // Clear log file at scene start + fetch('/api/log', { method: 'DELETE' }) + this.writeLog('scene_start', { tileSize: TILE_SIZE, gridTiles: GRID_TILES }) + this.drawGrid() this.setupCamera() this.setupInput() @@ -111,6 +118,7 @@ export class ZoomTestScene extends Phaser.Scene { /** * Registers scroll wheel zoom and stores keyboard key references. * Zoom uses cam.setZoom() only — pure Phaser default, anchors to camera center. + * Each zoom event is logged immediately with before/after state. */ private setupInput(): void { const cam = this.cameras.main @@ -128,13 +136,42 @@ export class ZoomTestScene extends Phaser.Scene { } this.input.on('wheel', ( - _ptr: Phaser.Input.Pointer, + 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(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) cam.setZoom(newZoom) + + // Log after Phaser has applied the zoom (next microtask so values are updated) + 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: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) }, + centerWorld_after: { + x: +(cam.scrollX + (cam.width / cam.zoom) / 2).toFixed(2), + y: +(cam.scrollY + (cam.height / cam.zoom) / 2).toFixed(2), + }, + vpTiles_after: { + w: +((cam.width / cam.zoom) / TILE_SIZE).toFixed(3), + h: +((cam.height / cam.zoom) / TILE_SIZE).toFixed(3), + }, + }) + }, 0) }) } @@ -157,6 +194,13 @@ export class ZoomTestScene extends Phaser.Scene { update(_time: number, delta: number): void { this.handleKeyboard(delta) this.updateOverlay() + + // Periodic snapshot + this.snapshotTimer += delta + if (this.snapshotTimer >= SNAPSHOT_EVERY) { + this.snapshotTimer = 0 + this.writeSnapshot() + } } /** @@ -236,4 +280,46 @@ export class ZoomTestScene extends Phaser.Scene { 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 + vpW / 2).toFixed(2), + y: +(cam.scrollY + vpH / 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. + * Written to game-test.log in the project root. + * @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/vite.config.ts b/vite.config.ts index 6d5059d..61741bc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,39 @@ import { defineConfig } from 'vite' import { resolve } from 'path' +import fs from 'fs' + +const LOG_FILE = resolve(__dirname, 'game-test.log') export default defineConfig({ server: { port: 3000, - host: true + host: true, }, + plugins: [ + { + name: 'game-logger', + configureServer(server) { + server.middlewares.use('/api/log', (req, res) => { + if (req.method === 'POST') { + let body = '' + req.on('data', chunk => { body += chunk }) + req.on('end', () => { + fs.appendFileSync(LOG_FILE, body + '\n', 'utf8') + res.writeHead(200) + res.end('ok') + }) + } else if (req.method === 'DELETE') { + fs.writeFileSync(LOG_FILE, '', 'utf8') + res.writeHead(200) + res.end('cleared') + } else { + res.writeHead(405) + res.end() + } + }) + }, + }, + ], build: { outDir: 'dist', assetsInlineLimit: 0, From a93e8a2c5d0047b158fc659bec6b6a49f932e4b4 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Sat, 21 Mar 2026 11:34:04 +0000 Subject: [PATCH 16/21] =?UTF-8?q?=F0=9F=90=9B=20fix=20HUD=20overlay=20zoom?= =?UTF-8?q?=20+=20add=20red=20center=20crosshair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text overlay now uses a dedicated HUD camera (zoom=1, fixed scroll) so it's never scaled by the world zoom. World objects and HUD objects are separated via camera ignore lists. Added red screen-center crosshair to HUD layer as a precise alignment reference. --- src/test/ZoomTestScene.ts | 115 ++++++++++++++++++++++---------------- 1 file changed, 67 insertions(+), 48 deletions(-) diff --git a/src/test/ZoomTestScene.ts b/src/test/ZoomTestScene.ts index f848663..2008daa 100644 --- a/src/test/ZoomTestScene.ts +++ b/src/test/ZoomTestScene.ts @@ -1,14 +1,14 @@ import Phaser from 'phaser' import { TILE_SIZE } from '../config' -const GRID_TILES = 50 // world size in tiles -const MIN_ZOOM = 0.25 -const MAX_ZOOM = 4.0 -const ZOOM_STEP = 0.1 -const MARKER_EVERY = 5 // small crosshair every N tiles -const LABEL_EVERY = 10 // coordinate label every N tiles -const CAMERA_SPEED = 400 // px/s -const SNAPSHOT_EVERY = 2000 // ms between periodic log snapshots +const GRID_TILES = 50 // world size in tiles +const MIN_ZOOM = 0.25 +const MAX_ZOOM = 4.0 +const ZOOM_STEP = 0.1 +const MARKER_EVERY = 5 // small crosshair every N tiles +const LABEL_EVERY = 10 // coordinate label every N tiles +const CAMERA_SPEED = 400 // px/s +const SNAPSHOT_EVERY = 2000 // ms between periodic log snapshots /** * First test scene: observes pure Phaser default zoom behavior. @@ -19,6 +19,9 @@ const SNAPSHOT_EVERY = 2000 // ms between periodic log snapshots */ export class ZoomTestScene 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 @@ -43,19 +46,18 @@ export class ZoomTestScene extends Phaser.Scene { this.drawGrid() this.setupCamera() this.setupInput() - this.createOverlay() + this.createHUD() + this.setupCameras() } /** * Draws the static world grid into world space. - * - Faint tile lines on every tile boundary - * - Small green crosshairs every MARKER_EVERY tiles - * - Yellow labeled crosshairs every LABEL_EVERY tiles - * - Red world border + * 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) // Background fill g.fillStyle(0x111811) @@ -90,12 +92,13 @@ export class ZoomTestScene extends Phaser.Scene { // Coordinate labels at LABEL_EVERY intersections for (let tx = 0; tx <= GRID_TILES; tx += LABEL_EVERY) { for (let ty = 0; ty <= GRID_TILES; ty += LABEL_EVERY) { - this.add.text( + const label = this.add.text( tx * TILE_SIZE + 4, ty * TILE_SIZE + 4, `${tx},${ty}`, { fontSize: '9px', color: '#ffff88', fontFamily: 'monospace' } ).setDepth(1) + this.worldObjects.push(label) } } @@ -141,27 +144,26 @@ export class ZoomTestScene extends Phaser.Scene { _dx: number, dy: number ) => { - const zoomBefore = cam.zoom + const zoomBefore = cam.zoom const scrollXBefore = cam.scrollX const scrollYBefore = cam.scrollY const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) cam.setZoom(newZoom) - // Log after Phaser has applied the zoom (next microtask so values are updated) setTimeout(() => { this.writeLog('zoom', { - direction: dy > 0 ? 'out' : 'in', - zoomBefore: +zoomBefore.toFixed(4), - zoomAfter: +cam.zoom.toFixed(4), + 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: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) }, + mouseScreen: { x: +ptr.x.toFixed(1), y: +ptr.y.toFixed(1) }, + mouseWorld: { x: +ptr.worldX.toFixed(2), y: +ptr.worldY.toFixed(2) }, centerWorld_after: { x: +(cam.scrollX + (cam.width / cam.zoom) / 2).toFixed(2), y: +(cam.scrollY + (cam.height / cam.zoom) / 2).toFixed(2), @@ -176,9 +178,25 @@ export class ZoomTestScene extends Phaser.Scene { } /** - * Creates the fixed HUD text overlay (scroll factor 0 = screen space). + * Creates all HUD elements: log overlay and screen-center crosshair. + * All objects are registered in hudObjects for main-camera exclusion. + * Uses a dedicated HUD camera (zoom=1, fixed) so elements are never scaled. */ - private createOverlay(): void { + private createHUD(): void { + const w = this.scale.width + const h = this.scale.height + + // Screen-center crosshair (red) + 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) + + // Log text overlay this.logText = this.add.text(10, 10, '', { fontSize: '13px', color: '#e8e8e8', @@ -186,16 +204,27 @@ export class ZoomTestScene extends Phaser.Scene { padding: { x: 10, y: 8 }, lineSpacing: 3, fontFamily: 'monospace', - }) - .setScrollFactor(0) - .setDepth(100) + }).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() - // Periodic snapshot this.snapshotTimer += delta if (this.snapshotTimer >= SNAPSHOT_EVERY) { this.snapshotTimer = 0 @@ -208,8 +237,8 @@ export class ZoomTestScene extends Phaser.Scene { * @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 cam = this.cameras.main + const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom const worldPx = GRID_TILES * TILE_SIZE let dx = 0, dy = 0 @@ -231,27 +260,17 @@ export class ZoomTestScene extends Phaser.Scene { const cam = this.cameras.main const ptr = this.input.activePointer - // Viewport size in world pixels (what is actually visible) - const vpWidthPx = cam.width / cam.zoom - const vpHeightPx = cam.height / cam.zoom - - // Viewport size in tiles + const vpWidthPx = cam.width / cam.zoom + const vpHeightPx = cam.height / cam.zoom const vpWidthTiles = vpWidthPx / TILE_SIZE const vpHeightTiles = vpHeightPx / TILE_SIZE - - // Camera center in world coords - const centerWorldX = cam.scrollX + vpWidthPx / 2 - const centerWorldY = cam.scrollY + vpHeightPx / 2 - - // Tile under mouse - const mouseTileX = Math.floor(ptr.worldX / TILE_SIZE) - const mouseTileY = Math.floor(ptr.worldY / TILE_SIZE) - - // Tile at camera center - const centerTileX = Math.floor(centerWorldX / TILE_SIZE) - const centerTileY = Math.floor(centerWorldY / TILE_SIZE) - - const renderer = this.game.renderer.type === Phaser.WEBGL ? 'WebGL' : 'Canvas' + const centerWorldX = cam.scrollX + vpWidthPx / 2 + const centerWorldY = cam.scrollY + vpHeightPx / 2 + const mouseTileX = Math.floor(ptr.worldX / TILE_SIZE) + const mouseTileY = Math.floor(ptr.worldY / TILE_SIZE) + const centerTileX = Math.floor(centerWorldX / TILE_SIZE) + const centerTileY = Math.floor(centerWorldY / TILE_SIZE) + const renderer = this.game.renderer.type === Phaser.WEBGL ? 'WebGL' : 'Canvas' const lines = [ '── ZOOM TEST [Phaser default] ──', From d83b97a4475b768998d69a03dc63080ed9cf6c28 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Sat, 21 Mar 2026 11:40:11 +0000 Subject: [PATCH 17/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20increase=20test=20wo?= =?UTF-8?q?rld=20to=20500=C3=97500=20tiles,=20adjust=20marker=20intervals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/ZoomTestScene.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/ZoomTestScene.ts b/src/test/ZoomTestScene.ts index 2008daa..ea78a36 100644 --- a/src/test/ZoomTestScene.ts +++ b/src/test/ZoomTestScene.ts @@ -1,12 +1,12 @@ import Phaser from 'phaser' import { TILE_SIZE } from '../config' -const GRID_TILES = 50 // world size in tiles +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 = 5 // small crosshair every N tiles -const LABEL_EVERY = 10 // coordinate label every N tiles +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 From 7f0ef0554e77b88222731129debc669c27ea4f34 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Sat, 21 Mar 2026 11:49:39 +0000 Subject: [PATCH 18/21] =?UTF-8?q?=E2=9C=A8=20add=20ZoomMouseScene=20with?= =?UTF-8?q?=20zoom-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, From 3fdf621966653f57d71a2102adc312176e175ae4 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Sat, 21 Mar 2026 11:53:00 +0000 Subject: [PATCH 19/21] =?UTF-8?q?=E2=9C=A8=20implement=20zoom-to-mouse=20i?= =?UTF-8?q?n=20CameraSystem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces plain cam.setZoom() with zoom-to-mouse: after each zoom step the scroll is corrected by (mouseOffset from center) * (1/zBefore - 1/zAfter), keeping the world point under the cursor fixed. Also fixes getCenterWorld() which previously divided by zoom incorrectly. Added JSDoc to all methods. --- src/systems/CameraSystem.ts | 46 ++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts index a41b456..98c89dd 100644 --- a/src/systems/CameraSystem.ts +++ b/src/systems/CameraSystem.ts @@ -27,11 +27,19 @@ export class CameraSystem { private lastPanX = 0 private lastPanY = 0 + /** + * @param scene - The Phaser scene this system belongs to + * @param adapter - Network adapter used to persist camera position + */ constructor(scene: Phaser.Scene, adapter: LocalAdapter) { this.scene = scene this.adapter = adapter } + /** + * Initializes the camera: restores saved position, registers keyboard keys, + * sets up scroll-wheel zoom-to-mouse, and middle-click pan. + */ create(): void { const state = stateManager.getState() const cam = this.scene.cameras.main @@ -52,10 +60,22 @@ export class CameraSystem { d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), } - // Scroll wheel zoom - this.scene.input.on('wheel', (_ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { - const newZoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + // Scroll wheel: zoom-to-mouse. + // Phaser zooms from the screen center, so the world point under the mouse + // is corrected by shifting scroll by the mouse offset from center. + this.scene.input.on('wheel', (ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { + const zoomBefore = cam.zoom + const newZoom = Phaser.Math.Clamp(zoomBefore - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) cam.setZoom(newZoom) + + const factor = 1 / zoomBefore - 1 / newZoom + cam.scrollX += (ptr.x - cam.width / 2) * factor + cam.scrollY += (ptr.y - cam.height / 2) * factor + + const worldW = WORLD_TILES * 32 + const worldH = WORLD_TILES * 32 + cam.scrollX = Phaser.Math.Clamp(cam.scrollX, 0, worldW - cam.width / newZoom) + cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, worldH - cam.height / newZoom) }) // Middle-click pan: start on button down @@ -86,6 +106,10 @@ export class CameraSystem { }) } + /** + * Moves the camera via keyboard input and periodically saves the position. + * @param delta - Frame delta in milliseconds + */ update(delta: number): void { const cam = this.scene.cameras.main const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom @@ -103,7 +127,7 @@ export class CameraSystem { if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 } - const worldW = WORLD_TILES * 32 // TILE_SIZE hardcoded since WORLD_PX may not exist + const worldW = WORLD_TILES * 32 const worldH = WORLD_TILES * 32 cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldW - cam.width / cam.zoom) cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldH - cam.height / cam.zoom) @@ -120,14 +144,24 @@ export class CameraSystem { } } + /** + * Returns the world coordinates of the visual camera center. + * Phaser zooms from the screen center, so the center world point + * is scrollX + screenWidth/2 (independent of zoom level). + * @returns World position of the screen center + */ getCenterWorld(): { x: number; y: number } { const cam = this.scene.cameras.main return { - x: cam.scrollX + cam.width / (2 * cam.zoom), - y: cam.scrollY + cam.height / (2 * cam.zoom), + x: cam.scrollX + cam.width / 2, + y: cam.scrollY + cam.height / 2, } } + /** + * Returns the tile coordinates of the visual camera center. + * @returns Tile position (integer) of the screen center + */ getCenterTile(): { tileX: number; tileY: number } { const { x, y } = this.getCenterWorld() return { tileX: Math.floor(x / 32), tileY: Math.floor(y / 32) } From 71aee058b55e5f5357f9b0ffcc0313ea06ce4b36 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Sat, 21 Mar 2026 12:01:38 +0000 Subject: [PATCH 20/21] =?UTF-8?q?=F0=9F=93=9D=20update=20CHANGELOG=20for?= =?UTF-8?q?=20Issue=20#5=20zoom-to-mouse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb8722..e26536e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Villagers are now called **Nisse** throughout the UI (panel, controls hint, stockpile display, context menu, spawn message) ### Added -- Scroll wheel now zooms toward the mouse cursor position instead of the screen center +- Scroll wheel zooms toward the mouse cursor position (zoom-to-mouse), correctly accounting for Phaser's center-based zoom model - Middle mouse button held: pan the camera by dragging +- Test environment at `/test.html` with `ZoomTestScene` (Phaser default) and `ZoomMouseScene` (zoom-to-mouse) for camera behaviour analysis; file-logging via Vite middleware to `game-test.log` + +### Fixed +- `getCenterWorld()` in `CameraSystem` returned wrong world coordinates at zoom ≠ 1; corrected from `scrollX + width/(2·zoom)` to `scrollX + width/2` - Right-click context menu: suppresses browser default, shows Build and Nisse actions in the game world - Initial project setup: Phaser 3 + TypeScript + Vite - Core scenes: `BootScene`, `GameScene`, `UIScene` From 1ba38cc23e6ae88ffc5a7e8f414d24c87b5d36dc Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Sat, 21 Mar 2026 12:36:17 +0000 Subject: [PATCH 21/21] =?UTF-8?q?=F0=9F=94=92=20ignore=20.claude/=20dir=20?= =?UTF-8?q?and=20game-test.log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b947077..6086f85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ dist/ +game-test.log +.claude/