Adding touch pan and zoom controls to a Phaser game

Photo by Seth Doyle on Unsplash

Adding touch pan and zoom controls to a Phaser game

ยท

4 min read

Hello. For this one, I ended up using Hammer.js. I did try Interact.js which is definitely newer and more updated, but that just... well... didn't work nicely. Judging by the examples on both libraries' pages you can see if the core functionality of these libraries works for you. Hammer.js, which I put an eye on for quite some time, nails it, even though it required some extra tweaking.

As my single/coop-player online space multiplayer 4X/resource managemnet game is targeting the mobile web, I wanted to make sure I got pan and pinch-zoom working properly before I settle down on the game framework, just to know I'm on the right track with that. These controls are important in the galaxy or solar system views, etc.

The interesting part

I will show this in parts and then post the "final" code I have for this. The quotation marks are because I am still missing mouse-wheel zooming but I believe it's rather easy to add and desktop is not the most important focus at this moment.

By creating a function that is external to any scene logic or class I can keep the scene classes clean and the touch code decoupled. This function accepts the target scene and an optional camera in case I don't want the main one to pan/zoom.

export function phaserPinchZoomPanCamera(
  scene: Phaser.Scene,
  camera?: Phaser.Types.Cameras.Scene2D.CameraConfig
) {
  const hammer = new Hammer(scene.game.canvas);
  hammer.get('pinch').set({ enable: true });

  const chosenCamera = camera ?? scene.cameras.main;

This is pretty self-explanatory. I enable Hammer.js on the game's canvas itself and enable pinch.

With Hammer.js, I found out that the continuous events provide a delta (x, y) coordinate relative to the starting touch point. I therefore have to keep the starting coordinates of my camera (as well as the zoom as we'll see). This is in contrast to Interact.js which gave me the delta x and y's relative to the previous event which made the code a bit simpler, but it was buggy and thus ditched.

  let startX: number;
  let startY: number;
  let startZoom: number;

  hammer.on('pinchstart panstart', () => {
    startZoom = chosenCamera.zoom ?? 1;
    startX = chosenCamera.scrollX ?? 0;
    startY = chosenCamera.scrollY ?? 0;
  });

And for the actual touch-move events. It's as simple as that:

  hammer.on('pinchmove panmove', (event) => {
    chosenCamera.zoom = startZoom * event.scale;
    chosenCamera.scrollX = startX - event.deltaX / chosenCamera.zoom;
    chosenCamera.scrollY = startY - event.deltaY / chosenCamera.zoom;
  });
}

It looks like this (YouTube link): https://www.youtube.com/shorts/h77OUO-6m14

Final code:

export function phaserPinchZoomPanCamera(
  scene: Phaser.Scene,
  camera?: Phaser.Types.Cameras.Scene2D.CameraConfig
) {
  const hammer = new Hammer(scene.game.canvas);
  hammer.get('pinch').set({ enable: true });

  const chosenCamera = camera ?? scene.cameras.main;

  let startX: number;
  let startY: number;
  let startZoom: number;

  hammer.on('pinchstart panstart', () => {
    startZoom = chosenCamera.zoom ?? 1;
    startX = chosenCamera.scrollX ?? 0;
    startY = chosenCamera.scrollY ?? 0;
  });

  hammer.on('pinchmove panmove', (event) => {
    chosenCamera.zoom = startZoom * event.scale;
    chosenCamera.scrollX = startX - event.deltaX / chosenCamera.zoom;
    chosenCamera.scrollY = startY - event.deltaY / chosenCamera.zoom;
  });
}

Making TypeScript not shout at you (or me)

My TypeScript external-types-foo is a bit lacking, but I got Hammer.js typing working with something like this:

After installing @types/hammerjs, in a lib/types/index.d.ts file (though you can probably put it in your regular code or something) I put:

import type Hammer from 'hammerjs';

Which seems to do the trick:

Screenshot from 2022-05-28 15-39-38 (crop).png

Some extra

To use the function we defined above:

  create() {
    super.create();
    // ...whatever
    phaserPinchZoomPanCamera(this);
  }

The super call is because I extend the game's actual scene classes for a custom base scene class that extends the original phaser Scene. Because I use Colyseus.js I have, for example, something like this:

// This is very early work of integrating between the Colyseus client and my scene,
// the real scenes extend this and implement `onStateChange`.
export abstract class RoomBasedScene extends Phaser.Scene {
  protected room!: Room<GameRoomSchema>;

  create() {
    this.room = this.game.registry.get(
      GameFramework.ROOM_DATA_KEY
    ) as Room<GameRoomSchema>;

    this.room.onStateChange((state) => this.onStateChange(state));
  }

  abstract onStateChange(state: GameRoomSchema): void;
}

BTW, on the Phaser side of things, I recommend checking how to build a custom Phaser build to keep your bundle size much smaller than out-of-the-box Phaser.

An on a final note, I wanted to say that this blogging thing I do from time to time is amazing because when I paste my code, I fix things and improve to not post nonsense on the internet, so it makes my overall code a bit better and I learn extra stuff that I wouldn't have if I was not to publish these code snippets. ๐Ÿ‘๐Ÿป

ย