import { EventBus } from "../../core/EventBus"; import { Cell } from "../../core/game/Game"; import { GameView } from "../../core/game/GameView"; import { CenterCameraEvent, DragEvent, ZoomEvent } from "../InputHandler"; import { GoToPlayerEvent, GoToPositionEvent, GoToUnitEvent, } from "./layers/Leaderboard"; export const GOTO_INTERVAL_MS = 16; export const CAMERA_MAX_SPEED = 15; export const CAMERA_SMOOTHING = 0.03; export class TransformHandler { public scale: number = 1.8; private _boundingRect: DOMRect; private offsetX: number = -350; private offsetY: number = -200; private lastGoToCallTime: number | null = null; private target: Cell | null; private intervalID: NodeJS.Timeout | null = null; private changed = false; constructor( private game: GameView, private eventBus: EventBus, private canvas: HTMLCanvasElement, ) { this._boundingRect = this.canvas.getBoundingClientRect(); this.eventBus.on(ZoomEvent, (e) => this.onZoom(e)); this.eventBus.on(DragEvent, (e) => this.onMove(e)); this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e)); this.eventBus.on(GoToPositionEvent, (e) => this.onGoToPosition(e)); this.eventBus.on(GoToUnitEvent, (e) => this.onGoToUnit(e)); this.eventBus.on(CenterCameraEvent, () => this.centerCamera()); } public updateCanvasBoundingRect() { this._boundingRect = this.canvas.getBoundingClientRect(); } boundingRect(): DOMRect { return this._boundingRect; } width(): number { return this.boundingRect().width; } hasChanged(): boolean { return this.changed; } resetChanged() { this.changed = false; } handleTransform(context: CanvasRenderingContext2D) { // Disable image smoothing for pixelated effect context.imageSmoothingEnabled = false; // Apply zoom and pan context.setTransform( this.scale, 0, 0, this.scale, this.game.width() / 2 - this.offsetX * this.scale, this.game.height() / 2 - this.offsetY * this.scale, ); } worldToScreenCoordinates(cell: Cell): { x: number; y: number } { // Step 1: Convert from Cell coordinates to game coordinates // (reverse of Math.floor operation - we'll use the exact values) const gameX = cell.x; const gameY = cell.y; // Step 2: Reverse the game center offset calculation // Original: gameX = centerX + this.game.width() / 2 // Therefore: centerX = gameX - this.game.width() / 2 const centerX = gameX - this.game.width() / 2; const centerY = gameY - this.game.height() / 2; // Step 3: Reverse the world point calculation // Original: centerX = (canvasX - this.game.width() / 2) / this.scale + this.offsetX // Therefore: canvasX = (centerX - this.offsetX) * this.scale + this.game.width() / 2 const canvasX = (centerX - this.offsetX) * this.scale + this.game.width() / 2; const canvasY = (centerY - this.offsetY) * this.scale + this.game.height() / 2; // Step 4: Convert canvas coordinates back to screen coordinates const canvasRect = this.boundingRect(); const screenX = canvasX + canvasRect.left; const screenY = canvasY + canvasRect.top; return { x: screenX, y: screenY }; } screenToWorldCoordinates(screenX: number, screenY: number): Cell { const canvasRect = this.boundingRect(); const canvasX = screenX - canvasRect.left; const canvasY = screenY - canvasRect.top; // Calculate the world point we want to zoom towards const centerX = (canvasX - this.game.width() / 2) / this.scale + this.offsetX; const centerY = (canvasY - this.game.height() / 2) / this.scale + this.offsetY; const gameX = centerX + this.game.width() / 2; const gameY = centerY + this.game.height() / 2; return new Cell(Math.floor(gameX), Math.floor(gameY)); } screenBoundingRect(): [Cell, Cell] { const canvasRect = this.boundingRect(); const canvasWidth = canvasRect.width; const canvasHeight = canvasRect.height; const LeftX = -this.game.width() / 2 / this.scale + this.offsetX; const TopY = -this.game.height() / 2 / this.scale + this.offsetY; const gameLeftX = LeftX + this.game.width() / 2; const gameTopY = TopY + this.game.height() / 2; const rightX = (canvasWidth - this.game.width() / 2) / this.scale + this.offsetX; const bottomY = (canvasHeight - this.game.height() / 2) / this.scale + this.offsetY; const gameRightX = rightX + this.game.width() / 2; const gameBottomY = bottomY + this.game.height() / 2; return [ new Cell(Math.floor(gameLeftX), Math.floor(gameTopY)), new Cell(Math.floor(gameRightX), Math.floor(gameBottomY)), ]; } isOnScreen(cell: Cell): boolean { const [topLeft, bottomRight] = this.screenBoundingRect(); return ( cell.x > topLeft.x && cell.x < bottomRight.x && cell.y > topLeft.y && cell.y < bottomRight.y ); } screenCenter(): { screenX: number; screenY: number } { const [upperLeft, bottomRight] = this.screenBoundingRect(); return { screenX: upperLeft.x + Math.floor((bottomRight.x - upperLeft.x) / 2), screenY: upperLeft.y + Math.floor((bottomRight.y - upperLeft.y) / 2), }; } onGoToPlayer(event: GoToPlayerEvent) { this.clearTarget(); const nameLocation = event.player.nameLocation(); if (!nameLocation) { return; } this.target = new Cell(nameLocation.x, nameLocation.y); this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS); } onGoToPosition(event: GoToPositionEvent) { this.clearTarget(); this.target = new Cell(event.x, event.y); this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS); } onGoToUnit(event: GoToUnitEvent) { this.clearTarget(); this.target = new Cell( this.game.x(event.unit.lastTile()), this.game.y(event.unit.lastTile()), ); this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS); } centerCamera() { this.clearTarget(); const player = this.game.myPlayer(); if (!player || !player.nameLocation()) return; this.target = new Cell(player.nameLocation().x, player.nameLocation().y); this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS); } private goTo() { const { screenX, screenY } = this.screenCenter(); if (this.target === null) throw new Error("null target"); if ( Math.abs(this.target.x - screenX) + Math.abs(this.target.y - screenY) < 2 ) { this.clearTarget(); return; } let dt: number; const now = window.performance.now(); if (this.lastGoToCallTime === null) { dt = GOTO_INTERVAL_MS; } else { dt = now - this.lastGoToCallTime; } this.lastGoToCallTime = now; const r = 1 - Math.pow(CAMERA_SMOOTHING, dt / 1000); this.offsetX += Math.max( Math.min((this.target.x - screenX) * r, CAMERA_MAX_SPEED), -CAMERA_MAX_SPEED, ); this.offsetY += Math.max( Math.min((this.target.y - screenY) * r, CAMERA_MAX_SPEED), -CAMERA_MAX_SPEED, ); this.changed = true; } onZoom(event: ZoomEvent) { this.clearTarget(); const oldScale = this.scale; const zoomFactor = 1 + event.delta / 600; this.scale /= zoomFactor; // Clamp the scale to prevent extreme zooming this.scale = Math.max(0.2, Math.min(20, this.scale)); const canvasRect = this.boundingRect(); const canvasX = event.x - canvasRect.left; const canvasY = event.y - canvasRect.top; // Calculate the world point we want to zoom towards const zoomPointX = (canvasX - this.game.width() / 2) / oldScale + this.offsetX; const zoomPointY = (canvasY - this.game.height() / 2) / oldScale + this.offsetY; // Adjust the offset this.offsetX = zoomPointX - (canvasX - this.game.width() / 2) / this.scale; this.offsetY = zoomPointY - (canvasY - this.game.height() / 2) / this.scale; this.clampOffsets(); this.changed = true; } private clampOffsets() { const canvasRect = this.boundingRect(); const canvasWidth = canvasRect.width; const canvasHeight = canvasRect.height; const gameWidth = this.game.width(); const gameH = this.game.height(); const scale = this.scale; // Allow panning so that up to half of the viewport can be outside the map on each side. // This lets a map corner be placed at the screen center, but no further. // Derivation (X axis): // gameLeftX = -gameWidth/(2*scale) + offsetX + gameWidth/2 >= -vw/2 // gameRightX = (canvasWidth - gameWidth/2)/scale + offsetX + gameWidth/2 <= gameWidth + vw/2 // Solving gives: // minOffsetX = -gameWidth/2 + (gameWidth - canvasWidth) / (2*scale) // maxOffsetX = gameWidth/2 + (gameWidth - canvasWidth) / (2*scale) const minOffsetX = -gameWidth / 2 + (gameWidth - canvasWidth) / (2 * scale); const maxOffsetX = gameWidth / 2 + (gameWidth - canvasWidth) / (2 * scale); const minOffsetY = -gameH / 2 + (gameH - canvasHeight) / (2 * scale); const maxOffsetY = gameH / 2 + (gameH - canvasHeight) / (2 * scale); // Clamp offsets within computed bounds on each axis if (this.offsetX < minOffsetX) { this.offsetX = minOffsetX; } else if (this.offsetX > maxOffsetX) { this.offsetX = maxOffsetX; } if (this.offsetY < minOffsetY) { this.offsetY = minOffsetY; } else if (this.offsetY > maxOffsetY) { this.offsetY = maxOffsetY; } } onMove(event: DragEvent) { this.clearTarget(); this.offsetX -= event.deltaX / this.scale; this.offsetY -= event.deltaY / this.scale; this.clampOffsets(); this.changed = true; } private clearTarget() { if (this.intervalID !== null) { clearInterval(this.intervalID); this.intervalID = null; } this.target = null; } override(x: number = 0, y: number = 0, s: number = 1) { //hardset view position this.clearTarget(); this.offsetX = x; this.offsetY = y; this.scale = s; this.changed = true; } centerAll(fit: number = 1) { //position entire map centered on the screen const vpWidth = this.boundingRect().width; const vpHeight = this.boundingRect().height; const mapWidth = this.game.width(); const mapHeight = this.game.height(); const scHor = (vpWidth / mapWidth) * fit; const scVer = (vpHeight / mapHeight) * fit; const tScale = Math.min(scHor, scVer); const oHor = (mapWidth - vpWidth) / 2 / tScale; const oVer = (mapHeight - vpHeight) / 2 / tScale; this.override(oHor, oVer, tScale); } }