7 Commits

Author SHA1 Message Date
1ba38cc23e 🔒 ignore .claude/ dir and game-test.log 2026-03-21 12:36:17 +00:00
71aee058b5 📝 update CHANGELOG for Issue #5 zoom-to-mouse 2026-03-21 12:01:38 +00:00
3fdf621966 implement zoom-to-mouse in CameraSystem
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.
2026-03-21 11:53:00 +00:00
7f0ef0554e add ZoomMouseScene with zoom-to-mouse correction
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.
2026-03-21 11:49:39 +00:00
d83b97a447 ♻️ increase test world to 500×500 tiles, adjust marker intervals 2026-03-21 11:40:11 +00:00
a93e8a2c5d 🐛 fix HUD overlay zoom + add red center crosshair
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.
2026-03-21 11:34:04 +00:00
7c130763b5 add file logging via Vite middleware to ZoomTestScene
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.
2026-03-21 11:19:54 +00:00
7 changed files with 596 additions and 50 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
node_modules/
dist/
game-test.log
.claude/

View File

@@ -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`

View File

@@ -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) }

364
src/test/ZoomMouseScene.ts Normal file
View File

@@ -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<string, unknown>)['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<string, unknown>): 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 */ })
}
}

View File

@@ -1,22 +1,27 @@
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 = 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
/**
* 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.
*/
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
@@ -26,29 +31,34 @@ 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
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()
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)
@@ -83,12 +93,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)
}
}
@@ -111,6 +122,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
@@ -125,23 +137,73 @@ 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,
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)
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)
})
}
/**
* 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',
@@ -149,14 +211,32 @@ 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()
this.snapshotTimer += delta
if (this.snapshotTimer >= SNAPSHOT_EVERY) {
this.snapshotTimer = 0
this.writeSnapshot()
}
}
/**
@@ -164,8 +244,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
@@ -187,27 +267,18 @@ 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'
// 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)
const centerTileY = Math.floor(centerWorldY / TILE_SIZE)
const renderer = this.game.renderer.type === Phaser.WEBGL ? 'WebGL' : 'Canvas'
const lines = [
'── ZOOM TEST [Phaser default] ──',
@@ -231,9 +302,51 @@ export class ZoomTestScene extends Phaser.Scene {
`roundPixels: ${(this.game.renderer.config as Record<string, unknown>)['roundPixels']}`,
`Renderer: ${renderer}`,
'',
'[Scroll] Zoom [WASD / ↑↓←→] Pan',
'[Scroll] Zoom [WASD / ↑↓←→] Pan [Tab] → Mouse',
]
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.
* 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<string, unknown>): 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 */ })
}
}

View File

@@ -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,

View File

@@ -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,