Camera
Pan, zoom, focus. The view into the world.
Goals
The camera must:
- 🖱️ Be intuitive (drag-to-pan, scroll-to-zoom)
- 🎯 Support focus (center on an NPC or location)
- 🔍 Support zoom levels 0.5x to 3x
- 📱 Work on touch devices (pinch-zoom, drag-pan)
Implementation
A simple Camera class that wraps PixiJS container transforms:
ts
class Camera {
x = 0;
y = 0;
zoom = 1;
target: Container;
constructor(target: Container) {
this.target = target;
}
pan(dx: number, dy: number) {
this.x += dx;
this.y += dy;
this.apply();
}
setZoom(z: number, focusX?: number, focusY?: number) {
const oldZoom = this.zoom;
this.zoom = clamp(z, 0.5, 3);
if (focusX !== undefined && focusY !== undefined) {
this.x = focusX - (focusX - this.x) * (this.zoom / oldZoom);
this.y = focusY - (focusY - this.y) * (this.zoom / oldZoom);
}
this.apply();
}
focusOn(wx: number, wy: number, viewportWidth: number, viewportHeight: number) {
this.x = wx * TILE_SIZE - viewportWidth / 2;
this.y = wy * TILE_SIZE - viewportHeight / 2;
this.apply();
}
private apply() {
this.target.x = -this.x * this.zoom;
this.target.y = -this.y * this.zoom;
this.target.scale.set(this.zoom);
}
}Input Handling
Mouse / Touch
ts
let isDragging = false;
let lastX = 0, lastY = 0;
stage.on('pointerdown', (event) => {
isDragging = true;
lastX = event.global.x;
lastY = event.global.y;
});
stage.on('pointermove', (event) => {
if (!isDragging) return;
camera.pan(lastX - event.global.x, lastY - event.global.y);
lastX = event.global.x;
lastY = event.global.y;
});
stage.on('pointerup', () => isDragging = false);
window.addEventListener('wheel', (event) => {
event.preventDefault();
const delta = -event.deltaY * 0.001;
camera.setZoom(camera.zoom + delta, event.clientX, event.clientY);
});Touch (pinch)
ts
let touchStartDist = 0;
let touchStartZoom = 1;
stage.on('touchstart', (event) => {
if (event.touches.length === 2) {
const [a, b] = event.touches;
touchStartDist = distance(a, b);
touchStartZoom = camera.zoom;
}
});
stage.on('touchmove', (event) => {
if (event.touches.length === 2) {
const [a, b] = event.touches;
const dist = distance(a, b);
camera.setZoom(touchStartZoom * (dist / touchStartDist));
}
});Smooth Transitions
For "focus on NPC" actions, the camera animates smoothly:
ts
async function smoothFocusOn(camera: Camera, wx: number, wy: number, duration = 600) {
const startX = camera.x;
const startY = camera.y;
const startZoom = camera.zoom;
const targetZoom = 2;
const targetX = wx * TILE_SIZE - viewport.width / 2;
const targetY = wy * TILE_SIZE - viewport.height / 2;
return new Promise<void>(resolve => {
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1);
const eased = easeInOutCubic(t);
camera.x = startX + (targetX - startX) * eased;
camera.y = startY + (targetY - startY) * eased;
camera.zoom = startZoom + (targetZoom - startZoom) * eased;
camera.apply();
if (t < 1) requestAnimationFrame(tick);
else resolve();
};
requestAnimationFrame(tick);
});
}Minimap
A small overview in the corner of the screen showing all buildings and NPC positions. Clicking the minimap moves the main camera.
Camera States
| State | What it shows |
|---|---|
overview | Whole town, zoomed out |
focus-npc | Centered on an NPC, zoom 2x |
focus-location | Centered on a building, zoom 2x |
Keyboard Shortcuts
| Key | Action |
|---|---|
| Arrow keys | Pan |
+ / - | Zoom in/out |
0 | Reset to overview |
F | Focus on selected NPC |
Esc | Deselect |
Performance
The camera doesn't trigger re-renders — it only transforms existing sprites. Zero FPS cost for panning/zooming at typical speeds.