Spaces:
Runtime error
Runtime error
| 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() | |
| } | |
| } | |