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.
170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
import Phaser from 'phaser'
|
|
import { WORLD_TILES } from '../config'
|
|
import { stateManager } from '../StateManager'
|
|
import type { LocalAdapter } from '../NetworkAdapter'
|
|
|
|
const CAMERA_SPEED = 400 // px/s
|
|
const MIN_ZOOM = 0.25
|
|
const MAX_ZOOM = 2.0
|
|
const ZOOM_STEP = 0.1
|
|
|
|
export class CameraSystem {
|
|
private scene: Phaser.Scene
|
|
private adapter: LocalAdapter
|
|
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
|
|
}
|
|
private saveTimer = 0
|
|
private readonly SAVE_TICK = 2000
|
|
private middlePanActive = false
|
|
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
|
|
|
|
// Start at saved player position (reused as camera anchor)
|
|
cam.scrollX = state.player.x - cam.width / 2
|
|
cam.scrollY = state.player.y - cam.height / 2
|
|
|
|
const kb = this.scene.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),
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
|
|
const up = this.keys.up.isDown || this.keys.w.isDown
|
|
const down = this.keys.down.isDown || this.keys.s.isDown
|
|
const left = this.keys.left.isDown || this.keys.a.isDown
|
|
const right = this.keys.right.isDown || this.keys.d.isDown
|
|
|
|
let dx = 0, dy = 0
|
|
if (left) dx -= speed
|
|
if (right) dx += speed
|
|
if (up) dy -= speed
|
|
if (down) dy += speed
|
|
|
|
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 }
|
|
|
|
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)
|
|
|
|
// Periodically save camera center as "player position"
|
|
this.saveTimer += delta
|
|
if (this.saveTimer >= this.SAVE_TICK) {
|
|
this.saveTimer = 0
|
|
this.adapter.send({
|
|
type: 'PLAYER_MOVE',
|
|
x: cam.scrollX + cam.width / 2,
|
|
y: cam.scrollY + cam.height / 2,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
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) }
|
|
}
|
|
}
|