/** * Game class library, utility functions and globals. * * @author Kevin Roast * * 30/04/09 Initial version. * 12/05/09 Refactored to remove globals into GameHandler instance and added FPS controller game loop. * 17/01/11 Full screen resizable canvas * 26/01/11 World to screen transformation - no longer unit=pixel * 03/08/11 Modified version for CanvasMark usage */ var KEY = { SHIFT:16, CTRL:17, ESC:27, RIGHT:39, UP:38, LEFT:37, DOWN:40, SPACE:32, A:65, D:68, E:69, G:71, L:76, P:80, R:82, S:83, W:87, Z:90, OPENBRACKET:219, CLOSEBRACKET:221 }; var iOS = (navigator.userAgent.indexOf("iPhone;") != -1 || navigator.userAgent.indexOf("iPod;") != -1 || navigator.userAgent.indexOf("iPad;") != -1); /** * Game Handler. * * Singleton instance responsible for managing the main game loop and * maintaining a few global references such as the canvas and frame counters. */ var GameHandler = { /** * The single Game.Main derived instance */ game: null, /** * True if the game is in pause state, false if running */ paused: false, /** * The single canvas play field element reference */ canvas: null, /** * Width of the canvas play field */ width: 0, /** * Height of the canvas play field */ height: 0, offsetX: 0, offsetY: 0, /** * Frame counter */ frameCount: 0, sceneStartTime: 0, benchmarkScoreCount: 0, benchmarkScores: [], FPSMS: 60, FRAME_TIME_MAX: 1000/30, MAX_GLITCH_COUNT: 10, /** * Debugging output */ maxfps: 0, frametime: 0, frameInterval: 0, /** * Init function called once by your window.onload handler */ init: function() { this.canvas = document.getElementById('canvas'); this.width = this.canvas.height; this.height = this.canvas.width; var me = GameHandler; var el = me.canvas, x = 0, y = 0; do { y += el.offsetTop; x += el.offsetLeft; } while (el = el.offsetParent); // compute canvas offset including page view position me.offsetX = x - window.pageXOffset; me.offsetY = y - window.pageYOffset; }, /** * Game start method - begins the main game loop. * Pass in the object that represent the game to execute. * Also called each frame by the main game loop unless paused. * * @param {Game.Main} game main derived object handler */ start: function(game) { if (game) this.game = game; GameHandler.game.frame(); }, /** * Game pause toggle method. */ pause: function() { if (this.paused) { this.paused = false; GameHandler.game.frame(); } else { this.paused = true; } } }; /** * Game root namespace. * * @namespace Game */ if (typeof Game == "undefined" || !Game) { var Game = {}; } /** * Transform a vector from world coordinates to screen * * @method worldToScreen * @return Vector or null if non visible */ Game.worldToScreen = function worldToScreen(vector, world, radiusx, radiusy) { // transform a vector from the world to the screen radiusx = (radiusx ? radiusx : 0); radiusy = (radiusy ? radiusy : radiusx); var screenvec = null, viewx = vector.x - world.viewx, viewy = vector.y - world.viewy; if (viewx < world.viewsize + radiusx && viewy < world.viewsize + radiusy && viewx > -radiusx && viewy > -radiusy) { screenvec = new Vector(viewx, viewy).scale(world.scale); } return screenvec; }; /** * Game main loop class. * * @namespace Game * @class Game.Main */ (function() { Game.Main = function() { var me = this; document.onkeydown = function(event) { var keyCode = (event === null ? window.event.keyCode : event.keyCode); if (me.sceneIndex !== -1) { if (me.scenes[me.sceneIndex].onKeyDownHandler(keyCode)) { // if the key is handled, prevent any further events if (event) { event.preventDefault(); event.stopPropagation(); } } } }; document.onkeyup = function(event) { var keyCode = (event === null ? window.event.keyCode : event.keyCode); if (me.sceneIndex !== -1) { if (me.scenes[me.sceneIndex].onKeyUpHandler(keyCode)) { // if the key is handled, prevent any further events if (event) { event.preventDefault(); event.stopPropagation(); } } } }; }; Game.Main.prototype = { scenes: [], startScene: null, endScene: null, currentScene: null, sceneIndex: -1, lastFrameStart: 0, interval: null, /** * Game frame method - called by window timeout. */ frame: function frame() { var frameStart = Date.now(); GameHandler.frameInterval = frameStart - GameHandler.frameStart; if (GameHandler.frameInterval === 0) GameHandler.frameInterval = 1; // calculate scene transition and current scene var currentScene = this.currentScene; if (currentScene === null) { // set to scene zero (game init) this.sceneIndex = 0; currentScene = this.scenes[0]; currentScene._onInitScene(); currentScene.onInitScene(); } if ((currentScene.interval === null || currentScene.interval.complete) && currentScene.isComplete()) { if (this.sceneIndex === 0) { // reset total score recorded during the benchmark GameHandler.benchmarkScoreCount = 0; } this.sceneIndex++; if (this.sceneIndex < this.scenes.length) { currentScene = this.scenes[this.sceneIndex]; } else { this.sceneIndex = 0; currentScene = this.scenes[0]; } currentScene._onInitScene(); currentScene.onInitScene(); } // get canvas context for a render pass var ctx = GameHandler.canvas.getContext('2d'); // calculate viewport transform and offset against the world // we want to show a fixed number of world units in our viewport // so calculate the scaling factor to transform world to view currentScene.world.scale = GameHandler.width / currentScene.world.viewsize; // render the game and current scene if (currentScene.interval === null || currentScene.interval.complete) { currentScene.onBeforeRenderScene(currentScene._onBeforeRenderScene()); currentScene.onRenderScene(ctx); } else { // for the benchmark we just clear the canvas ctx.clearRect(0, 0, GameHandler.width, GameHandler.height); currentScene.interval.intervalRenderer.call(currentScene, currentScene.interval, ctx); } // update global frame counter and current scene reference this.currentScene = currentScene; GameHandler.frameCount++; // calculate frame time and frame multiplier required for smooth animation var now = Date.now(); GameHandler.frametime = now - frameStart; GameHandler.frameMultipler = GameHandler.frameInterval / GameHandler.FPSMS; GameHandler.frameStart = frameStart; // update last fps every few frames for debugging output if (GameHandler.frameCount % 16 === 0) GameHandler.lastfps = ~~(1000 / GameHandler.frameInterval); // IE9 does not support requestAnimationFrame so need to calc interval manually var ieinterval = 17 - (GameHandler.frametime); requestAnimFrame(GameHandler.start, ieinterval > 0 ? ieinterval : 1); }, isGameOver: function isGameOver() { return false; } }; })(); // requestAnimFrame shim window.requestAnimFrame = (function() { return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.oRequestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame || function(callback, frameOffset) { window.setTimeout(callback, frameOffset); }; })(); /** * Game scene base class. Adapted for Benchmark scoring. * * @namespace Game * @class Game.Scene */ (function() { Game.Scene = function(playable, interval) { this.playable = playable; this.interval = interval; }; Game.Scene.prototype = { playable: true, interval: null, sceneStartTime: null, sceneCompletedTime: null, sceneGlitchCount: 0, testState: 0, testScore: 0, world: { size: 1500, // total units vertically and horizontally viewx: 0, // current view left corner xpos viewy: 0, // current view left corner ypos viewsize: 1500, // size of the viewable area scale: 1 // scale for world->view transformation - calculated based on physical viewport size }, /** * Return true if this scene should update the actor list. */ isPlayable: function isPlayable() { return this.playable; }, _onInitScene: function _onInitScene() { this.sceneGlitchCount = this.testScore = this.testState = 0; this.sceneStartTime = Date.now(); this.sceneCompletedTime = null; }, onInitScene: function onInitScene() { if (this.interval !== null) { // reset interval flag this.interval.reset(); } }, _onBeforeRenderScene: function _onBeforeRenderScene() { // calculate if the scene shoud render in benchmark mode or not if (this.playable) { if (!this.sceneCompletedTime) { if (GameHandler.frameInterval > GameHandler.FRAME_TIME_MAX) { this.sceneGlitchCount++; } if (this.sceneGlitchCount < GameHandler.MAX_GLITCH_COUNT) { return true; } else { // too many FPS glitches! so benchmark scene completed (allow to run visually for a few seconds) this.sceneCompletedTime = Date.now(); var score = ~~(((this.sceneCompletedTime - this.sceneStartTime) * this.testScore) / 100); GameHandler.benchmarkScoreCount += score; GameHandler.benchmarkScores.push(score); if (typeof console !== "undefined") { console.log(score + " [" + this.interval.label + "]"); } } } } return false; }, getTransientTestScore: function getTransientTestScore() { var score = ((this.sceneCompletedTime ? this.sceneCompletedTime : Date.now()) - this.sceneStartTime) * this.testScore; return ~~(score/100); }, onBeforeRenderScene: function onBeforeRenderScene() { }, onRenderScene: function onRenderScene(ctx) { }, onRenderInterval: function onRenderInterval(ctx) { }, onKeyDownHandler: function onKeyDownHandler(keyCode) { }, onKeyUpHandler: function onKeyUpHandler(keyCode) { }, isComplete: function isComplete() { return this.sceneCompletedTime && (Date.now() - this.sceneCompletedTime > 2000); }, intervalRenderer: function intervalRenderer(interval, ctx) { if (interval.framecounter++ < 100) { Game.centerFillText(ctx, interval.label, "14pt Courier New", GameHandler.height/2 - 8, "white"); } else { interval.complete = true; } } }; })(); (function() { Game.Interval = function(label, intervalRenderer) { this.label = label; this.intervalRenderer = intervalRenderer; this.framecounter = 0; this.complete = false; }; Game.Interval.prototype = { label: null, intervalRenderer: null, framecounter: 0, complete: false, reset: function reset() { this.framecounter = 0; this.complete = false; } }; })(); /** * Actor base class. * * Game actors have a position in the game world and a current vector to indicate * direction and speed of travel per frame. They each support the onUpdate() and * onRender() event methods, finally an actor has an expired() method which should * return true when the actor object should be removed from play. * * An actor can be hit and destroyed by bullets or similar. The class supports a hit() * method which should return true when the actor should be removed from play. * * @namespace Game * @class Game.Actor */ (function() { Game.Actor = function(p, v) { this.position = p; this.vector = v; return this; }; Game.Actor.prototype = { /** * Actor position * * @property position * @type Vector */ position: null, /** * Actor vector * * @property vector * @type Vector */ vector: null, /** * Alive flag * * @property alive * @type boolean */ alive: true, /** * Radius - default is zero to imply that it is not affected by collision tests etc. * * @property radius * @type int */ radius: 0, /** * Actor expiration test * * @method expired * @return true if expired and to be removed from the actor list, false if still in play */ expired: function expired() { return !(this.alive); }, /** * Hit by bullet * * @param force of the impacting bullet (as the actor may support health) * @return true if destroyed, false otherwise */ hit: function hit(force) { this.alive = false; return true; }, /** * Transform current position vector from world coordinates to screen. * Applies the appropriate translation and scaling to the canvas context. * * @method worldToScreen * @return Vector or null if non visible */ worldToScreen: function worldToScreen(ctx, world, radius) { var viewposition = Game.worldToScreen(this.position, world, radius); if (viewposition) { // scale ALL graphics... - translate to position apply canvas scaling ctx.translate(viewposition.x, viewposition.y); ctx.scale(world.scale, world.scale); } return viewposition; }, /** * Actor game loop update event method. Called for each actor * at the start of each game loop cycle. * * @method onUpdate */ onUpdate: function onUpdate() { }, /** * Actor rendering event method. Called for each actor to * render for each frame. * * @method onRender * @param ctx {object} Canvas rendering context * @param world {object} World metadata */ onRender: function onRender(ctx, world) { } }; })(); /** * SpriteActor base class. * * An actor that can be rendered by a bitmap. The sprite handling code deals with the increment * of the current frame within the supplied bitmap sprite strip image, based on animation direction, * animation speed and the animation length before looping. Call renderSprite() each frame. * * NOTE: by default sprites source images are 64px wide 64px by N frames high and scaled to the * appropriate final size. Any other size input source should be set in the constructor. * * @namespace Game * @class Game.SpriteActor */ (function() { Game.SpriteActor = function(p, v, s) { Game.SpriteActor.superclass.constructor.call(this, p, v); if (s) this.frameSize = s; return this; }; extend(Game.SpriteActor, Game.Actor, { /** * Size in pixels of the width/height of an individual frame in the image */ frameSize: 64, /** * Animation image sprite reference. * Sprite image sources are all currently 64px wide 64px by N frames high. */ animImage: null, /** * Length in frames of the sprite animation */ animLength: 0, /** * Animation direction, true for forward, false for reverse. */ animForward: true, /** * Animation frame inc/dec speed. */ animSpeed: 1.0, /** * Current animation frame index */ animFrame: 0, /** * Render sprite graphic based on current anim image, frame and anim direction * Automatically updates the current anim frame. * * Optionally this method will automatically correct for objects moving on/off * a cyclic canvas play area - if so it will render the appropriate stencil * sections of the sprite top/bottom/left/right as needed to complete the image. * Note that this feature can only be used if the sprite is absolutely positioned * and not translated/rotated into position by canvas operations. */ renderSprite: function renderSprite(ctx, x, y, w, cyclic) { var offset = this.animFrame << 6, fs = this.frameSize; ctx.drawImage(this.animImage, 0, offset, fs, fs, x, y, w, w); if (cyclic) { if (x < 0 || y < 0) { ctx.drawImage(this.animImage, 0, offset, fs, fs, (x < 0 ? (GameHandler.width + x) : x), (y < 0 ? (GameHandler.height + y) : y), w, w); } if (x + w >= GameHandler.width || y + w >= GameHandler.height) { ctx.drawImage(this.animImage, 0, offset, fs, fs, (x + w >= GameHandler.width ? (x - GameHandler.width) : x), (y + w >= GameHandler.height ? (y - GameHandler.height) : y), w, w); } } // update animation frame index if (this.animForward) { this.animFrame += this.animSpeed; if (this.animFrame >= this.animLength) { this.animFrame = 0; } } else { this.animFrame -= this.animSpeed; if (this.animFrame < 0) { this.animFrame = this.animLength - 1; } } } }); })(); /** * EffectActor base class. * * An actor representing a transient effect in the game world. An effect is nothing more than * a special graphic that does not play any direct part in the game and does not interact with * any other objects. It automatically expires after a set lifespan, generally the rendering of * the effect is based on the remaining lifespan. * * @namespace Game * @class Game.EffectActor */ (function() { Game.EffectActor = function(p, v, lifespan) { Game.EffectActor.superclass.constructor.call(this, p, v); this.lifespan = lifespan; return this; }; extend(Game.EffectActor, Game.Actor, { /** * Effect lifespan remaining */ lifespan: 0, /** * Actor expiration test * * @return true if expired and to be removed from the actor list, false if still in play */ expired: function expired() { // deduct lifespan from the explosion return (--this.lifespan === 0); } }); })(); /** * Image Preloader class. Executes the supplied callback function once all * registered images are loaded by the browser. * * @namespace Game * @class Game.Preloader */ (function() { Game.Preloader = function() { this.images = []; return this; }; Game.Preloader.prototype = { /** * Image list * * @property images * @type Array */ images: null, /** * Callback function * * @property callback * @type Function */ callback: null, /** * Images loaded so far counter */ counter: 0, /** * Add an image to the list of images to wait for */ addImage: function addImage(img, url) { var me = this; img.url = url; // attach closure to the image onload handler img.onload = function() { me.counter++; if (me.counter === me.images.length) { // all images are loaded - execute callback function me.callback.call(me); } }; this.images.push(img); }, /** * Load the images and call the supplied function when ready */ onLoadCallback: function onLoadCallback(fn) { this.counter = 0; this.callback = fn; // load the images for (var i=0, j=this.images.length; i 20) s = 20; else if (s < 8) s = 8; return s; }; Game.fontFamily = function fontFamily(world, size, font) { return Game.fontSize(world, size) + "pt " + (font ? font : "Courier New"); };