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.
353 lines
12 KiB
TypeScript
353 lines
12 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
|
||
|
||
/**
|
||
* 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
|
||
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: '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.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)
|
||
|
||
// 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) {
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
* Each zoom event is logged immediately with before/after state.
|
||
*/
|
||
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),
|
||
}
|
||
|
||
;(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,
|
||
_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 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 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',
|
||
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.
|
||
*/
|
||
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: 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] ──',
|
||
'',
|
||
`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] → 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 */ })
|
||
}
|
||
}
|