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