/**
* @module Learning
* @namespace springroll.pbskids
* @requires Core
*/
(function($, undefined)
{
// Core SpringRoll classes
var Application = include('springroll.Application');
var Debug = include('springroll.Debug', false);
var SavedData = include('springroll.SavedData');
var EventDispatcher = include('springroll.EventDispatcher');
// Internal classes
var EventCatalog = include('springroll.pbskids.EventCatalog');
var EventError = include('springroll.pbskids.EventError');
var EventSignature = include('springroll.pbskids.EventSignature');
var EventUtils = include('springroll.pbskids.EventUtils');
var LearningError = include('springroll.pbskids.LearningError');
var ValidationError = include('springroll.pbskids.ValidationError');
/**
* The base game class
* @class Learning
* @extends springroll.EventDispatcher
* @constructor
* @param {springroll.Application} app The application reference
* @param {boolean} [showTray=false] Show the documentation at init or false (dev build only!)
*/
var Learning = function(app, showTray)
{
EventDispatcher.call(this);
/**
* Create a new instance of the event catalog
* @property {springroll.EventCatalog} catalog
*/
this.catalog = new EventCatalog();
if (DEBUG)
{
if ($ === undefined)
{
this._handleError('jQuery is required for debug mode');
return;
}
/**
* The documentation dom element, development build only!
* @property {Element} _tray
* @private
*/
this._tray = $('<div class="learning-tray">' +
'<h2>Learning API <span class="learning-version"></span></h2>' +
'</div>');
/**
* The toggle handle dom element, development build only!
* @property {Element} _handle
* @private
*/
this._handle = $('<button class="learning-handle"></button>');
// Match the last position of the PT tray.
// ie Start with the tray open ('learning-tray-show') when reloading
// or returning to the game.
var defaultTrayPosition = SavedData.read('learning-tray-show') ?
'learning-tray-show' :
'learning-tray-hide';
/**
* The body dom element, development build only!
* @property {Element} _body
* @private
*/
this._body = $("body").append(this._tray, this._handle)
.addClass(defaultTrayPosition);
this._handle.click(this.toggleDocs.bind(this));
this.showTray = !!showTray;
}
/**
* The collection of timers
* @property {object} _timers
* @private
*/
this._timers = {};
//Add the spec, can be added later
this.spec = null;
/**
* The reference to the application
* @property {springroll.Application} _app
* @private
*/
this._app = app;
/**
* The saved feedback or instructions
* @property {Object} _feedback
* @private
*/
this._feedback = null;
/**
* The saved data for movie events
* @property {Object} _movie
* @private
*/
this._movie = null;
/**
* The collection of api methods called
* @property {array} _history
* @private
*/
this._history = [];
/**
* The current level number if support, null if unsupported
* @property {int} _round
* @private
* @default null
*/
this._level = null;
/**
* The current round number if support, null if unsupported
* @property {int} _round
* @private
* @default null
*/
this._round = null;
/**
* Keep track of the round a feedback event was started on
* the ending event should dispatch the same round.
* @property {int} _feedbackStartRound
* @private
* @default null
*/
this._feedbackStartRound = null;
//Add event to handle the internal timers
updateTimers = updateTimers.bind(this);
app.on('update', updateTimers);
//Add a listeners for called
this.on(CALLED, this._onCalled.bind(this));
};
/**
* If the Learning should throw errors
* @property {Boolean} throwErrors
* @static
*/
Learning.throwErrors = false;
//Reference to the prototype
var s = EventDispatcher.prototype;
var p = EventDispatcher.extend(Learning);
/**
* An event is tracked
* @event learningEvent
* @param {object} data The event data
* @param {string} data.game_id The unique game id
* @param {string} data.event_id The unique event id
* @param {object} data.event_data The data attached to event
* @param {int} data.event_data.event_code The code of the event
*/
var EVENT = 'learningEvent';
/**
* An api method was called, this happens before any validation
* @event called
* @param {string} api The name of the api method called
*/
var CALLED = 'called';
/**
* Handle errors
* @method _handleError
* @private
* @param {Error} error The error to handle
* @return {[type]} [description]
*/
p._handleError = function(error)
{
try
{
if (typeof error === "string")
{
error = new LearningError(error);
}
throw error;
}
catch (e)
{
if (DEBUG)
{
if (e instanceof ValidationError)
{
this._showError(e.message, e.api, e.property);
}
else if (e instanceof EventError)
{
this._showError(e.message, e.api);
}
if (Debug)
{
Debug.error(error);
}
}
if (Learning.throwErrors)
{
throw e;
}
}
};
/**
* The map of API event name overrides
* @method addMap
* @param {object} eventDictionary The collection of game-specific APIs, this is a map
* of the eventCode to the name of the API method
*/
p.addMap = function(eventDictionary)
{
if (eventDictionary)
{
try
{
this.catalog.add(eventDictionary);
}
catch (e)
{
this._handleError(e);
}
}
};
/**
* The tracking specification
* @property {object} spec
* @property {string} spec.gameId
* @property {int} spec.version
* @property {array} spec.events
*/
Object.defineProperty(p, "spec",
{
get: function()
{
return this._spec;
},
set: function(spec)
{
this._spec = spec;
if (spec)
{
var api, args, eventData, eventCode;
for (eventCode in spec.events)
{
api = this.catalog.events[eventCode];
if (!api)
{
api = 'event' + eventCode;
this.catalog.events[eventCode] = api;
}
eventData = spec.events[eventCode];
//Create the dynamic API method based on the
//arguments found in the spec event data
if (this[api] === undefined)
{
this[api] = this._specTrack.bind(this, api);
args = eventData.args;
}
//Allow for a staticly defined override
else
{
args = EventCatalog.args[eventCode];
}
//Create a new signature for the api call
var signature = new EventSignature(
eventCode,
api,
args,
eventData.args,
eventData.info
);
this[api].signature = signature;
if (DEBUG)
{
this._tray.append(signature.docs());
}
}
//Populate the tray with some information
if (DEBUG)
{
$(".learning-version").text(spec.version);
$(".learning-api").click(this._toggleRowCollapse.bind(this));
}
}
}
});
if (DEBUG)
{
/**
* When clicking on a method name
* @method _toggleRowCollapse
* @private
* @param {event} e The click event
*/
p._toggleRowCollapse = function(e)
{
$(e.currentTarget).parent().toggleClass('collapsed');
};
/**
* Toogle the display of the documentation
* @method toggleDocs
*/
p.toggleDocs = function()
{
var show = !this._body.hasClass('learning-tray-show');
this._body.removeClass('learning-tray-show learning-tray-hide')
.addClass(show ? 'learning-tray-show' : 'learning-tray-hide');
//remember the position of the tray for this session
SavedData.write('learning-tray-show', show);
this._app.triggerResize();
};
/**
* Show the documentation panel, development build only!
* @property {boolean} showTray
*/
Object.defineProperty(p, 'showTray',
{
set: function(show)
{
this._tray.hide();
if (show)
{
this._tray.show();
}
}
});
}
if (RELEASE)
{
//Set-up public methods for release build
//so that the API stays consistent
p.toggleDocs = function()
{
this._handleError("toggleDocs only available in dev build");
};
Object.defineProperty(p, 'showTray',
{
set: function(show)
{
this._handleError("showTray setter only available in dev build");
}
});
}
/**
* Convenience function for measuring the duration which is common
* for many events. These timers respect the application being paused
* and should be use instead of implementing Date.now() or some other
* Date-based method.
* @method startTimer
* @param {string} alias A unique alias for this timer
*/
p.startTimer = function(alias)
{
if (this._timers[alias] !== undefined)
{
this._handleError("Timer exists matching '" + alias + "', call stopTimer first");
return;
}
this._timers[alias] = 0;
};
/**
* Check the current progress of a timer, this will not destory the timer
* @method pollTimer
* @param {string} alias The unique alias for this timer
* @return {int} The timer in milliseconds
*/
p.pollTimer = function(alias)
{
if (this._timers[alias] === undefined)
{
this._handleError("Timer doesn't exist matching '" + alias + "'");
return;
}
return this._timers[alias] | 0;
};
/**
* Get the amount of time since the start of the game
* @method gameTime
* @return {int} The time since the beginning of the game in milliseconds
*/
p.gameTime = function()
{
return this.pollTimer('_game');
};
/**
* Stop a timer and get the final duration to send with an event. This
* will clean-up and discard the timer and it can't be used again.
* @method stopTimer
* @param {string} alias The unique alias for this timer
* @return {int} The timer in milliseconds
*/
p.stopTimer = function(alias)
{
var duration = this.pollTimer(alias);
this.removeTimer(alias);
return duration;
};
/**
* This will clean-up and discard the timer and it can't be used again.
* @method removeTimer
* @param {string} alias The unique alias for this timer
*/
p.removeTimer = function(alias)
{
if (this._timers[alias] !== undefined)
{
delete this._timers[alias];
}
};
/**
* Handle the frame update
* @method updateTimers
* @private
* @param {int} elapsed The number of milliseconds since the last update
*/
var updateTimers = function(elapsed)
{
for (var alias in this._timers)
{
this._timers[alias] += elapsed;
}
};
/**
* Override for start game event
* @method startGame
*/
p.startGame = function()
{
var sign = this.startGame.signature;
//make sure signature exists
if (!sign)
{
this._handleError("startGame: signature is undefined");
return;
}
//Initialize the round
if (sign.hasProperty('round', true))
{
this._round = 0;
}
//Initialize the level
if (sign.hasProperty('level', true))
{
this._level = 0;
}
//Reset the history on start game
this._history.length = 0;
this._history.push('startGame');
this.startTimer('_game');
this._track('startGame',
{
version: this._spec.version
});
};
/**
* When a user clicks or taps on the play button to start the game.
* @method clickPlay
* @param {Object} coordinates The coordinates object
* @param {int} coordinates.x The x position clicked or tapped
* @param {int} coordinates.y The y position clicked or tapped
* @param {int} coordinates.stage_width The stage width
* @param {int} coordinates.stage_height The stage height
*/
p.clickPlay = function(coordinates)
{
if (!this.clickPlay.signature)
{
this._handleError("clickPlay: signature is undefined");
return;
}
this._track('clickPlay',
{
coordinates: coordinates
});
};
/**
* Override for the end game event
* @method endGame
* @param {string} [exitType] The exit type for certain games
*/
p.endGame = function(exitType)
{
var sessionDuration = this.gameTime();
var signature = this.endGame.signature;
if (signature.hasProperty('exit_type', true))
{
this._track('endGame',
{
session_duration: sessionDuration,
exit_type: exitType || ''
});
}
else
{
this._track('endGame',
{
session_duration: sessionDuration
});
}
//Reset the history on start game
this._history.length = 0;
};
/**
* Basic method for starting a feedback or instruction
* @method _startFeedback
* @private
* @param {string} api The event method to call
* @param {string} description Description of the instruction
* @param {string} identifier A unique identifier
* @param {string} mediaType Either audio animation or other
* @param {int} totalDuration The estimated time of instruction in milliseconds
*/
p._startFeedback = function(api, description, identifier, mediaType, totalDuration)
{
if (this._feedback)
{
this._handleError("Feedback or instruction already started, stop it first");
return;
}
var feedback = {
media_type: mediaType,
description: description,
identifier: identifier,
total_duration: totalDuration
};
this._feedbackStartRound = this._round;
this._track(api, feedback);
this.startTimer('_feedback');
this._feedback = feedback;
};
/**
* Basic method for starting a feedback or instruction
* @method _startFeedback
* @private
* @param {string} api The event method to call
*/
p._endFeedback = function(api)
{
var feedback = this._feedback;
if (!feedback)
{
this._handleError("Feedback or instruction not found, start it first");
return;
}
delete feedback.total_duration;
feedback.duration = this.stopTimer('_feedback');
this._feedback = null;
this._track(api, feedback, this._feedbackStartRound);
this._feedbackStartRound = null;
};
/**
* Start the system initiated instruction
* @method startInstruction
* @param {string} description The text description of the instruction
* @param {string} identifier A unique identifier for this peice of instruction
* @param {string} mediaType The type of media, audio animation or other
* @param {int} total_duration The estimated duration of the media in milliseconds
*/
p.startInstruction = function(description, identifier, mediaType, totalDuration)
{
this._startFeedback('startInstruction', description, identifier, mediaType, totalDuration);
};
/**
* End the system initiated instruction
* @method endInstruction
*/
p.endInstruction = function()
{
if (!this.requires || !this.requires('startInstruction')) return;
this._endFeedback('endInstruction');
};
/**
* Start the incorrect feedback
* @method startIncorrectFeedback
* @param {string} description The text description of the instruction
* @param {string} identifier A unique identifier for this peice of instruction
* @param {string} mediaType The type of media, audio animation or other
* @param {int} total_duration The estimated duration of the media in milliseconds
*/
p.startIncorrectFeedback = function(description, identifier, mediaType, totalDuration)
{
this._startFeedback('startIncorrectFeedback', description, identifier, mediaType, totalDuration);
};
/**
* End the incorrect feedback
* @method endIncorrectFeedback
*/
p.endIncorrectFeedback = function()
{
if (!this.requires || !this.requires('startIncorrectFeedback')) return;
this._endFeedback('endIncorrectFeedback');
};
/**
* Start the correct feedback event
* @method startCorrectFeedback
* @param {string} description The text description of the instruction
* @param {string} identifier A unique identifier for this peice of instruction
* @param {string} mediaType The type of media, audio animation or other
* @param {int} total_duration The estimated duration of the media in milliseconds
*/
p.startCorrectFeedback = function(description, identifier, mediaType, totalDuration)
{
this._startFeedback(
'startCorrectFeedback',
description,
identifier,
mediaType,
totalDuration
);
};
/**
* End the correct feedback event
* @method endCorrectFeedback
*/
p.endCorrectFeedback = function()
{
if (!this.requires || !this.requires('startCorrectFeedback')) return;
this._endFeedback('endCorrectFeedback');
};
/**
* The movie started
* @method startMovie
* @param {string} movieId The identifier for the movie that's playing
* @param {int} duration The duration of the media playback in milliseconds
* @param {string} description The text or description of the instruction
*/
p.startMovie = function(movieId, duration, description)
{
if (this._movie)
{
this._handleError("Movie is already started called skipMovie or endMovie first");
return;
}
this._movie = {
movie_id: movieId,
duration: duration,
description: description
};
this.startTimer('_movie');
this._track('startMovie', this._movie);
};
/**
* The user decided to skip the movie playback by clicking a skip button
* @method skipMovie
*/
p.skipMovie = function()
{
if (!this.requires || !this.requires('startMovie')) return;
var movie = this._movie;
if (!movie)
{
this._handleError("No movie started, call startMovie first");
return;
}
movie.time_played = this.stopTimer('_movie');
this._movie = null;
this._track('skipMovie', movie);
};
/**
* The movie ended
* @method endMovie
*/
p.endMovie = function()
{
if (!this.requires || !this.requires('startMovie')) return;
var data = this._movie;
if (!data)
{
this._handleError("No movie started, call startMovie first");
return;
}
this.removeTimer('_movie');
var movie = this._movie;
this._movie = null;
this._track('endMovie', movie);
};
/**
* Handler when an api is called
* @method _onCalled
* @private
* @param {string} api The name of the API method called
*/
p._onCalled = function(api)
{
if (api === 'startRound' && this._round !== null)
{
this._round++;
}
else if (api === 'startLevel' && this._level !== null)
{
this._level++;
}
};
/**
* Generic method to track an event based on the spec, the arguments
* mirror the arguments in the event spec.
* @method _specTrack
* @private
* @param {string} api The name of the api
* @param {*} [...extraArgs] The Additional arguments
*/
p._specTrack = function(api)
{
var signature = this[api].signature,
data = null;
try
{
data = EventUtils.argsMap(
signature.args,
Array.prototype.slice.call(arguments, 1)
);
}
catch (error)
{
if (error instanceof EventError)
{
error.api = api;
error.eventCode = this.catalog.lookup(api);
error.signature = signature;
}
this._handleError(error);
return;
}
//Now we have a formatted data object, pass to the track method
this._track(api, data);
};
/**
* Generic method to track an event based on the spec, the arguments
* mirror the arguments in the event spec.
* @method _track
* @private
* @param {string} api The name of the api
* @param {object} [input] The collection of argument values
* @param {int} [round] The explicit round to add the track event for
*/
p._track = function(api, input, round)
{
if (!this.requires || !this.requires('startGame')) return;
var eventCode = this.catalog.lookup(api),
eventData = this._spec.events[eventCode],
signature = this[api].signature,
data = null;
//Check that the event code is valid on this spec
if (eventData === undefined)
{
this._handleError(new EventError("Supplied event code is invalid", eventCode, api));
return;
}
try
{
//Validate the specification arguments against the input
data = EventUtils.validate(
signature.eventArgs,
input
);
}
catch (error)
{
if (error instanceof EventError)
{
error.api = api;
error.eventCode = eventCode;
error.signature = signature;
}
this._handleError(error);
return;
}
//Trigger the called event, validation checked out
this.trigger(CALLED, api);
//If we're using the concept of levels, add it
if (this._level !== null)
{
data.level = this._level;
}
//If we're using the concept of rounds, add it
if (round !== undefined && round !== null)
{
data.round = round;
}
else if (this._round !== null)
{
data.round = this._round;
}
//Get the current game time since the start
//this gets applyed to all events being sent
data.game_time = this.gameTime();
//Add the event code to the data
data.event_code = parseInt(eventCode);
if (DEBUG)
{
$("#learning-api-" + api).addClass('success');
}
//Key track of the tracking history
//so we can do a history check
//using requires
this._history.push(api);
//Trigger an event where the event is the API
//and the parameter is the event data
this.trigger(api, data);
//Dispatch the tracking event here
this.trigger(
EVENT,
{
game_id: this._spec.gameId,
event_id: eventData.id,
event_data: data
}
);
};
if (DEBUG)
{
/**
* Display an error in the tray
* @method _showError
* @private
* @param {string} message The message to log
* @param {string} api The name of the api
* @param {string} [property] Optional property
*/
p._showError = function(message, api, property)
{
var container = $("#learning-api-" + api)
.addClass('error')
.removeClass('collapsed');
message = '<span class="learning-api-alert">' + message + '</span>';
// Add the erroring to the property specifically
if (property)
{
container.find(".arg-" + property)
.addClass('error')
.prepend(message);
}
// Add the error message to the container
else
{
container.find('.learning-api').after(message);
}
};
}
/**
* Require that an api has been called
* @method requires
* @param {String} api The names of the method or API call
* @return {Boolean} If the api was called before
*/
p.requires = function(api)
{
if (this._history.indexOf(api) === -1)
{
this._handleError("Learning API '" + api + "' needs to be called first");
return false;
}
return true;
};
/**
* Don't use after this
* @method destroy
*/
p.destroy = function()
{
if (this._app)
{
this._app.off('update', updateTimers);
}
if (this.catalog)
{
this.catalog.destroy();
this.catalog = null;
}
this.off(CALLED);
if (DEBUG)
{
this._body.removeClass('learning-tray-show learning-tray-hide');
this._handle.remove();
this._tray.remove();
this._handle = null;
this._tray = null;
this._body = null;
}
this._app = null;
this._timers = null;
this._spec = null;
this._history = null;
this._movie = null;
this._feedback = null;
s.destroy.call(this);
};
//Assign to namespace
namespace('springroll.pbskids').Learning = Learning;
}(window.jQuery));