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.
365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
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 */ })
|
||
}
|
||
}
|