Fullscreen Scaling in Phaser

This weekend I’ve been playing a bit with nailing down multi-device scaling in Phaser games. This was a bit more confusing than I expected, but I mostly figured out why and thought I’d share.

Update: I’ve posted this example and a state-based project template to GitHub.

My objectives were very simple:

  • Set logical view dimensions around which the game will be based;
  • Scale the game to fill the available window when loaded, maintaining aspect ratio and content;
  • Upon clicking have the game go fullscreen and scale to fill the screen, maintaining aspect ratio and content.

This minimal example demonstrates those (click to fullscreen, again to return):

 

In the blog view here the game has been placed inside a 300px iframe and scales down accordingly from an 800×600 logical view. After you click it scales up to fill the screen as much as it can without losing content. All of this is using Phaser 2.1.3, the latest release. Note that this is just in Chrome & Chromium. Desktop Firefox seems to have have wholly separate issues, and on Android Firefox fullscreening doesn’t seem to do anything.

This test is extremely similar to Phaser’s dragon and anime fullscreen examples as well as its Full Screen Mobile project template. However, those have a couple issues and do not work ideally for me on both my desktop (Linux laptop, Chromium) and phone (Galaxy S3, Chrome). In the end the problems were small but critical.

First, Phaser’s ScaleManager has an undocumented property fullScreenScaleMode that controls how games scale in fullscreen mode. The examples use this. The project template however uses the property scaleMode. It’s fairly easy to miss that these are using different properties, and that the fullscreen variant even exists. Further, it seems you actually have to set both to get the expected behavior, at least for SHOW_ALL scaling and my two platforms. The code below has a simple chart of the effects under various combinations of setting or not setting both of these.

Second, the ScaleManager property pageAlignHorizontally seems to have a bug. In windowed mode it works as expected, but in fullscreen it actually causes the game to be horizontally off-centered. For the moment I’m planning to put the game into a container div to center in windowed mode. Phaser seems to take care of centering the game in fullscreen mode even without this being set. I haven’t diagnosed this further. My guess is the base code is setting a left margin to center the game if the property is set, and then fullscreen mode applies a left margin again without checking to see if that has already been done.

Third, it’s not clear from the examples and docs what of Phaser’s ScaleManager API is needed and what is not. The project template in particular does several things that don’t seem to matter, for example calling ScaleManager::setScreenSize(). Removing that seems to have no effect with these settings, though the docs say it’s necessary. Conversely, it is important to call ScaleManager::refresh() to have the game scale at the start. Otherwise you have to go fullscreen or cause another window change to have the settings apply. The examples however don’t call this.

In any event, after finally identifying all these the test seems to work well on both my laptop and phone. Code is as follows:

<html>
<head>
  <script src="/deps/phaser-2.1.3-arcade-min.js"></script>
</head>

<body style="margin:0px; padding: 0px;">
<div id="game"></div>

<script type="text/javascript">
var game = new Phaser.Game(800, 600, Phaser.CANVAS, 'game',
  { preload: preload, create: create });

function preload() {
  game.stage.backgroundColor = '#336699';
  game.load.image('logo', '20141026-scaletest-logo.png');
}

function create() {

  // Put a graphic in the center to demonstrate.
  sprite = game.add.sprite(game.world.centerX, game.world.centerY, 'logo');
  sprite.anchor.set(0.5);

  /*
   * How the following modes take affect.
   *
   * On a laptop (Linux, Chromium browser):
   *   FS Scale  Reg Scale Reg Result  FS Result
   *   On        On        Fills       Fills
   *   On        Off       Unscaled    Fills
   *   Off       On        Fills       Unscaled
   *   Off       Off       Unscaled    Unscaled
   *
   * On a phone (Galaxy S3, Chrome browser):
   *   FS Scale  Reg Scale N Result    FS Result
   *   On        On        Fills       Fills
   *   On        Off       Too big     Unscaled
   *   Off       On        Fills       Unscaled; buggy
   *   Off       Off       Too big     Exact fit
   *
   */
  game.scale.fullScreenScaleMode = Phaser.ScaleManager.SHOW_ALL;
  game.scale.scaleMode = Phaser.ScaleManager.SHOW_ALL;

  // Properly centers game in windowed mode, but aligning
  // horizontally makes it off-centered in fullscreen mode.
  //game.scale.pageAlignHorizontally = true;
  //game.scale.pageAlignVertically = true;

  // Docs say this is necessary, but it doesn't seem to change behavior?
  //game.scale.setScreenSize(true);

  // This is necessary to scale before waiting for window changes.
  game.scale.refresh();

  game.input.onDown.add(gofull, this);

}

function gofull() {
  if (game.scale.isFullScreen) {
    game.scale.stopFullScreen();
  } else {
    game.scale.startFullScreen(false);
  }
}
</script>
</body>
</html>

Circle Intersection Test

Yet another quick test for a geometry function:

Click, drag, and release to draw a line. A point will be drawn at the intersections if there are any. Hit ‘S’ to toggle between treating the drawn segment as a line segments or a line.

The demo uses some basic Phaser features for the interaction and drawing. The Javascript code is:

var game = new Phaser.Game(530, 300, Phaser.CANVAS, 'container',
                           { create: create, update: update, render: render });

var line;

var setting;

var result1;
var result2;

var circle;

var segment = true;

function create() {

  line = new Phaser.Line(game.world.width/4, game.world.height/4,
                         3*game.world.width/4, 3*game.world.height/4);

  circle = new Phaser.Circle(game.world.width/2, game.world.height/2,
                             Math.min(game.world.height, game.world.width)/2);

  game.input.onDown.add(click, this);
  setting = false;

  result1 = new Phaser.Point();
  result2 = new Phaser.Point();

    game.input.keyboard.addKey(Phaser.Keyboard.S)
        .onDown.add(function() {
            segment = !segment;
        }, this);
}


