Files
OpenFrontIO/src/client/graphics/TransformHandler.ts
T
tnhnblgl 936b43bedc Fix: Handle not spawned player focus (#1186)
## Description:
Implementing the fix for #1184

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

dovg
2025-06-17 01:35:42 +00:00

289 lines
8.7 KiB
TypeScript

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 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.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());
}
boundingRect(): DOMRect {
return this.canvas.getBoundingClientRect();
}
width(): number {
return this.boundingRect().width;
}
hasChanged(): boolean {
return this.changed;
}
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,
);
this.changed = false;
}
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.game.setFocusedPlayer(event.player);
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.changed = true;
}
onMove(event: DragEvent) {
this.clearTarget();
this.offsetX -= event.deltaX / this.scale;
this.offsetY -= event.deltaY / this.scale;
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);
}
}