Gamification is happening all over the place. In case you’ve missed what it’s all about, this short video should help. To get even more background, here’s another from the same source on augmented reality games, which is a follow-on from this video on alternate reality games.
After having managed to get Apollonian Packings brought into the very cool PointCloud Browser, I thought it’d be fun to integrate some of the code from another of the PointCloud Browser sample apps, creating a little augmented reality game.
Here’s the basic idea: each level consists of a packing brought into PointCloud Browser from the Apollonian web-service, and you have twice as many bullets as there are spheres to pop them all (which you do by aiming the cross-hairs and tapping the screen). The good news is that each time you pop a sphere you get two more bullets (an idea copied directly from the original Ball Invasion sample, along with much of its code).
What you end up with is something that is actually pretty fun – and surprisingly therapeutic… it’s a bit like popping 3D bubble-wrap – and is an interesting way to experience the internal structure of a 3D fractal. My 6-year old is now regularly pestering me to let him play it, which I take as a good sign. :-)
Here are some screenshots:
Here’s a video of me playing the game. It was a bit tricky capturing what was being shown on the iPad and playing the game at the same time, but it settles down after about a minute (please don’t give up on it straight away).
The game has a few quirks that I haven’t yet ironed out: some spheres just end up being impossible to shoot, which I’m guessing is due to the (usually invisible) base plane stopping the bullets from reaching them. It should be simple enough to fix, but please don’t get too frustrated if this happens to you.
I’ve also left the game open-ended: the levels just keep on going. The Apollonian web-service will only serve up packings for up to level 10, but I’ve in any case found that my iPad 2 slows down in a big way when it gets to level 5 (with 989 spheres) and is basically unusable at level 6 (with 2,837). It may be that more recent iPad devices manage later levels very well, so I haven’t capped them at all.
I do wonder whether gamifiying design work is an interesting concept or not (I mostly suspect not, but I could well be wrong). Very interestingly two of the entrants of the APPHACK at AU 2012 were focused on the idea of gamifying AutoCAD usage, providing awards, etc. when you use certain commands. Perhaps gamifying augmented reality clash detection in BIM 360 or in-place analysis via Simulation 360 would be fun and compelling (and yes, I’m pulling these examples out of thin air, so there’s really no need to tell if you find them ridiculous).
Anyway, here’s the code. I’ve avoided a separate JavaScript file (which I’d probably have used under normal circumstances for the bulk of this implementation) to make it simpler to post here.
You can load this page directly in the PointCloud Browser by entering the following in the application’s address-bar: http://autode.sk/apparg.
<!DOCTYPE html>
<html>
<head>
<title>Apollonian Obliteration</title>
<meta
name="viewport"
content="user-scalable=no, width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<meta name="viper-init-options" content="manual"/>
<link
rel="viper-app-icon" type="images/png"
href="resources/images/appicon2.png"/>
<link
rel="stylesheet" href="css/common.css" type="text/css"
charset="utf-8"/>
<link
rel="stylesheet" href="resources/css/ballinvasion.css"
type="text/css" charset="utf-8"/>
<script
type="text/javascript"
src="http://code.jquery.com/jquery-1.7.1.min.js">
</script>
<script type="text/javascript" src="js/common.js"></script>
<script type="text/xml" id="library">
<library>
<sound
id="fire" pitch="1.0" volume="0.8"
src="resources/sounds/fire.mp3"/>
<sound
id="explosion" prototype="fire"
src="resources/sounds/explosion.mp3"/>
<sound
id="bounce" pitch="1.0" volume="0.5"
src="resources/sounds/bounce.mp3"/>
<sound
id="reset" pitch="1.0" volume="0.5"
src="resources/sounds/reset.mp3"/>
<mesh
id="cameracube_mesh" primitive="cube" width="0.05"
height="0.05" len="0.05"
texture_src="resources/images/explosion.png"/>
<mesh
id="bullet_mesh" primitive="sphere" radius="0.01"
details="2" color="0.3,0.3,0.3,1.0"/>
<mesh
id="base_plane_mesh" primitive="plane" width="1.0"
height="1.0"
texture_src="resources/images/init_texture.png"/>
<mesh
id="sphere_mesh" radius="0.1" primitive="sphere"
details="2" />
<node
id="sphere_node" static="true"
notify_collisions_with_classes="bullet"
bounce_factor="0" friction_factor="0.99">
<model
texture_src="resources/images/white-4x4.png"
id="sphere_model" mesh="sphere_mesh"/>
</node>
<node
id="bullet" class="bullet" mass="1.0" bounce_factor="0.3"
friction_factor="0.90">
<model mesh="bullet_mesh"/>
<animation
class="fadeout" duration="1.5s" delay="1.5s"
autostart="true">
<property name="alpha" from="1.0" to="0.0"
function="linear"/>
</animation>
</node>
<node id="explosion_node" scale="0.1" alpha="1.0">
<model
id="explosion_model" blending="luminous"
texture_src="resources/images/explosion.png"/>
<animation
class="explosion" duration="0.4" autostart="true">
<property
name="scale" from="0.1" to="2.0" function="ease-out"/>
<property
name="alpha" from="1" to="0" function="ease-out"/>
</animation>
</node>
</library>
</script>
<script type="text/xml" id="scene">
<scene
base="relative-center" gravity_constant="0.5"
notify_collisions_with_classes="bullet">
<light id="main_light"
intensity="1.0"
fade="constant"
ambient="0.2, 0.2, 0.2, 0.2"
diffuse="1.0, 1.0, 1.0, 1.0"
specular="1.0, 1.2, 1.2, 1.0"
position="3, 0.5, 2, 0"/>
<node id="base_plane" position="0,0,0" alpha="0.0">
<model mesh="base_plane_mesh" blending="no_shadows"/>
<animation
class="color_animation" id="show" duration="0.3">
<property
name="alpha" from="0.0" to="1.0" function="ease-out"/>
</animation>
<animation
class="color_animation" id="hide" duration="0.3">
<property
name="alpha" from="1.0" to="0.0" function="ease-out"/>
</animation>
</node>
</scene>
</script>
<script type="text/javascript">
var appobliteration = {
STATE_MENU : 0,
STATE_GAME_STARTING : 1,
STATE_GAME_ONGOING : 2,
state : -1,
init : function() {
appobliteration.game.init();
appobliteration.menu.init();
},
onStartGameRequested : function(course) {
if (!viper.isCameraEnabled()) {
viper.setCameraEnabled();
}
// Hide menu regardless...
appobliteration.menu.fadeOut();
// ...then determine whether we need to initialize a map,
// or whether we can start the game right away
if (viper.hasTracking()) {
appobliteration.startGame();
}
else {
appobliteration.state =
appobliteration.STATE_GAME_STARTING;
var resetExistingMap = false;
viper.requireRealityMap(resetExistingMap);
}
},
startGame : function() {
appobliteration.state =
appobliteration.STATE_GAME_ONGOING;
appobliteration.game.resetGame();
appobliteration.game.fadeIn();
},
endGame : function() {
appobliteration.state = appobliteration.STATE_MENU;
appobliteration.showMenu();
},
showMenu : function() {
appobliteration.game.fadeOut();
appobliteration.menu.fadeIn();
},
onMapCreated : function() {
if (
appobliteration.state ==
appobliteration.STATE_GAME_STARTING
) {
// Start the game when map has been created
// (called as a result of viper.requireRealityMap();
// above)
appobliteration.startGame();
}
else if (
appobliteration.state == appobliteration.STATE_MENU
) {
// May happen if we load a map from the browser UI
// while in the game menu
appobliteration.menu.fadeIn();
appobliteration.game.fadeIn(true);
}
else {
// Do nothing if we're already playing a game
}
},
}
appobliteration.menu = {
menuView : null,
init : function() {
this.menuView = $("#menu");
this.menuView.bind("webkitTransitionEnd", function() {
if ($(this).css("opacity") == 0.0) {
$(this).css("display", "none");
}
});
},
fadeIn : function() {
this.menuView.css("display", "block");
this.menuView.css("opacity", 1.0);
},
fadeOut : function() {
this.menuView.css("opacity", 0.0);
},
pushView : function(view) {
$("#menutitle").html(view.attr('title'));
$("#content").html(view.html());
var $panelbuttons = $("#content").find(".panelbutton");
appobliteration.ui.prepareButtons(
$panelbuttons,
appobliteration.menu.onMenuButtonClick
);
},
onMenuButtonClick : function($elem) {
viper.log("on menu button click: " + $elem.attr('id'));
if ($elem.attr('id') == 'play') {
appobliteration.onStartGameRequested();
}
else if ($elem.attr('id') == 'action') {
viper.goHome();
}
else if ($elem.attr('id') == 'more') {
viper.launchSystemURI("http://autode.sk/ttif");
}
},
}
appobliteration.ui = {
sightSource : "",
showSplash : function() {
var sight = $("#sight")[0];
appobliteration.ui.sightSource =
sight.style.backgroundImage;
sight.style.backgroundImage =
"url(resources/images/splash.png)";
},
hideSplash : function() {
$("#sight")[0].style.backgroundImage =
appobliteration.ui.sightSource;
},
prepareButtons : function($elem, fnc) {
viper.log("preparing button");
var maxMove = 16;
$elem.unbind();
$elem.bind("touchstart", function(e) {
viper.log("touched button");
var touch =
e.originalEvent.touches[0] ||
e.originalEvent.changedTouches[0];
$(this).data(
'touchdown',
{x: touch.pageX, y: touch.pageY}
);
});
$elem.bind("touchend", function(e) {
var touch =
e.originalEvent.touches[0] ||
e.originalEvent.changedTouches[0];
var touchDown = $(this).data('touchdown');
var maxMoveSq =
Math.pow((touchDown.x - touch.pageX),2) +
Math.pow((touchDown.y - touch.pageY), 2);
if (maxMoveSq < Math.pow(maxMove,2)) {
viper.log("executing function");
fnc($(this));
}
});
},
}
appobliteration.game = {
score : 0,
level : 1,
spheres : [],
bulletCount : 0,
sphereCount : 0,
roundsFired : 0,
cameraTransform : 0,
isRunning : false,
init : function() {
this.gameView = $("#game");
this.gameView.bind("webkitTransitionEnd",
function() {
if ($(this).css("opacity") == 0.0) {
$(this).css("display", "none");
}
}
);
this.gameNode = viper.getScene();
this.spheresForLevel(this.level);
var $buttons = $("#game").find(".clickable");
appobliteration.ui.prepareButtons(
$buttons, appobliteration.game.onGameButtonClick
);
},
onGameButtonClick : function($elem) {
if ($elem.attr('id') == 'recharge') {
appobliteration.game.populateWithSpheres(
appobliteration.game.spheres
);
appobliteration.game.updateDashboard();
viper.find("#reset").play();
}
else if ($elem.attr('id') == 'toggle_baseplane') {
viper.log('toggle baseplane');
appobliteration.game.toggleBasePlane($elem);
}
else if ($elem.attr('id') == 'toggle_points') {
appobliteration.game.togglePoints($elem);
}
},
togglePoints : function($elem) {
if (!$elem.data('on')) {
$elem.data('on', true);
$elem.addClass('selected');
viper.showTrackedPoints(true);
}
else {
$elem.data('on', false);
$elem.removeClass('selected');
viper.showTrackedPoints(false);
}
},
toggleBasePlane : function($elem) {
if (!$elem.data('on')) {
$elem.data('on', true);
$elem.addClass('selected');
var anim = viper.find("#base_plane").find("#show");
anim.start();
}
else {
$elem.data('on', false);
$elem.removeClass('selected');
var anim = viper.find("#base_plane").find("#hide");
anim.start();
}
},
populateWithSpheres : function(spheres) {
viper.log(
"Populate with spheres: " + spheres.length
);
// Hard-code the colour for each level in an array
var colors =
[ "0,0,0,1", "1,0,0,1", "1,1,0,1", "0,1,0,1",
"0,1,1,1", "0,0,1,1", "1,0,1,1", "0.9,0.9,0.9,1",
"0.6,0.6,0.6,1", "0.3,0.3,0.3,1", "1,1,1,1",
"1,1,1,1" ]
// Process each sphere, adding it to the scene
$.each(
spheres,
function (i, item) {
// Get shortcuts to our JSON data
//viper.log("Processing item " + i + ": " + item);
var x = item.X, y = item.Y, z = item.Z,
rad = item.R, level = item.L;
// Create a spherical node
var nodeID = "sphere_" + i;
var position = new viper.math.Vector(x, y + 1, z);
var sphere = new viper.Node(nodeID, position);
sphere.setPrototype("sphere_node");
sphere.setScale(rad * 10);
sphere.setTint(colors[parseInt(level)]);
appobliteration.game.gameNode.addChild(sphere);
}
);
appobliteration.game.sphereCount = spheres.length;
appobliteration.game.bulletCount = spheres.length * 2;
appobliteration.game.updateDashboard();
},
spheresForLevel : function(level) {
viper.log("Spheres for level: " + level);
appobliteration.ui.showSplash();
// Make sure CORS is enabled
jQuery.support.cors = true;
// Call our web-service with the appropriate level
$.ajax({
url:
'http://apollonian.cloudapp.net/api/spheres/0.3/' +
level,
crossDomain: true,
data: {},
dataType: "json"
}).done(function(results) {
viper.log("Successfully called web service.");
appobliteration.game.spheres = results;
appobliteration.game.populateWithSpheres(results);
appobliteration.ui.hideSplash();
});
},
resetGame : function() {
viper.find("#reset").play();
appobliteration.game.start();
},
createBullet : function(position, referenceNode) {
try {
var nodeID = "bullet_" + this.roundsFired;
var bulletNode = new viper.Node(nodeID);
// Get all properties from the prototype
bulletNode.setPrototype("bullet");
bulletNode.setPosition(position, referenceNode);
return bulletNode;
}
catch (ex) {
alert(ex);
}
},
fire : function() {
if (appobliteration.game.bulletCount <= 0) { return; }
var camera = viper.getCamera();
var bullet =
this.createBullet(
new viper.math.Vector(0,(0.2+0.01),0), camera
);
appobliteration.game.gameNode.addChild(bullet);
try {
var forceVector = null;
if (appobliteration.deviceAngle == 0) {
forceVector =
viper.Math.unit(new viper.math.Vector(0, 1.0, 0.2));
}
else if (appobliteration.deviceAngle == 90) {
forceVector =
viper.Math.unit(new viper.math.Vector(0.2, 1.0, 0));
}
else if (appobliteration.deviceAngle == 180) {
forceVector =
viper.Math.unit(new viper.math.Vector(0, 1.0, -0.2));
}
else if (appobliteration.deviceAngle == 270) {
forceVector =
viper.Math.unit(new viper.math.Vector(-0.2, 1.0, 0));
}
var innerPosition = viper.math.Vector(0,0,0);
bullet.push(1.1, forceVector, innerPosition, camera);
viper.log(
"Fired bullet in (local) direction " +
forceVector.toArray()
);
appobliteration.game.bulletCount--;
appobliteration.game.updateDashboard();
}
catch (ex) {
alert(ex);
}
viper.find("#fire").play();
this.roundsFired++;
},
updateDashboard : function() {
try {
$("#disp_current_score").html(
appobliteration.game.score
);
$("#disp_level").html(appobliteration.game.level);
$("#disp_bulletcount").html(
appobliteration.game.bulletCount
);
$("#disp_spherecount").html(
appobliteration.game.sphereCount
);
}
catch (ex) {
alert("error updating dash: " + ex);
}
},
onBounce : function(event) {
var bullet = event.element;
var bounceCount = bullet.getMeta('bounce_count', 0);
bounceCount++;
bullet.setMeta('bounce_count', bounceCount);
var pitch = 1.0;
var volume = 1 / (bounceCount*2);
viper.find("#bounce").play(pitch, volume);
},
onCollision : function(event1, event2) {
try {
// Determine which event belongs to which class
// (sphere vs bullet)
var bullet_collision = null;
var sphere_collision = null;
if (event1.element.getClass() == "bullet") {
bullet_collision = event1;
sphere_collision = event2;
}
else if (event2.element.getClass() == "bullet") {
sphere_collision = event1;
bullet_collision = event2;
}
else {
return;
}
// Bullet explosion
var bullet_explosion =
new viper.Node(
bullet_collision.element.id + "_explosion",
bullet_collision.position
);
bullet_explosion.setPrototype("explosion_node");
bullet_explosion.find("#explosion_model").
setMesh("bullet_mesh"); // Replace the mesh
// Sphere explosion
var sphere_explosion =
new viper.Node(
sphere_collision.element.id + "_explosion",
sphere_collision.position
);
sphere_explosion.setPrototype("explosion_node");
sphere_explosion.find("#explosion_model").
setMesh("sphere_mesh"); // Replace the mesh
viper.log(
'Collision position: ' +
sphere_collision.position.toArray()
);
viper.find("#explosion").play();
// Remove the colliding objects
bullet_collision.element.remove();
var sphereSuperNode =
sphere_collision.element.getOwnerNode();
if (
sphereSuperNode &&
sphereSuperNode.getClass() == "supernode"
) {
// Remove the sphere superclass, if any
viper.log("Removing sphere supernode");
sphereSuperNode.remove();
}
else {
// Remove the sphere directly
viper.log("Removing sphere directly");
sphere_collision.element.remove();
}
// Add the explosion objects
viper.getScene().addChild(sphere_explosion);
viper.getScene().addChild(bullet_explosion);
// Update numbers
appobliteration.game.score += 200; // A good score
appobliteration.game.sphereCount--; // 1 less sphere
appobliteration.game.bulletCount += 2; // 2 for each hit
// Update dashboard
appobliteration.game.updateDashboard();
if (appobliteration.game.sphereCount <= 0)
{
appobliteration.game.spheresForLevel(
++appobliteration.game.level
);
}
}
catch (ex) {
viper.log("Error: " + ex);
}
},
onAnimationFinished : function(animationElement) {
var animClass = animationElement.getClass();
if (
animClass != "color_animation" && animClass != null
) {
var node = animationElement.getOwnerNode();
if (node) {
node.remove();
}
}
},
fadeIn : function() {
var $fireFrame = $("#fireframe");
$fireFrame.css("display", "block").bind(
"touchstart",
function() {
if (
appobliteration.state ==
appobliteration.STATE_GAME_ONGOING
) {
appobliteration.game.fire();
}
}
);
this.gameView.css("opacity", 1.0);
},
fadeOut : function() {
this.gameView.css("opacity", 0.0);
$("#fireframe").unbind().css("display", "none");
},
pause : function() {
this.isRunning = false;
},
start : function() {
this.isRunning = true;
}
}
function onAppLoaded() {
}
function onViperReady() {
viper.setLoggingEnabled(false); // Logging is slow in IOS
viper.setBrowserBounce(false); // No browser window bounce
viper.resetRealityMap(); // Reset any existing map
viper.showTrackedPoints(false); // Default is points off
appobliteration.init();
var observer = {
onTrackingLost: function () {
},
onTrackingFound: function () {
viper.find("#base_plane").setPosition(
new viper.math.Vector(0, 0, 0), viper.getBasePlane()
);
},
onMapCreated: function () {
viper.find("#base_plane").setPosition(
new viper.math.Vector(0, 0, 0), viper.getBasePlane()
);
appobliteration.onMapCreated();
},
onMapCreationCancelled: function () {
appobliteration.showMenu();
},
onCollision: function (element1, element2) {
appobliteration.game.onCollision(element1, element2);
},
onBounce: function (element1) {
appobliteration.game.onBounce(element1);
},
onAnimationFinished: function (animationElement) {
appobliteration.game.onAnimationFinished(
animationElement
);
},
onDeviceOrientationChanged: function (angle) {
appobliteration.deviceAngle = angle;
var $screen = $("#screen");
var classname = "screen_orientation_" + angle;
$screen.removeClass().addClass(classname);
},
handleTouchStart: function (x, y) {
viper.log("GAME: Touch start at " + x + ", " + y);
return false;
},
handleTouchMove: function (x, y, dX, dY) {
return false;
},
handleTouchEnd: function (x, y) {
return false;
}
}
// Attach the observer to viper
viper.setObserver(observer);
appobliteration.menu.pushView($("#mainmenu"));
appobliteration.menu.fadeIn();
}
</script>
</head>
<body>
<div id="screen">
<div id="menu">
<div id="header">
<div class="inner"><div class="logo"></div></div>
</div>
<div class="headershade"></div>
<div class="menupanel">
<div class="stripes">
<div class="header">
<div id="menutitle">Title</div>
</div>
<div class="content_outer">
<div class="headershade"></div>
<div id="content"></div>
<div class="footershade"></div>
</div>
<div class="footer"></div>
</div>
</div>
<div id="mainmenu" class="menuview" title="Main Menu">
<div class="mainmenu_content">
<div id="play" class="panelbutton">
<div class="icon"></div>
<div class="title">Play the Game</div>
<div class="subtext">
Blast some Apollonian Packings
</div>
<div style="clear:both;"></div>
</div>
<div id="action" class="panelbutton">
<div class="icon"></div>
<div class="title">Go Home and Relax</div>
<div class="subtext">
Go back to the home page
</div>
<div style="clear:both;"></div>
</div>
<div id="more" class="panelbutton">
<div class="icon"></div>
<div class="title">Visit Kean's blog</div>
<div class="subtext">
Find out more about all this
</div>
<div style="clear:both;"></div>
</div>
</div>
</div>
</div>
<div id="game">
<div id="fireframe"></div>
<div id="header">
<div class="inner"><div class="logo"></div></div>
</div>
<div id="dashboard">
<div class="inner">
<div class="score_column">
<div class="caption">Current Score</div>
<div id="disp_current_score">0</div>
</div>
<div class="score_column">
<div class="caption">Level</div>
<div id="disp_level">0</div>
</div>
<div class="score_column">
<div class="caption">Bullets</div>
<div id="disp_bulletcount">0</div>
</div>
<div class="score_column">
<div class="caption">Enemies</div>
<div id="disp_spherecount">0</div>
</div>
<div style="clear:both;"></div>
</div>
</div>
<div class="headershade"></div>
<div id="sight"></div>
<div id="toggle_buttons">
<div id="toggle_baseplane" class="clickable"></div>
<div id="toggle_points" class="clickable"></div>
</div>
<div id="recharge" class="button clickable">Reset</div>
</div>
<div id="log"></div>
</div>
</body>
</html>