Skip to content

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

StateWhat it shows
overviewWhole town, zoomed out
focus-npcCentered on an NPC, zoom 2x
focus-locationCentered on a building, zoom 2x

Keyboard Shortcuts

KeyAction
Arrow keysPan
+ / -Zoom in/out
0Reset to overview
FFocus on selected NPC
EscDeselect

Performance

The camera doesn't trigger re-renders — it only transforms existing sprites. Zero FPS cost for panning/zooming at typical speeds.

Last updated:

Released under the MIT License.