Gameplay in HTML5: Game Views

Homework #5 review

Instructor's solution

http://EpsilonDelta.us/UW_GameplayHTML5/Homework/Solutions/5

Overview

The Spin Canvas view

Setup

Objective

Set up the canvas and dimensions for the game.

Setup

In SpinCanvasView.js the tiles are square, with their size (in pixels) computed to fit the spinBoard. Since that space may not fit the tiles perfectly, a margin is set up to center the tile grid.
app.spin.view =
    (function()
     {
    //-------------------------------------------------------------------------

         var spinModel,
             canvas, ctx,
             tilesHoriz, tilesVert,
             tileSize,
             margins;
         
    //=========================================================================

         function setup( model, params )
         {
             var boardDiv = $("#spinBoard"),
                 dim = model.getDimensions(),
                 tileWidth, tileHeight;

             spinModel = model;
             canvas = $("<canvas />")[0];
             $(canvas).addClass( "board" );
             ctx = canvas.getContext( "2d" );
             canvas.width = boardDiv.width();
             canvas.height = boardDiv.height();
             $(canvas).appendTo( boardDiv );

             tilesHoriz = dim.horiz;
             tilesVert = dim.vert;
             tileWidth = canvas.width / tilesHoriz;
             tileHeight = canvas.height / tilesVert;
             if ( tileWidth <= tileHeight )
             {
                 tileSize = tileWidth;
                 margins =
                     { x: 0,
                       y: (canvas.height  -  tileSize * tilesVert) / 2 };
             }
             else
             {
                 tileSize = tileHeight;
                 margins =
                     { x: (canvas.width  -  tileSize * tilesHoriz) / 2,
                       y: 0 };
             }
         }
            

Calling the view's setup

In SpinScreen.js we pass the model to the view, together with any parameters that may be needed.
         function run( )
         {
             ...
             viewParams = { };
             app.spin.view.setup( app.spin.model, viewParams );
         }
            

Cleaning up

Objective

Each time we enter the Spin screen, we are creating a canvas and adding it to the DOM. We need to remove this when we leave the screen.
In fact, as we go along, we will need to clean up many of the things on our screens. We will add stop() methods to go with our run() methods.

A cleanup function

In SpinScreen.js we add:
         function stop( )
         {
             $("#spinBoard canvas").remove( );
         }
              

Calling stop()

Now we modify showScreen() to call a screen's stop() function, if there is one.
app.showScreen = function( screenId )
{
    var oldScreenDiv = $("#game .screen.active"),
        oldScreenId = $(oldScreenDiv).attr( "id" ),
        oldScreen = app.screens[ oldScreenId ],
        newScreenDiv = $("#" + screenId),
        newScreen = app.screens[ screenId ];
    if ( oldScreenDiv )
    {
        oldScreenDiv.removeClass( "active" );
        if ( oldScreen && typeof oldScreen.stop === "function" )
        {
            oldScreen.stop( );
        }
    }
    newScreenDiv.addClass( "active" );
    newScreen.run( );
};
              

Example

Example 1

Color schemes

Objective

Establish the array of color schemes, which are cycled through by the model and displayed by the view.

Screen/Controller

The array is defined in the SpinScreen.js module. Its length is passed to the model and the whole array to the view.
app.screens[ "spinScreen" ] =
    (function()
     {
    //-------------------------------------------------------------------------

         var firstRun = true,
             colorSchemes =
                 [
                     { //white on blue
                         circle: "rgb( 255, 255, 255 )",
                         square: "rgb( 0, 0, 255 )"
                     },
                     { //green on yellow
                         circle: "rgb( 0, 192, 0 )",
                         square: "rgb( 255, 255, 0 )"
                     },
                     { //yellow on green
                         circle: "rgb( 255, 255, 0 )",
                         square: "rgb( 0, 192, 0 )"
                     },
                     { //yellow on violet
                         circle: "rgb( 255, 255, 0 )",
                         square: "rgb( 192, 0, 192 )"
                     },
                     { //white on red
                         circle: "rgb( 255, 255, 255 )",
                         square: "rgb( 255, 0, 0 )"
                     }
                 ];
         
    //=========================================================================

         function run( )
         {
             var modelParams,
                 viewParams;
             if ( firstRun )
             {
                 init( );
                 firstRun = false;
             }
             modelParams = determineLayout( );
             modelParams.numColors = colorSchemes.length;
             modelParams.rotationRate = 2.0 * (2.0 * Math.PI); //rad/sec
             modelParams.time = 0.0;
             app.spin.model.setup( modelParams );
             viewParams = { colorSchemes: colorSchemes };
             app.spin.view.setup( app.spin.model, viewParams );
         }
            

View

The view maintains a colorSchemes property.
         var spinModel,
             ...
             colorSchemes;
         
    //=========================================================================

         function setup( model, params )
         {
             ...
             setColorSchemes( params.colorSchemes );
         }

    //=========================================================================

         function setColorSchemes( schemes )
         {
             colorSchemes = schemes;
         }
            

Example

Example 2

Displaying the model

Objective

Using the Canvas API, display the tiles.

Update draws all tiles

The update method updates the display of the model.
         function update( )
         {
             drawTiles( );
         }

    //-------------------------------------------------------------------------

         function drawTiles( )
         {
             var x, y,
                 loc = { }, 
                 tile;
             for ( x = 0; x < tilesHoriz; ++x )
             {
                 loc.x = x;
                 for ( y = 0; y < tilesVert; ++y )
                 {
                     loc.y = y;
                     tile = spinModel.getTile( loc );
                     drawTile( loc, tile );
                 }
             }
         }
            

Drawing a tile

Drawing a tile requires computing screen (pixel) coordinates and using the tile's color scheme.
         function drawTile( loc, tile )
         {
             var gfx = app.graphics,
                 pos = locToPos( loc ),
                 colorScheme = colorSchemes[ tile.colorNum ],
                 angle = tile.angle,
                 orbitRadius = tileSize * 0.3,
                 circleRadius = tileSize * 0.1,
                 circleX, circleY;
             
             gfx.setContext( ctx );
             
             ctx.beginPath( );
             ctx.fillStyle = colorScheme.square;
             ctx.fillRect( pos.x, pos.y, tileSize, tileSize );

             circleX = (pos.x + tileSize / 2) +
                 Math.cos( angle ) * orbitRadius;
             circleY = (pos.y + tileSize / 2) +
                 Math.sin( angle ) * orbitRadius;
             ctx.fillStyle = colorScheme.circle;
             gfx.fillCircle( circleX, circleY, circleRadius );
         }
         
    //=========================================================================

         function locToPos( loc )
         {
             return {
                 x: loc.x * tileSize  +  margins.x,
                 y: loc.y * tileSize  +  margins.y
             };
         }
            

Example

Example 3

An optimization

Objective

Between updates most of the screen does not change. Let's update only the tiles that have changed.

Tracking changes

In the SpinModel.js we add a changes variable. When the tiles are initialized, all is set to true, indicating that all tiles must be drawn. Afterwards the list is updated with changed tile locations.
         var tilesHoriz,
             ...
             changes = { all: false, list: [] };
         
    //=========================================================================

         function setup( params )
         {
             ...
             changes = { all: true, list: [] };
         }

    //=========================================================================

         function moveTo( newLoc )
         {
             //The originally active tile changes color when we leave it.
             ...
             changes.list.push( curLoc );
             curLoc = newLoc;
         }

    //=========================================================================
         
         function update( time )
         {
             //The active tile's rotation is incremented
             ...
             changes.list.push( curLoc );
         }
            
We provide a public function to get the changes and one to reset it.
         function getChanges( )
         {
             return changes;
         }
         
    //-------------------------------------------------------------------------

         function resetChanges( )
         {
             changes = { all: false, list: [] };
         }
            

Displaying the changes

In SpinCanvasView.js we now only draw the changed tiles:
         function update( )
         {
             var changes,
                 i, lim,
                 loc, tile;
             changes = spinModel.getChanges();
             spinModel.resetChanges();
             if ( changes.all )
             {
                 drawTiles( );
             }
             else
             {
                 for ( i = 0, lim = changes.list.length; i < lim; ++i )
                 {
                     loc = changes.list[ i ];
                     tile = spinModel.getTile( loc );
                     drawTile( loc, tile );
                 }
             }
         }
            

Example

Example 4

Loading resources

Loading resource files

Objective

Use Modernizr (yepnope) to load image files.

Yepnope prefixes

By default, yepnope loads JavaScript files and executes them immediately. But it provides a prefix mechanism to override default behavior.
There are some pre-defined prefixes, but we will add a custom one for resources (in Loader.js. Our purpose is to turn on the noexec flag.
         function setResourcePrefix( )
         {
             yepnope.addPrefix(
                 "resource",
                 function( resourceObj )
                 {
                     resourceObj.noexec = true;
                     return resourceObj;
                 }
             );
         }
            

Resource URLs

Let's refer to resources by name, rather than URLs:
         function getResourceUrl( name )
         {
             switch ( name )
             {
             case "Beach ball":
                 return "images/beach_ball_64.png";
             case "Beach background":
                 return "images/beach.jpg";
             default:
                 return null;
             }
         }
            

List resources to load

We build a list of resources (starting with separate lists for each section of the app), and prepend our prefix, followed by an exclamation point.
         function listResources( )
         {
             var resources = [],
                 i, lim;
             resources = resources.concat( listBeachResources( ) );
             for ( i = 0, lim = resources.length; i < lim; ++i )
             {
                 resources[ i ] = "resource!" + resources[ i ];
             }
             return resources;
         }
         
    //-------------------------------------------------------------------------

         function listBeachResources( )
         {
             return [ getResourceUrl( "Beach ball" ),
                      getResourceUrl( "Beach background" ) ];
         }
            

Load the resources

Now we add this list as a new section of our Modernizr.load call:
         function run( )
         {
             var resourceList = listResources( );

             setResourcePrefix( );
             
             Modernizr.load(
                 [
                     {
                         test: navigator.appName.match(/Explorer/),
                         yep: "jquery.min.js",
                         nope: "zepto.min.js"
                     },
                     {
                         load: [
                             "App.js",
                             ...
                             "AboutScreen.js"
                         ],
                         complete: function()
                         {
                             app.start( );
                         }
                     },
                     {
                         load: resourceList,
                     }
                 ]
             );
         }
            

Example

Example 5

Tracking resource load progress

Objective

Keep a count of the number of resources loaded and the total to be loaded.

Tracking in the loader

app.loader =
    (function()
     {
    //-------------------------------------------------------------------------

         var numResourcesToLoad = 0,
             numResourcesLoaded = 0;
         
    //=========================================================================

         function run( )
         {
             var resourceList = listResources( );

             setResourcePrefix( );
             
             numResourcesToLoad = resourceList.length;
             numResourcesLoaded = 0;

             Modernizr.load(
                 [
                     ...
                     {
                         load: resourceList,
                         callback: function( url, result, key )
                         {
                             ++numResourcesLoaded;
                         },
                         complete: function()
                         {
                             numResourcesLoaded = numResourcesToLoad = 0;
                         }
                     }
                 ]
             );
         }
            

Accessing load progress

The loader supplies a public method that returns the fraction loaded.
         function getResourceLoadProgress( )
         {
             if ( numResourcesToLoad > 0 )
             {
                 return numResourcesLoaded / numResourcesToLoad;
             }
             else
             {
                 return 1;
             }
         }
            

Example

Example 6

Displaying load progress

Objective

Show a loading bar on the splash screen and prevent click-through until resource loading completes.

HTML

The progress bar is built out of a div within a div.
      <div id="splashScreen" class="screen">
        <div class="logo">
          <h1 class="beach">Beach</h1>
          <h1 class="spin">Spin</h1>
        </div>
        <div class="progress">
          <div class="indicator">
          </div>
        </div>
        <span class="continue">[Click to continue]</span>
      </div>
            

CSS

The outline is a wide rounded rectangle. We set overflow to hidden to confine the indicator bar to the interior.
.progress
{
    margin: 0 auto;
    width: 25em;
    height: 2em;
    border-radius: 0.5em;
    overflow: hidden;
    border: 0.2em solid rgb( 0, 0, 200 );
}

.progress .indicator
{
    background-color: rgb( 200, 200, 0 );
    height: 100%;
    width: 0%;
}
            

Passing arguments from showScreen

We need the ability to pass arguments to our app's showScreen() (App.js) method and have them passed on to the new screen's run() method.
In showScreen, strip off the first argument, putting the rest into an array, args. (We have to use Array.prototype.slice.call( arguments, 1 ), rather than simply arguments.slice( 1 ) because arguments is not exactly an array, only "Array-like". We are removing the first argument, because that is the name of the screen.) Then we pass that array to the newScreen.run() function. (We have to use apply() here because we are passing the arguments as an array, rather than as individual arguments.)
app.showScreen = function( screenId )
{
    var oldScreenDiv = $("#game .screen.active"),
        newScreenDiv = $("#" + screenId),
        newScreen = app.screens[ screenId ],
        args = Array.prototype.slice.call( arguments, 1 );
    if ( oldScreenDiv )
    {
        oldScreenDiv.removeClass( "active" );
    }
    newScreenDiv.addClass( "active" );
    newScreen.run.apply( newScreen, args );
};
            
We use this new capability to pass the loader's get-progress function to the Splash screen:
app.start = function( )
{
    app.drawBackground( );
    app.showScreen( "splashScreen", app.loader.getResourceLoadProgress );
};
            

Disabling/enabling click-to-continue

In SplashScreen.js, we need to be able to toggle the click-through behavior.
         function disableContinue( )
         {
             $("#splashScreen").off( "click" );
             $("#splashScreen .continue").hide( );
         }

    //-------------------------------------------------------------------------

         function enableContinue( )
         {
             $("#splashScreen").click(
                 function( event )
                 {
                     app.showScreen( "mainMenu" );
                 }
             );
             $("#splashScreen .continue").show( );
         }
            

Drawing the progress bar

We update the progress indicator frequently until loading is complete. (We will discuss setTimeout in the next lecture.) Then hold briefly before switching to the click-to-continue prompt. The progress indicator's width is set as a percentage string, as it would be in CSS.
         function showProgress( )
         {
             var progress  = $("#splashScreen .progress" ),
                 progressIndicator = $("#splashScreen .progress .indicator"),
                 pct = 100 * getProgressFn( );
             if ( pct < 100 )
             {
                 progress.show( );
                 progressIndicator.width( pct + "%" );
                 setTimeout( showProgress, 30 );
             }
             else
             {
                 progressIndicator.width( "100%" );
                 setTimeout( hideProgress, 300 );
             }
         }

    //-------------------------------------------------------------------------

         function hideProgress( )
         {
             var progress  = $("#splashScreen .progress" );
             progress.hide( );
             enableContinue( );
         }
            

Starting the new Splash screen

In SplashScreen.js we store the get-progress function in a local variable and start progress bar.
         function run( getProgress )
         {
             getProgressFn = getProgress;
             disableContinue( );
             showProgress( );
         }
            

Example

Example 7

The Beach Canvas view

The basic Beach view: the balls

Objective

Set up the view module in BeachCanvasView.js and draw a background and the balls in the model.

Setup

The view has some private variables, and setup creates a canvas and calls some subroutines.
app.beach.view =
    (function()
     {
    //-------------------------------------------------------------------------

         var beachModel,
             canvas, ctx,
             ballImage,
             ballRadius = 0,
             bg = { };

    //=========================================================================

         function setup( model, params )
         {
             var boardDiv = $("#beachBoard");

             beachModel = model;

             canvas = $("<canvas />")[0];
             $(canvas).addClass( "board" );
             ctx = canvas.getContext( "2d" );
             canvas.width = boardDiv.width();
             canvas.height = boardDiv.height();
             $(canvas).appendTo( boardDiv );

             setupBallImage( );
             setupBackground( );
         }
            

The ball image and radius

We create an img element (without adding it to the DOM), set its src to the URL of the ball graphic, and set the ballRadius to half its width.
In this game, that ball radius will be needed in order to determine the dimensions passed to the model's setup. So we provide a public accessor and also make it safe to call setupBallImage more than once.
         function setupBallImage( )
         {
             if ( ballRadius > 0 )
                 return;
             ballImage = $("<img />")[0];
             ballImage.src =
                 app.loader.getResourceUrl( "Beach ball" );
             ballRadius = ballImage.width / 2;
         }

    //-------------------------------------------------------------------------

         function getBallRadius( )
         {
             setupBallImage( );
             return ballRadius;
         }
            

The background object

The background will be represented by an object. For now it just holds an img element.
         function setupBackground( )
         {
             bg.image = $("<img />")[0];
             bg.image.src =
                 app.loader.getResourceUrl( "Beach background" );
         }
            

Drawing the scene

The scene consists of the background and balls. It is drawn each time the view is updated.
         function update( )
         {
             drawBackground( );
             drawBalls( );
         }
            
For now the background image is drawn, unscaled, at the origin.
         function drawBackground( )
         {
             ctx.drawImage( bg.image, 0, 0 );
         }
            
The ball list is obtained from the model. Each ball's position must be transformed from model to view coordinates.
         function drawBalls( )
         {
             var balls = beachModel.getBalls( ),
                 i, numBalls = balls.length, ball,
                 pos;
             for ( i = 0; i < numBalls; ++i )
             {
                 ball = balls[ i ];
                 //Model to view: switch from Y-up to Y-down;
                 //               from ball center to top-left corner;
                 //               scale from radius=1 to pixels
                 pos =
                     {
                         x: ballRadius * (ball.position.x - 1),
                         y: canvas.height - ballRadius * (ball.position.y + 1)
                     };
                 ctx.drawImage( ballImage, pos.x, pos.y );
             }
         }
            

Example

Example 8

Scrolling the background

Objective

Using a graphic that shows a 360° panorama, draw it using coordinates updated over time so that it gradually scrolls around.

Setup

The last-update time will be initialized from the input parameters, as will the speed at which the background cycles around.
         function setup( model, params )
         {
             ...
             lastTime = params.time;
             ...
             setupBackground( params.bgSecondsPerCycle );
         }
            
The background will be scaled so its height fills the canvas. The corresponding destination width is computed to maintain the same aspect ratio. This and other variables are added to the background object.
The speed is negated so that a positive secondsPerCycle, indicating that our view of the background moves to the right, translates into offseting the image farther left.
         function setupBackground( secondsPerCycle )
         {
             ...
             bg.destWidth = bg.image.width * (canvas.height / bg.image.height);
             bg.offset = 0;
             bg.speed = - bg.destWidth / secondsPerCycle;
         }
            

Updating the background offset

The update() method now takes a time parameter, which it passes along to update the horizontal offset of the background.
The offset is always negative because of the way we will draw the background.
         function update( time )
         {
             updateBackground( time );
             drawBackground( );
             drawBalls( );
         }

    //-------------------------------------------------------------------------

         function updateBackground( time )
         {
             var deltaT = time - lastTime;

             lastTime = time;

             bg.offset += deltaT * bg.speed;
             bg.offset = app.util.wrap( bg.offset, -bg.destWidth, 0 );
         }
            

Drawing the background

Within a save/restore block, we translate the origin leftward, then draw the background image twice. Both drawImage calls use the full source image rectangle, the full destination (canvas) height, and the scaled destination width.
The first draw is at the (translated) origin, and the second at the far right end of the first, so that if the first image has been translated (partially or fully) off the screen, the second copy will fill in the gap. Everything outside the canvas is always clipped off.
         function drawBackground( )
         {
             ctx.save( );
             ctx.translate( bg.offset, 0 );
             ctx.drawImage( bg.image,
                            0, 0, bg.image.width, bg.image.height,
                            0, 0, bg.destWidth, canvas.height );
             ctx.drawImage( bg.image,
                            0, 0, bg.image.width, bg.image.height,
                            bg.destWidth, 0, bg.destWidth, canvas.height );
             ctx.restore( );
         }
            

Example

Example 9

Homework #6

http://EpsilonDelta.us/UW_GameplayHTML5/Homework/Homework_6.html