function update() {

    if (setting) {
        line.end.set(game.input.activePointer.x,
                      game.input.activePointer.y);

        if (!game.input.activePointer.isDown) {
            setting = false;
        }
    }
}


function click(pointer) {
    setting = true;
    line.start.set(pointer.x, pointer.y);
}


function render() {
  game.debug.geom(line);

  game.debug.geom(circle, '#00ff00', false, 2);

  var res = intersection(line, circle, result1, result2, segment);
  if (res) {
    result1.x--;
    result1.y--;
    result2.x--;
    result2.y--;

    game.debug.geom(result1, '#ff0000');

    if (res == INTERSECTION)
      game.debug.geom(result2, '#ff0000');
  }

}


var NO_INTERSECTION = 0;
var INTERSECTION = 1;
var SINGLE_INTERSECTION = 2;
var TANGENT = 3;

function intersection(line, circle, result1, result2, segment) {
  var lx = line.end.x - line.start.x;
  var ly = line.end.y - line.start.y;

  var len = Math.sqrt(lx*lx + ly*ly);

  var dx = lx / len;
  var dy = ly / len;

  var t = dx*(circle.x-line.start.x) + dy*(circle.y-line.start.y);

  var ex = t * dx + line.start.x;
  var ey = t * dy + line.start.y;

  var lec = Math.sqrt((ex-circle.x)*(ex-circle.x) +
                      (ey-circle.y)*(ey-circle.y));

  if (lec < circle.radius) {

    var dt = Math.sqrt(circle.radius*circle.radius - lec*lec);

    var te = dx*(line.end.x-line.start.x) + dy*(line.end.y-line.start.y);

    if (segment) {
      if ((t-dt < 0 || t-dt > te) &&
          (t+dt < 0 || t+dt > te)) {
            return NO_INTERSECTION;
      } else if (t-dt < 0 || t-dt > te) {
          result1.x = (t+dt)*dx + line.start.x;
          result1.y = (t+dt)*dy + line.start.y;
          return SINGLE_INTERSECTION;
      } else if (t+dt < 0 || t+dt > te) {
          result1.x = (t-dt)*dx + line.start.x;
          result1.y = (t-dt)*dy + line.start.y;
          return SINGLE_INTERSECTION;
      }
    }

    result1.x = (t-dt)*dx + line.start.x;
    result1.y = (t-dt)*dy + line.start.y;

    result2.x = (t+dt)*dx + line.start.x;
    result2.y = (t+dt)*dy + line.start.y;

    return INTERSECTION;
  } else if (lec == circle.radius) {

    result1.x = ex;
    result1.y = ey;

    result2.x = ex;
    result2.y = ey;

    return TANGENT;
  }

  return NO_INTERSECTION;
}

Line Intersection Test

Working on another game feature, I now need to compute actual line and rectangle intersections rather than just detecting them. This is a quick test:

Click, drag, and release to draw a line. A point will be drawn at the intersection if there is one. Hit ‘S’ to toggle between treating the geometry as line segments or lines.

The demo uses some basic Phaser features for the interaction and drawing. The calculation is taken straight from Phaser’s Arcade Physics math. The full Javascript code is:

var game = new Phaser.Game(530, 300, Phaser.AUTO, 'gamecontainer',
                           { create: create, update: update, render: render });

var linea;
var lineb;

var setting;

var result;

var segment;

function create() {

    linea = new Phaser.Line(game.world.width/4, game.world.height/4,
                            3*game.world.width/4, 3*game.world.height/4);
    lineb = new Phaser.Line(game.world.width/4, 3*game.world.height/4,
                            3*game.world.width/4, game.world.height/4);

    game.input.onDown.add(click, this);
    setting = false;

    result = new Phaser.Point();

    segment = true;

    game.input.keyboard.addKey(Phaser.Keyboard.S)
        .onDown.add(function() {
            segment = !segment;
        }, this);
}


function update() {

    if (setting) {
        lineb.end.set(game.input.activePointer.x,
                      game.input.activePointer.y);

        if (!game.input.activePointer.isDown) {
            setting = false;
        }
    }
}


function click(pointer) {
    setting = true;
    lineb.start.set(pointer.x, pointer.y);
}


function render() {
    game.debug.geom(linea);
    game.debug.geom(lineb);

    if (intersection(linea.start.x, linea.start.y,
                     linea.end.x, linea.end.y,
                     lineb.start.x, lineb.start.y,
                     lineb.end.x, lineb.end.y,
                     segment,
                     result)) {
        result.x--;
        result.y--;
        game.debug.geom(result, '#ff0000');
    }
}

function intersection(ax, ay, bx, by, ex, ey, fx, fy, asSegment, result) {

    var a1 = by - ay;
    var a2 = fy - ey;
    var b1 = ax - bx;
    var b2 = ex - fx;
    var c1 = (bx * ay) - (ax * by);
    var c2 = (fx * ey) - (ex * fy);
    var denom = (a1 * b2) - (a2 * b1);

    if (denom === 0) {
        return false;
    }

    result.x = ((b1 * c2) - (b2 * c1)) / denom;
    result.y = ((a2 * c1) - (a1 * c2)) / denom;

    if (asSegment) {
        if ( result.x < Math.min(ax, bx) || result.x > Math.max(ax, bx) ||
             result.y < Math.min(ay, by) || result.y > Math.max(ay, by) ||
             result.x < Math.min(ex, fx) || result.x > Math.max(ex, fx) ||
             result.y < Math.min(ey, fy) || result.y > Math.max(ey, fy) ) {
            return false;
        }
    }

    return true;

}