mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 18:06:39 +00:00
935ff7a970
## Description: Prevent users from "scrolling"/moving the map outside of viewport and "being lost and unable to find the map back". This can happen by pressing keys intentionally (RIP me) or conflict with browser shortcut containing a WASD key which would keep moving. Related to reports from discord as highlighted by here: https://discord.com/channels/1359946986937258015/1360078040222142564/1432750863994192003 Here is the new behavior. I am actively pressing WASD key to try to "get out" but I can't. Same with mouse clicks. https://www.loom.com/share/a9ecb0a7514d4e54b92d24678833eb2e ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: sorikairo
334 lines
10 KiB
TypeScript
334 lines
10 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 _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);
|
|
}
|
|
}
|