SpaceCities / camera.js
Claude
Claude Opus 4.8
Touch / mobile controls β€” play the whole game by finger
e7bc56d unverified
Raw History Blame Contribute Delete
4.81 kB
/* ============================================================
Camera: pans and zooms the view over the map instead of squeezing
the whole map into one screen. Pure view-space math β€” never reads or
mutates game state, just converts between screen (CSS pixel, origin
at the canvas's top-left) and world (map) coordinates.
============================================================ */
"use strict";
export const MIN_ZOOM = 0.6;
export const MAX_ZOOM = 2.5;
const PAN_SPEED = 900; // world units/sec of keyboard pan at zoom 1
// How far out the wheel can zoom: the usual MIN_ZOOM, but never so tight that
// a big map can't be pulled fully into view. On a Small map this is just
// MIN_ZOOM; on a Gigantic one it drops low enough to fit the whole thing.
function minZoomFor(map, vw, vh) {
if (!vw || !vh) return MIN_ZOOM;
return Math.min(MIN_ZOOM, vw / map.width, vh / map.height);
}
export function createCamera(map) {
return { x: map.width / 2, y: map.height / 2, zoom: 1 };
}
export function screenToWorld(camera, vw, vh, sx, sy) {
return {
x: camera.x + (sx - vw / 2) / camera.zoom,
y: camera.y + (sy - vh / 2) / camera.zoom,
};
}
export function worldToScreen(camera, vw, vh, wx, wy) {
return {
x: vw / 2 + (wx - camera.x) * camera.zoom,
y: vh / 2 + (wy - camera.y) * camera.zoom,
};
}
// Keeps the camera from panning past the map edge into empty space. Once
// the viewport is wider/taller than the map (zoomed out far enough, or a
// tiny window), that axis just centers instead of "clamping" to a
// backwards range.
export function clampCamera(camera, map, vw, vh) {
const halfW = vw / (2 * camera.zoom);
const halfH = vh / (2 * camera.zoom);
camera.x = map.width <= 2 * halfW ? map.width / 2 : Math.min(Math.max(camera.x, halfW), map.width - halfW);
camera.y = map.height <= 2 * halfH ? map.height / 2 : Math.min(Math.max(camera.y, halfH), map.height - halfH);
}
// Zooms by `factor` while keeping the world point under (sx, sy) fixed on
// screen, so scrolling the wheel feels like it's zooming toward the
// cursor instead of the map drifting out from under it.
export function zoomAt(camera, map, vw, vh, sx, sy, factor) {
const before = screenToWorld(camera, vw, vh, sx, sy);
camera.zoom = Math.min(MAX_ZOOM, Math.max(minZoomFor(map, vw, vh), camera.zoom * factor));
const after = screenToWorld(camera, vw, vh, sx, sy);
camera.x += before.x - after.x;
camera.y += before.y - after.y;
clampCamera(camera, map, vw, vh);
}
// dx/dy is a direction (not necessarily normalized) β€” e.g. from held
// keys β€” scaled by dt and the current zoom so panning covers the same
// screen distance per second regardless of zoom level.
export function panCamera(camera, map, vw, vh, dx, dy, dt) {
if (dx === 0 && dy === 0) return;
const len = Math.hypot(dx, dy);
camera.x += (dx / len) * PAN_SPEED * dt / camera.zoom;
camera.y += (dy / len) * PAN_SPEED * dt / camera.zoom;
clampCamera(camera, map, vw, vh);
}
// Drag the camera by a raw screen-space delta (touch two-finger pan, or a
// drag-pan): move the view so the world stays glued to the fingers. dsx/dsy is
// the finger movement in CSS pixels; dividing by zoom converts it to world
// units, and the sign is inverted because dragging content right (positive dsx)
// must decrease camera.x to bring the left-of-view world into frame.
export function dragCamera(camera, map, vw, vh, dsx, dsy) {
camera.x -= dsx / camera.zoom;
camera.y -= dsy / camera.zoom;
clampCamera(camera, map, vw, vh);
}
// One combined two-finger gesture step. `prev`/`cur` each hold the two touch
// points in screen space ({ ax, ay, bx, by }); this pinch-zooms by how their
// spread changed and pans by how their midpoint moved β€” with the zoom anchored
// on the current midpoint so the world under the pinch stays put. Pure view
// math (same clamps as the wheel), so a test can assert it without a browser.
export function pinchZoomPan(camera, map, vw, vh, prev, cur) {
const prevMidX = (prev.ax + prev.bx) / 2, prevMidY = (prev.ay + prev.by) / 2;
const curMidX = (cur.ax + cur.bx) / 2, curMidY = (cur.ay + cur.by) / 2;
const prevDist = Math.hypot(prev.ax - prev.bx, prev.ay - prev.by) || 1;
const curDist = Math.hypot(cur.ax - cur.bx, cur.ay - cur.by) || 1;
// Zoom about the current midpoint (keep that world point fixed on screen).
const before = screenToWorld(camera, vw, vh, curMidX, curMidY);
camera.zoom = Math.min(MAX_ZOOM, Math.max(minZoomFor(map, vw, vh), camera.zoom * (curDist / prevDist)));
const after = screenToWorld(camera, vw, vh, curMidX, curMidY);
camera.x += before.x - after.x;
camera.y += before.y - after.y;
// ...then pan by the midpoint's own movement.
dragCamera(camera, map, vw, vh, curMidX - prevMidX, curMidY - prevMidY);
}