mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-25 08:44:39 +00:00
4cd22a9b5c
The render/ tree was the only place in the client still using kebab-case filenames. Brings ~80 files in line with the rest of src/client/ (BuildPreviewController, TransformHandler, etc.). Directories kept as they were (name-pass/, fx-pass/, passes/, utils/, debug/) since the codebase already mixes those. Two collisions surfaced and got resolved: render/types/ is a directory, not a file, so its imports kept the lowercase form; and the sed pass incidentally normalized core/pathfinding imports, which had to be reverted since that file is actually lowercase on disk despite some imports having referenced it as ./Types under macOS case-insensitive resolution.
199 lines
5.7 KiB
TypeScript
199 lines
5.7 KiB
TypeScript
/**
|
|
* 2D camera: pan/zoom → column-major mat3 for WebGL2 vertex shaders.
|
|
*
|
|
* Pure viewport math — no DOM event listeners. Input handling lives
|
|
* in GameView, which calls panBy / zoomAtScreen / etc.
|
|
*
|
|
* Coordinate system:
|
|
* World: (0,0) top-left, (mapWidth, mapHeight) bottom-right, +Y down.
|
|
* Clip: (-1,-1) bottom-left, (1,1) top-right.
|
|
*
|
|
* The mat3 maps world → clip:
|
|
* sx = zoom * 2 / canvasWidth
|
|
* sy = zoom * -2 / canvasHeight (Y flip)
|
|
* tx = -offsetX * sx
|
|
* ty = -offsetY * sy
|
|
*/
|
|
|
|
const MIN_ZOOM = 0.2;
|
|
const MAX_ZOOM = 20;
|
|
const DBLCLICK_MIN_ZOOM = 0.7;
|
|
const DBLCLICK_MAX_ZOOM = 3;
|
|
|
|
export class Camera {
|
|
offsetX: number;
|
|
offsetY: number;
|
|
zoom: number;
|
|
|
|
private mapW: number;
|
|
private mapH: number;
|
|
private canvasW = 1;
|
|
private canvasH = 1;
|
|
private mat = new Float32Array(9);
|
|
private dirty = true;
|
|
/** True until fitMap() has been called with valid canvas dimensions. */
|
|
private needsInitialFit = true;
|
|
|
|
constructor(mapWidth: number, mapHeight: number) {
|
|
this.mapW = mapWidth;
|
|
this.mapH = mapHeight;
|
|
this.offsetX = mapWidth / 2;
|
|
this.offsetY = mapHeight / 2;
|
|
this.zoom = 1;
|
|
}
|
|
|
|
/** Update canvas pixel dimensions. Triggers initial fitMap on first call. */
|
|
resize(cssWidth: number, cssHeight: number): void {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
this.canvasW = Math.round(cssWidth * dpr);
|
|
this.canvasH = Math.round(cssHeight * dpr);
|
|
if (this.needsInitialFit) {
|
|
this.fitMap();
|
|
}
|
|
this.dirty = true;
|
|
}
|
|
|
|
/** Fit the map into the viewport (~90% fill). */
|
|
fitMap(): void {
|
|
this.offsetX = this.mapW / 2;
|
|
this.offsetY = this.mapH / 2;
|
|
const sx = this.canvasW / this.mapW;
|
|
const sy = this.canvasH / this.mapH;
|
|
this.zoom = Math.min(sx, sy) * 0.9;
|
|
this.dirty = true;
|
|
this.needsInitialFit = false;
|
|
}
|
|
|
|
/** Center the camera on a bounding box with padding (1.4 ≈ 71% fill). */
|
|
focusBBox(
|
|
minX: number,
|
|
minY: number,
|
|
maxX: number,
|
|
maxY: number,
|
|
padding = 1.4,
|
|
): void {
|
|
this.offsetX = (minX + maxX + 1) / 2;
|
|
this.offsetY = (minY + maxY + 1) / 2;
|
|
const bboxW = maxX - minX + 1;
|
|
const bboxH = maxY - minY + 1;
|
|
const sx = this.canvasW / bboxW;
|
|
const sy = this.canvasH / bboxH;
|
|
this.zoom = Math.max(
|
|
DBLCLICK_MIN_ZOOM,
|
|
Math.min(DBLCLICK_MAX_ZOOM, Math.min(sx, sy) / padding),
|
|
);
|
|
this.clampOffset();
|
|
this.dirty = true;
|
|
}
|
|
|
|
/** Set the camera center to a world position. */
|
|
panTo(worldX: number, worldY: number): void {
|
|
this.offsetX = worldX;
|
|
this.offsetY = worldY;
|
|
this.clampOffset();
|
|
this.dirty = true;
|
|
}
|
|
|
|
/** Shift the camera center by a world-space delta (used for drag panning). */
|
|
panBy(dx: number, dy: number): void {
|
|
this.offsetX += dx;
|
|
this.offsetY += dy;
|
|
this.clampOffset();
|
|
this.dirty = true;
|
|
}
|
|
|
|
/** Restore camera state, skipping the initial fitMap. */
|
|
setCameraState(x: number, y: number, z: number): void {
|
|
this.offsetX = x;
|
|
this.offsetY = y;
|
|
this.zoom = z;
|
|
this.needsInitialFit = false;
|
|
this.dirty = true;
|
|
}
|
|
|
|
/** Multiply zoom by a factor (centered on current view). */
|
|
zoomBy(factor: number): void {
|
|
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * factor));
|
|
this.clampOffset();
|
|
this.dirty = true;
|
|
}
|
|
|
|
/** Set absolute zoom level. */
|
|
zoomTo(level: number): void {
|
|
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level));
|
|
this.clampOffset();
|
|
this.dirty = true;
|
|
}
|
|
|
|
/**
|
|
* Zoom by a factor while keeping a screen point fixed in world space.
|
|
* Used for wheel-zoom: the world position under the cursor stays put.
|
|
*/
|
|
zoomAtScreen(factor: number, screenX: number, screenY: number): void {
|
|
const worldBefore = this.screenToWorld(screenX, screenY);
|
|
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * factor));
|
|
const worldAfter = this.screenToWorld(screenX, screenY);
|
|
this.offsetX += worldBefore.x - worldAfter.x;
|
|
this.offsetY += worldBefore.y - worldAfter.y;
|
|
this.clampOffset();
|
|
this.dirty = true;
|
|
}
|
|
|
|
/** Return the column-major mat3 camera matrix (world → clip). */
|
|
getMatrix(): Float32Array {
|
|
if (this.dirty) {
|
|
const sx = (this.zoom * 2) / this.canvasW;
|
|
const sy = (this.zoom * -2) / this.canvasH; // Y flip
|
|
const tx = -this.offsetX * sx;
|
|
const ty = -this.offsetY * sy;
|
|
const m = this.mat;
|
|
m[0] = sx;
|
|
m[1] = 0;
|
|
m[2] = 0;
|
|
m[3] = 0;
|
|
m[4] = sy;
|
|
m[5] = 0;
|
|
m[6] = tx;
|
|
m[7] = ty;
|
|
m[8] = 1;
|
|
this.dirty = false;
|
|
}
|
|
return this.mat;
|
|
}
|
|
|
|
/** Convert screen pixel position to world coordinates. */
|
|
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const ndcX = ((screenX * dpr) / this.canvasW) * 2 - 1;
|
|
const ndcY = -(((screenY * dpr) / this.canvasH) * 2 - 1);
|
|
const sx = (this.zoom * 2) / this.canvasW;
|
|
const sy = (this.zoom * -2) / this.canvasH;
|
|
return {
|
|
x: (ndcX - -this.offsetX * sx) / sx,
|
|
y: (ndcY - -this.offsetY * sy) / sy,
|
|
};
|
|
}
|
|
|
|
/** Convert world coordinates to screen pixel position (CSS pixels). */
|
|
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
return {
|
|
x: (this.zoom * (worldX - this.offsetX)) / dpr + this.canvasW / (2 * dpr),
|
|
y: (this.zoom * (worldY - this.offsetY)) / dpr + this.canvasH / (2 * dpr),
|
|
};
|
|
}
|
|
|
|
private clampOffset(): void {
|
|
const halfVpW = this.canvasW / (2 * this.zoom);
|
|
const halfVpH = this.canvasH / (2 * this.zoom);
|
|
this.offsetX = Math.max(
|
|
-halfVpW,
|
|
Math.min(this.mapW + halfVpW, this.offsetX),
|
|
);
|
|
this.offsetY = Math.max(
|
|
-halfVpH,
|
|
Math.min(this.mapH + halfVpH, this.offsetY),
|
|
);
|
|
}
|
|
}
|