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>

Recursive Shadowcasting

A GIF recording of me running around in something I’ve been playing with lately:

Recursive shadowcasting in action.

Recursive shadowcasting in action.

The baddies have been removed to focus on the visibility. Note how the map starts off unexplored, then my field of vision reveals the map as I go. The fog of war creeps back in as I move on, however, letting me see the map but obscuring what’s underneath; the bad guys don’t show under the fog.

Unfortunately the mechanism being used here isn’t well suited to the game framework. The visibility is being updated by an algorithm called recursive shadowcasting and displayed using a grid of tiles. Unexpectedly, Phaser is seemingly really slow to update tilemaps, more or less confirmed in testing a vastly simpler algorithm. I think this comes from the array copies it does in manipulating tilemaps, combined with the many different flags, event handlers, and other fields it has to both set on each tile and in some cases propagate to adjacent tiles to enable all the different collision manipulations and so on that it supports. However, this may have literally just been resolved as I wrote this. Either way, this approach doesn’t seem to apply well in a straightforward fashion within that toolkit. Either I need to do my own barebones tile rendering of just the visibility layers, or switch to a whole different approach. I’m probably going to do the latter but it’s a really neat algorithm so I wanted to post this code for posterity.

The main structures are that there’s a tilemap for the ground, a player sprite, and then one overlay of semi-transparent black tiles for the fog, and another of opaque black tiles for the unexplored areas. As the player moves around these visibility layers are updated as follows.

The process is kicked off by checking to see if the robot has moved out of its previous tile cell, and then essentially casting cones of light in eight directions, forming a circle around the robot:

var diagonals = [
    { deltaX: -1, deltaY: -1 },
    { deltaX: 1,  deltaY: -1 },
    { deltaX: -1, deltaY: 1 },
    { deltaX: 1,  deltaY: 1 }
];

RobotGame.PlayerRobot.prototype.updateFOV = function() {

    var startx = this.game.visibility.getTileX(this.body.x+this.body.width/2);
    var starty = this.game.visibility.getTileY(this.body.y+this.body.height/2);

    if (startx == this.tilex && starty == this.tiley)
        return;

    this.tilex = startx;
    this.tiley = starty;

    // Set every cell to dark
    this.game.map.fill(69,
                       0, 0,
                       this.game.map.width, this.game.map.height,
                       this.game.visibility);

    this.game.map.putTile(-1, startx, starty, this.game.visibility);

    this.game.map.putTile(-1, startx, starty, this.game.explored);

    for (var i = 0; i < diagonals.length; i++) {
        this.castLight(1, 1.0, 0.0, 0, diagonals[i].deltaX, diagonals[i].deltaY, 0);
        this.castLight(1, 1, 0, diagonals[i].deltaX, 0, 0, diagonals[i].deltaY);
    }

    // end PlayerRobot#updateFOV
};

Each cone is essentially scanned left to right within its expanding scope until it hits an obstruction in the ground tilemap. At that point a new cone is computed and recursively cast out to account for what’s visible around the blockage, and then the scan proceeds to the other side. As they scan they update the visibility grids.

RobotGame.PlayerRobot.prototype.castLight = function(row, start, end, xx, xy, yx, yy) {

    var radius = 15;

    var newStart = 0;
    if (start < end)
        return;

    var blocked = false;
    for (var distance = row; distance < radius && !blocked; distance++) {
        var deltaY = -distance;
        for (var deltaX = -distance; deltaX <= 0; deltaX++) {
            var currentX = this.tilex + deltaX * xx + deltaY * xy;
            var currentY = this.tiley + deltaX * yx + deltaY * yy;

            var leftSlope = (deltaX - 0.5) / (deltaY + 0.5);
            var rightSlope = (deltaX + 0.5) / (deltaY - 0.5);

            if (!(currentX >= 0 && currentY >= 0 &&
                  currentX < this.game.map.width &&
                  currentY < this.game.map.height) ||
                start < rightSlope) {
                continue;
            } else if (end > leftSlope) {
                break;
            }

            if ((deltaX * deltaX) + (deltaY * deltaY) <
                ((radius-3) * (radius-3))) {
                this.game.map.putTile(-1, currentX, currentY,
                                      this.game.visibility);
            }

            if ((deltaX * deltaX) + (deltaY * deltaY) <
                (radius * radius)) {
                this.game.map.putTile(-1, currentX, currentY,
                                      this.game.explored);
            }

            var tile = this.game.map.getTile(currentX, currentY,
                                             this.game.ground);
            if (blocked) {

                if (tile && tile.canCollide) {
                    newStart = rightSlope;
                    continue;
                } else {
                    blocked = false;
                    start = newStart;
                }

            } else {

                if (tile && tile.canCollide && distance < radius) {
                    blocked = true;
                    this.castLight(distance+1, start, leftSlope,
                                   xx, xy, yx, yy);
                    newStart = rightSlope;
                }

            }

            // end for deltaX
        }
        // end for distance
    }

    // end PlayerRobot#castLight
};

There is a more detailed explanation in the RogueBasin Wiki. This implementation is basically a few minor tweaks on that in SquidLib, a roguelike library. The changes incorporate the two levels of visibility with different radii, and switch a couple ≤ comparisons on the radius to strict < in order to eliminate weird single-cell artifacts.

I’m bummed this isn’t working out as it makes a couple things very simple, like waking up bad guys when the player sees them and updating their visibility. It’s also just a cool algorithm. I really like the discrete scanning within the grid defined by recursive splits into diagonal cones. On the other hand, a more polygonal approach will yield more intuitive results. This recursive shadowcasting is really more suited for roguelikes with movement in discrete grid steps and their own conventions and traditions on visibility. The polygonal approach is also much more aligned with the pathfinding used by the bad guys. Details on that to come!