caro5 / client /src /game /CameraController.ts
Pedro de Carvalho
Fix vict music
2e13c62
Raw
History Blame Contribute Delete
11.7 kB
import { Container, Ticker } from 'pixi.js'
import type { CameraState } from 'shared/board.ts'
import { CELL_SIZE } from 'shared/board.ts'
import { debug } from '../debug.ts'
const ZOOM_MIN = 0.1
const ZOOM_MAX = 5
const ZOOM_SPEED = 0.002
const CLICK_THRESHOLD = 5
const MOMENTUM_DECAY = 4
const MOMENTUM_MIN = 0.5
export class CameraController {
container: Container
state: CameraState
onChange: (() => void) | null = null
onCellClick: ((gx: number, gy: number) => void) | null = null
onCellHover: ((gx: number | null, gy: number | null) => void) | null = null
offsetX = 0
offsetY = 0
vw = 0
vh = 0
private dragging = false
private dragStartX = 0
private dragStartY = 0
private camStartX = 0
private camStartY = 0
private pointers = new Map<number, { x: number; y: number }>()
private pinchDist = 0
private pinchZoomStart = 1
private canvas: HTMLCanvasElement | null = null
private moved = false
private lastMoveX = 0
private lastMoveY = 0
private velocityX = 0
private velocityY = 0
private momentumActive = false
private currentHoverGx: number | null = null
private currentHoverGy: number | null = null
constructor(container: Container, vw: number, vh: number) {
this.container = container
this.vw = vw
this.vh = vh
this.state = { x: 0, y: 0, zoom: 1 }
}
resize(vw: number, vh: number): void {
this.vw = vw
this.vh = vh
this.apply()
}
screenToWorld(sx: number, sy: number): { x: number; y: number } {
return {
x: (sx - (this.vw / 2 + this.offsetX)) / this.state.zoom + this.state.x,
y: (sy - (this.vh / 2 + this.offsetY)) / this.state.zoom + this.state.y,
}
}
getVisibleWorldBounds(): { minX: number; minY: number; maxX: number; maxY: number } {
const topLeft = this.screenToWorld(0, 0)
const bottomRight = this.screenToWorld(this.vw, this.vh)
return {
minX: Math.min(topLeft.x, bottomRight.x),
minY: Math.min(topLeft.y, bottomRight.y),
maxX: Math.max(topLeft.x, bottomRight.x),
maxY: Math.max(topLeft.y, bottomRight.y),
}
}
setInitialZoom(boardSize: number = 15, fitRatio = 0.85): void {
const gridW = boardSize * CELL_SIZE
const gridH = boardSize * CELL_SIZE
const zoom = Math.min(this.vw / gridW, this.vh / gridH) * fitRatio
this.state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoom))
this.state.x = boardSize / 2 * CELL_SIZE
this.state.y = boardSize / 2 * CELL_SIZE
debug('CameraController', 'setInitialZoom', { boardSize, fitRatio, zoom: this.state.zoom, vw: this.vw, vh: this.vh })
this.apply()
this.triggerChange()
}
ensureCellsInView(cells: [number, number][], marginFraction = 0.15): void {
if (cells.length === 0) return
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const [gx, gy] of cells) {
const cx = gx * CELL_SIZE
const cy = gy * CELL_SIZE
if (cx < minX) minX = cx
if (cy < minY) minY = cy
if (cx > maxX) maxX = cx
if (cy > maxY) maxY = cy
}
maxX += CELL_SIZE
maxY += CELL_SIZE
const boardCx = (minX + maxX) / 2
const boardCy = (minY + maxY) / 2
const visible = this.getVisibleWorldBounds()
const visCx = (visible.minX + visible.maxX) / 2
const visCy = (visible.minY + visible.maxY) / 2
const visW = visible.maxX - visible.minX
const visH = visible.maxY - visible.minY
let changed = false
if (Math.abs(boardCx - visCx) > visW * 0.5 || Math.abs(boardCy - visCy) > visH * 0.5) {
this.state.x = boardCx
this.state.y = boardCy
changed = true
}
const marginX = visW * marginFraction
const marginY = visH * marginFraction
if (minX < visible.minX + marginX || minY < visible.minY + marginY ||
maxX > visible.maxX - marginX || maxY > visible.maxY - marginY) {
const boardW = maxX - minX
const boardH = maxY - minY
const occupyFrac = 0.4
const zoomX = occupyFrac * this.vw / boardW
const zoomY = occupyFrac * this.vh / boardH
const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.min(zoomX, zoomY)))
if (newZoom < this.state.zoom) {
this.state.zoom = newZoom
this.state.x = boardCx
this.state.y = boardCy
changed = true
}
}
if (changed) {
this.apply()
this.triggerChange()
}
}
enable(canvas: HTMLCanvasElement): void {
this.canvas = canvas
canvas.addEventListener('pointerdown', this.onPointerDown)
canvas.addEventListener('pointermove', this.onPointerMove)
canvas.addEventListener('pointerup', this.onPointerUp)
canvas.addEventListener('pointercancel', this.onPointerUp)
canvas.addEventListener('pointerleave', this.onPointerLeave)
canvas.addEventListener('wheel', this.onWheel, { passive: false })
}
disable(): void {
this.stopMomentum()
if (!this.canvas) return
this.canvas.removeEventListener('pointerdown', this.onPointerDown)
this.canvas.removeEventListener('pointermove', this.onPointerMove)
this.canvas.removeEventListener('pointerup', this.onPointerUp)
this.canvas.removeEventListener('pointercancel', this.onPointerUp)
this.canvas.removeEventListener('pointerleave', this.onPointerLeave)
this.canvas.removeEventListener('wheel', this.onWheel)
this.canvas = null
}
apply(): void {
this.container.position.set(
(this.vw / 2 + this.offsetX) - this.state.x * this.state.zoom,
(this.vh / 2 + this.offsetY) - this.state.y * this.state.zoom,
)
this.container.scale.set(this.state.zoom)
}
triggerChange(): void {
this.onChange?.()
}
private updateHover(e: PointerEvent): void {
if (!this.canvas) return
const rect = this.canvas.getBoundingClientRect()
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const world = this.screenToWorld(sx, sy)
const gx = Math.floor(world.x / CELL_SIZE)
const gy = Math.floor(world.y / CELL_SIZE)
if (gx !== this.currentHoverGx || gy !== this.currentHoverGy) {
this.currentHoverGx = gx
this.currentHoverGy = gy
this.onCellHover?.(gx, gy)
}
}
private clearHover(): void {
if (this.currentHoverGx !== null) {
this.currentHoverGx = null
this.currentHoverGy = null
this.onCellHover?.(null, null)
}
}
private startMomentum(vx: number, vy: number): void {
if (Math.abs(vx) < MOMENTUM_MIN && Math.abs(vy) < MOMENTUM_MIN) return
this.momentumActive = true
this.velocityX = vx
this.velocityY = vy
const ticker = Ticker.shared
const cb = () => {
if (!this.momentumActive) return
const dt = ticker.deltaTime / 60
this.state.x += this.velocityX * dt
this.state.y += this.velocityY * dt
this.velocityX *= 1 - MOMENTUM_DECAY * dt
this.velocityY *= 1 - MOMENTUM_DECAY * dt
if (Math.abs(this.velocityX) < MOMENTUM_MIN && Math.abs(this.velocityY) < MOMENTUM_MIN) {
this.momentumActive = false
ticker.remove(cb)
}
this.apply()
this.triggerChange()
}
ticker.add(cb)
}
private stopMomentum(): void {
this.momentumActive = false
this.velocityX = 0
this.velocityY = 0
}
private onPointerDown = (e: PointerEvent): void => {
debug('CameraController', 'pointerDown', { id: e.pointerId, button: e.button, clientX: e.clientX.toFixed(0), clientY: e.clientY.toFixed(0), pointerCount: this.pointers.size + 1 })
this.stopMomentum()
this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
this.moved = false
if (this.pointers.size === 1 && e.button === 1) {
this.dragging = true
this.dragStartX = e.clientX
this.dragStartY = e.clientY
this.camStartX = this.state.x
this.camStartY = this.state.y
this.lastMoveX = e.clientX
this.lastMoveY = e.clientY
debug('CameraController', 'drag start')
}
if (this.pointers.size === 2) {
this.dragging = false
const p = Array.from(this.pointers.values())
this.pinchDist = Math.hypot(p[1].x - p[0].x, p[1].y - p[0].y)
this.pinchZoomStart = this.state.zoom
debug('CameraController', 'pinch start', { pinchDist: this.pinchDist.toFixed(1), zoomStart: this.pinchZoomStart.toFixed(3) })
}
}
private onPointerMove = (e: PointerEvent): void => {
this.updateHover(e)
this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (this.pointers.size === 2) {
const p = Array.from(this.pointers.values())
const dist = Math.hypot(p[1].x - p[0].x, p[1].y - p[0].y)
const factor = dist / this.pinchDist
const mx = (p[0].x + p[1].x) / 2
const my = (p[0].y + p[1].y) / 2
const rect = this.canvas!.getBoundingClientRect()
const sx = mx - rect.left
const sy = my - rect.top
const world = this.screenToWorld(sx, sy)
this.state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.pinchZoomStart * factor))
this.state.x = world.x - (sx - (this.vw / 2 + this.offsetX)) / this.state.zoom
this.state.y = world.y - (sy - (this.vh / 2 + this.offsetY)) / this.state.zoom
this.apply()
this.triggerChange()
return
}
if (!this.dragging) return
if (Math.abs(e.clientX - this.dragStartX) > CLICK_THRESHOLD ||
Math.abs(e.clientY - this.dragStartY) > CLICK_THRESHOLD) {
this.moved = true
}
const dx = e.clientX - this.dragStartX
const dy = e.clientY - this.dragStartY
this.state.x = this.camStartX - dx / this.state.zoom
this.state.y = this.camStartY - dy / this.state.zoom
this.velocityX = (e.clientX - this.lastMoveX) / this.state.zoom
this.velocityY = (e.clientY - this.lastMoveY) / this.state.zoom
this.lastMoveX = e.clientX
this.lastMoveY = e.clientY
this.apply()
this.triggerChange()
}
private onPointerUp = (e: PointerEvent): void => {
debug('CameraController', 'pointerUp', { id: e.pointerId, button: e.button, moved: this.moved, pointerCount: this.pointers.size })
this.pointers.delete(e.pointerId)
if (e.button === 1) {
this.dragging = false
}
if (this.pointers.size === 0) {
this.clearHover()
if (this.moved) {
debug('CameraController', 'start momentum')
this.startMomentum(this.velocityX, this.velocityY)
} else if (this.onCellClick && this.canvas && e.button !== 1) {
const rect = this.canvas.getBoundingClientRect()
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const world = this.screenToWorld(sx, sy)
const gx = Math.floor(world.x / CELL_SIZE)
const gy = Math.floor(world.y / CELL_SIZE)
debug('CameraController', 'cell click detected', { gx, gy })
this.onCellClick(gx, gy)
} else {
debug('CameraController', 'no action on pointerUp')
}
}
}
private onPointerLeave = (): void => {
this.clearHover()
}
private onWheel = (e: WheelEvent): void => {
e.preventDefault()
debug('CameraController', 'wheel', { deltaY: e.deltaY.toFixed(0), zoomBefore: this.state.zoom.toFixed(3) })
const rect = this.canvas!.getBoundingClientRect()
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const world = this.screenToWorld(sx, sy)
this.state.zoom *= 1 - e.deltaY * ZOOM_SPEED
this.state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.state.zoom))
this.state.x = world.x - (sx - (this.vw / 2 + this.offsetX)) / this.state.zoom
this.state.y = world.y - (sy - (this.vh / 2 + this.offsetY)) / this.state.zoom
this.apply()
this.triggerChange()
}
}