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.