var clippy = {};
/******
*
*
* @constructor
*/
clippy.Agent = function (path, data, sounds) {
this.path = path;
this._queue = new clippy.Queue($.proxy(this._onQueueEmpty, this));
this._el = $('
').hide();
$(document.body).append(this._el);
this._animator = new clippy.Animator(this._el, path, data, sounds);
this._balloon = new clippy.Balloon(this._el);
this._setupEvents();
};
clippy.Agent.prototype = {
/**************************** API ************************************/
/***
*
* @param {Number} x
* @param {Number} y
*/
gestureAt:function (x, y) {
var d = this._getDirection(x, y);
var gAnim = 'Gesture' + d;
var lookAnim = 'Look' + d;
var animation = this.hasAnimation(gAnim) ? gAnim : lookAnim;
return this.play(animation);
},
/***
*
* @param {Boolean=} fast
*
*/
hide:function (fast, callback) {
this._hidden = true;
var el = this._el;
this.stop();
if (fast) {
this._el.hide();
this.stop();
this.pause();
if (callback) callback();
return;
}
return this._playInternal('Hide', function () {
el.hide();
this.pause();
if (callback) callback();
})
},
moveTo:function (x, y, duration) {
var dir = this._getDirection(x, y);
var anim = 'Move' + dir;
if (duration === undefined) duration = 1000;
this._addToQueue(function (complete) {
// the simple case
if (duration === 0) {
this._el.css({top:y, left:x});
this.reposition();
complete();
return;
}
// no animations
if (!this.hasAnimation(anim)) {
this._el.animate({top:y, left:x}, duration, complete);
return;
}
var callback = $.proxy(function (name, state) {
// when exited, complete
if (state === clippy.Animator.States.EXITED) {
complete();
}
// if waiting,
if (state === clippy.Animator.States.WAITING) {
this._el.animate({top:y, left:x}, duration, $.proxy(function () {
// after we're done with the movement, do the exit animation
this._animator.exitAnimation();
}, this));
}
}, this);
this._playInternal(anim, callback);
}, this);
},
_playInternal:function (animation, callback) {
// if we're inside an idle animation,
if (this._isIdleAnimation() && this._idleDfd && this._idleDfd.state() === 'pending') {
this._idleDfd.done($.proxy(function () {
this._playInternal(animation, callback);
}, this))
}
this._animator.showAnimation(animation, callback);
},
play:function (animation, timeout, cb) {
if (!this.hasAnimation(animation)) return false;
if (timeout === undefined) timeout = 5000;
this._addToQueue(function (complete) {
var completed = false;
// handle callback
var callback = function (name, state) {
if (state === clippy.Animator.States.EXITED) {
completed = true;
if (cb) cb();
complete();
}
};
// if has timeout, register a timeout function
if (timeout) {
window.setTimeout($.proxy(function () {
if (completed) return;
// exit after timeout
this._animator.exitAnimation();
}, this), timeout)
}
this._playInternal(animation, callback);
}, this);
return true;
},
/***
*
* @param {Boolean=} fast
*/
show:function (fast) {
this._hidden = false;
if (fast) {
this._el.show();
this.resume();
this._onQueueEmpty();
return;
}
if (this._el.css('top') === 'auto' || !this._el.css('left') === 'auto') {
var left = $(window).width() * 0.8;
var top = ($(window).height() + $(document).scrollTop()) * 0.8;
this._el.css({top:top, left:left});
}
this.resume();
return this.play('Show');
},
/***
*
* @param {String} text
*/
speak:function (text, hold) {
this._addToQueue(function (complete) {
this._balloon.speak(complete, text, hold);
}, this);
},
/***
* Close the current balloon
*/
closeBalloon:function () {
this._balloon.hide();
},
delay:function (time) {
time = time || 250;
this._addToQueue(function (complete) {
this._onQueueEmpty();
window.setTimeout(complete, time);
});
},
/***
* Skips the current animation
*/
stopCurrent:function () {
this._animator.exitAnimation();
this._balloon.close();
},
stop:function () {
// clear the queue
this._queue.clear();
this._animator.exitAnimation();
this._balloon.hide();
},
/***
*
* @param {String} name
* @returns {Boolean}
*/
hasAnimation:function (name) {
return this._animator.hasAnimation(name);
},
/***
* Gets a list of animation names
*
* @return {Array.}
*/
animations:function () {
return this._animator.animations();
},
/***
* Play a random animation
* @return {jQuery.Deferred}
*/
animate:function () {
var animations = this.animations();
var anim = animations[Math.floor(Math.random() * animations.length)];
// skip idle animations
if (anim.indexOf('Idle') === 0) {
return this.animate();
}
return this.play(anim);
},
/**************************** Utils ************************************/
/***
*
* @param {Number} x
* @param {Number} y
* @return {String}
* @private
*/
_getDirection:function (x, y) {
var offset = this._el.offset();
var h = this._el.height();
var w = this._el.width();
var centerX = (offset.left + w / 2);
var centerY = (offset.top + h / 2);
var a = centerY - y;
var b = centerX - x;
var r = Math.round((180 * Math.atan2(a, b)) / Math.PI);
// Left and Right are for the character, not the screen :-/
if (-45 <= r && r < 45) return 'Right';
if (45 <= r && r < 135) return 'Up';
if (135 <= r && r <= 180 || -180 <= r && r < -135) return 'Left';
if (-135 <= r && r < -45) return 'Down';
// sanity check
return 'Top';
},
/**************************** Queue and Idle handling ************************************/
/***
* Handle empty queue.
* We need to transition the animation to an idle state
* @private
*/
_onQueueEmpty:function () {
if (this._hidden || this._isIdleAnimation()) return;
var idleAnim = this._getIdleAnimation();
this._idleDfd = $.Deferred();
this._animator.showAnimation(idleAnim, $.proxy(this._onIdleComplete, this));
},
_onIdleComplete:function (name, state) {
if (state === clippy.Animator.States.EXITED) {
this._idleDfd.resolve();
}
},
/***
* Is the current animation is Idle?
* @return {Boolean}
* @private
*/
_isIdleAnimation:function () {
var c = this._animator.currentAnimationName;
return c && c.indexOf('Idle') === 0;
},
/**
* Gets a random Idle animation
* @return {String}
* @private
*/
_getIdleAnimation:function () {
var animations = this.animations();
var r = [];
for (var i = 0; i < animations.length; i++) {
var a = animations[i];
if (a.indexOf('Idle') === 0) {
r.push(a);
}
}
// pick one
var idx = Math.floor(Math.random() * r.length);
return r[idx];
},
/**************************** Events ************************************/
_setupEvents:function () {
$(window).on('resize', $.proxy(this.reposition, this));
this._el.on('mousedown', $.proxy(this._onMouseDown, this));
this._el.on('dblclick', $.proxy(this._onDoubleClick, this));
},
_onDoubleClick:function () {
if (!this.play('ClickedOn')) {
this.animate();
}
},
reposition:function () {
if (!this._el.is(':visible')) return;
var o = this._el.offset();
var bH = this._el.outerHeight();
var bW = this._el.outerWidth();
var wW = $(window).width();
var wH = $(window).height();
var sT = $(window).scrollTop();
var sL = $(window).scrollLeft();
var top = o.top - sT;
var left = o.left - sL;
var m = 5;
if (top - m < 0) {
top = m;
} else if ((top + bH + m) > wH) {
top = wH - bH - m;
}
if (left - m < 0) {
left = m;
} else if (left + bW + m > wW) {
left = wW - bW - m;
}
this._el.css({left:left, top:top});
// reposition balloon
this._balloon.reposition();
},
_onMouseDown:function (e) {
e.preventDefault();
this._startDrag(e);
},
/**************************** Drag ************************************/
_startDrag:function (e) {
// pause animations
this.pause();
this._balloon.hide(true);
this._offset = this._calculateClickOffset(e);
this._moveHandle = $.proxy(this._dragMove, this);
this._upHandle = $.proxy(this._finishDrag, this);
$(window).on('mousemove', this._moveHandle);
$(window).on('mouseup', this._upHandle);
this._dragUpdateLoop = window.setTimeout($.proxy(this._updateLocation, this), 10);
},
_calculateClickOffset:function (e) {
var mouseX = e.pageX;
var mouseY = e.pageY;
var o = this._el.offset();
return {
top:mouseY - o.top,
left:mouseX - o.left
}
},
_updateLocation:function () {
this._el.css({top:this._targetY, left:this._taregtX});
this._dragUpdateLoop = window.setTimeout($.proxy(this._updateLocation, this), 10);
},
_dragMove:function (e) {
e.preventDefault();
var x = e.clientX - this._offset.left;
var y = e.clientY - this._offset.top;
this._taregtX = x;
this._targetY = y;
},
_finishDrag:function () {
window.clearTimeout(this._dragUpdateLoop);
// remove handles
$(window).off('mousemove', this._moveHandle);
$(window).off('mouseup', this._upHandle);
// resume animations
this._balloon.show();
this.reposition();
this.resume();
},
_addToQueue:function (func, scope) {
if (scope) func = $.proxy(func, scope);
this._queue.queue(func);
},
/**************************** Pause and Resume ************************************/
pause:function () {
this._animator.pause();
this._balloon.pause();
},
resume:function () {
this._animator.resume();
this._balloon.resume();
}
};
/******
*
*
* @constructor
*/
clippy.Animator = function (el, path, data, sounds) {
this._el = el;
this._data = data;
this._path = path;
this._currentFrameIndex = 0;
this._currentFrame = undefined;
this._exiting = false;
this._currentAnimation = undefined;
this._endCallback = undefined;
this._started = false;
this._sounds = {};
this.currentAnimationName = undefined;
this.preloadSounds(sounds);
this._overlays = [this._el];
var curr = this._el;
this._setupElement(this._el);
for (var i = 1; i < this._data.overlayCount; i++) {
var inner = this._setupElement($(''));
curr.append(inner);
this._overlays.push(inner);
curr = inner;
}
};
clippy.Animator.prototype = {
_setupElement:function (el) {
var frameSize = this._data.framesize;
el.css('display', "none");
el.css({width:frameSize[0], height:frameSize[1]});
el.css('background', "url('" + this._path + "/map.png') no-repeat");
return el;
},
animations:function () {
var r = [];
var d = this._data.animations;
for (var n in d) {
r.push(n);
}
return r;
},
preloadSounds:function (sounds) {
for (var i = 0; i < this._data.sounds.length; i++) {
var snd = this._data.sounds[i];
var uri = sounds[snd];
if (!uri) continue;
this._sounds[snd] = new Audio(uri);
}
},
hasAnimation:function (name) {
return !!this._data.animations[name];
},
exitAnimation:function () {
this._exiting = true;
},
showAnimation:function (animationName, stateChangeCallback) {
this._exiting = false;
if (!this.hasAnimation(animationName)) {
return false;
}
this._currentAnimation = this._data.animations[animationName];
this.currentAnimationName = animationName;
if (!this._started) {
this._step();
this._started = true;
}
this._currentFrameIndex = 0;
this._currentFrame = undefined;
this._endCallback = stateChangeCallback;
return true;
},
_draw:function () {
var images = [];
if (this._currentFrame) images = this._currentFrame.images || [];
for (var i = 0; i < this._overlays.length; i++) {
if (i < images.length) {
var xy = images[i];
var bg = -xy[0] + 'px ' + -xy[1] + 'px';
this._overlays[i].css({'background-position':bg, 'display':'block'});
}
else {
this._overlays[i].css('display', 'none');
}
}
},
_getNextAnimationFrame:function () {
if (!this._currentAnimation) return undefined;
// No current frame. start animation.
if (!this._currentFrame) return 0;
var currentFrame = this._currentFrame;
var branching = this._currentFrame.branching;
if (this._exiting && currentFrame.exitBranch !== undefined) {
return currentFrame.exitBranch;
}
else if (branching) {
var rnd = Math.random() * 100;
for (var i = 0; i < branching.branches.length; i++) {
var branch = branching.branches[i];
if (rnd <= branch.weight) {
return branch.frameIndex;
}
rnd -= branch.weight;
}
}
return this._currentFrameIndex + 1;
},
_playSound:function () {
var s = this._currentFrame.sound;
if (!s) return;
var audio = this._sounds[s];
if (audio) audio.play();
},
_atLastFrame:function () {
return this._currentFrameIndex >= this._currentAnimation.frames.length - 1;
},
_step:function () {
if (!this._currentAnimation) return;
var newFrameIndex = Math.min(this._getNextAnimationFrame(), this._currentAnimation.frames.length - 1);
var frameChanged = !this._currentFrame || this._currentFrameIndex !== newFrameIndex;
this._currentFrameIndex = newFrameIndex;
// always switch frame data, unless we're at the last frame of an animation with a useExitBranching flag.
if (!(this._atLastFrame() && this._currentAnimation.useExitBranching)) {
this._currentFrame = this._currentAnimation.frames[this._currentFrameIndex];
}
this._draw();
this._playSound();
this._loop = window.setTimeout($.proxy(this._step, this), this._currentFrame.duration);
// fire events if the frames changed and we reached an end
if (this._endCallback && frameChanged && this._atLastFrame()) {
if (this._currentAnimation.useExitBranching && !this._exiting) {
this._endCallback(this.currentAnimationName, clippy.Animator.States.WAITING);
}
else {
this._endCallback(this.currentAnimationName, clippy.Animator.States.EXITED);
}
}
},
/***
* Pause animation execution
*/
pause:function () {
window.clearTimeout(this._loop);
},
/***
* Resume animation
*/
resume:function () {
this._step();
}
};
clippy.Animator.States = { WAITING:1, EXITED:0 };
/******
*
*
* @constructor
*/
clippy.Balloon = function (targetEl) {
this._targetEl = targetEl;
this._hidden = true;
this._setup();
};
clippy.Balloon.prototype = {
WORD_SPEAK_TIME:320,
CLOSE_BALLOON_DELAY:2000,
_setup:function () {
this._balloon = $(' ').hide();
this._content = this._balloon.find('.clippy-content');
$(document.body).append(this._balloon);
},
reposition:function () {
var sides = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
for (var i = 0; i < sides.length; i++) {
var s = sides[i];
this._position(s);
if (!this._isOut()) break;
}
},
_BALLOON_MARGIN:15,
/***
*
* @param side
* @private
*/
_position:function (side) {
var o = this._targetEl.offset();
var h = this._targetEl.height();
var w = this._targetEl.width();
var bH = this._balloon.outerHeight();
var bW = this._balloon.outerWidth();
this._balloon.removeClass('clippy-top-left');
this._balloon.removeClass('clippy-top-right');
this._balloon.removeClass('clippy-bottom-right');
this._balloon.removeClass('clippy-bottom-left');
var left, top;
switch (side) {
case 'top-left':
// right side of the balloon next to the right side of the agent
left = o.left + w - bW;
top = o.top - bH - this._BALLOON_MARGIN;
break;
case 'top-right':
// left side of the balloon next to the left side of the agent
left = o.left;
top = o.top - bH - this._BALLOON_MARGIN;
break;
case 'bottom-right':
// right side of the balloon next to the right side of the agent
left = o.left;
top = o.top + h + this._BALLOON_MARGIN;
break;
case 'bottom-left':
// left side of the balloon next to the left side of the agent
left = o.left + w - bW;
top = o.top + h + this._BALLOON_MARGIN;
break;
}
this._balloon.css({top:top, left:left});
this._balloon.addClass('clippy-' + side);
},
_isOut:function () {
var o = this._balloon.offset();
var bH = this._balloon.outerHeight();
var bW = this._balloon.outerWidth();
var wW = $(window).width();
var wH = $(window).height();
var sT = $(document).scrollTop();
var sL = $(document).scrollLeft();
var top = o.top - sT;
var left = o.left - sL;
var m = 5;
if (top - m < 0 || left - m < 0) return true;
if ((top + bH + m) > wH || (left + bW + m) > wW) return true;
return false;
},
speak:function (complete, text, hold) {
this._hidden = false;
this.show();
var c = this._content;
// set height to auto
c.height('auto');
c.width('auto');
// add the text
c.text(text);
// set height
c.height(c.height());
c.width(c.width());
c.text('');
this.reposition();
this._complete = complete;
this._sayWords(text, hold, complete);
},
show:function () {
if (this._hidden) return;
this._balloon.show();
},
hide:function (fast) {
if (fast) {
this._balloon.hide();
return;
}
this._hiding = window.setTimeout($.proxy(this._finishHideBalloon, this), this.CLOSE_BALLOON_DELAY);
},
_finishHideBalloon:function () {
if (this._active) return;
this._balloon.hide();
this._hidden = true;
this._hiding = null;
},
_sayWords:function (text, hold, complete) {
this._active = true;
this._hold = hold;
var words = text.split(/[^\S-]/);
var time = this.WORD_SPEAK_TIME;
var el = this._content;
var idx = 1;
this._addWord = $.proxy(function () {
if (!this._active) return;
if (idx > words.length) {
this._active = false;
if (!this._hold) {
complete();
this.hide();
}
} else {
el.text(words.slice(0, idx).join(' '));
idx++;
this._loop = window.setTimeout($.proxy(this._addWord, this), time);
}
}, this);
this._addWord();
},
close:function () {
if (this._active) {
this._hold = false;
} else if (this._hold) {
this._complete();
}
},
pause:function () {
window.clearTimeout(this._loop);
if (this._hiding) {
window.clearTimeout(this._hiding);
this._hiding = null;
}
},
resume:function () {
if (this._addWord) this._addWord();
this._hiding = window.setTimeout($.proxy(this._finishHideBalloon, this), this.CLOSE_BALLOON_DELAY);
}
};
clippy.BASE_PATH = './clippy.js/Agents/';
clippy.load = function (name, successCb, failCb) {
var path = clippy.BASE_PATH + name;
var mapDfd = clippy.load._loadMap(path);
var agentDfd = clippy.load._loadAgent(name, path);
var soundsDfd = clippy.load._loadSounds(name, path);
var data;
agentDfd.done(function (d) {
data = d;
});
var sounds;
soundsDfd.done(function (d) {
sounds = d;
});
// wrapper to the success callback
var cb = function () {
var a = new clippy.Agent(path, data,sounds);
successCb(a);
};
$.when(mapDfd, agentDfd, soundsDfd).done(cb).fail(failCb);
};
clippy.load._maps = {};
clippy.load._loadMap = function (path) {
var dfd = clippy.load._maps[path];
if (dfd) return dfd;
// set dfd if not defined
dfd = clippy.load._maps[path] = $.Deferred();
var src = path + '/map.png';
var img = new Image();
img.onload = dfd.resolve;
img.onerror = dfd.reject;
// start loading the map;
img.setAttribute('src', src);
return dfd.promise();
};
clippy.load._sounds = {};
clippy.load._loadSounds = function (name, path) {
var dfd = clippy.load._sounds[name];
if (dfd) return dfd;
// set dfd if not defined
dfd = clippy.load._sounds[name] = $.Deferred();
var audio = document.createElement('audio');
var canPlayMp3 = !!audio.canPlayType && "" != audio.canPlayType('audio/mpeg');
var canPlayOgg = !!audio.canPlayType && "" != audio.canPlayType('audio/ogg; codecs="vorbis"');
if (!canPlayMp3 && !canPlayOgg) {
dfd.resolve({});
} else {
var src = path + (canPlayMp3 ? '/sounds-mp3.js' : '/sounds-ogg.js');
// load
clippy.load._loadScript(src);
}
return dfd.promise()
};
clippy.load._data = {};
clippy.load._loadAgent = function (name, path) {
var dfd = clippy.load._data[name];
if (dfd) return dfd;
dfd = clippy.load._getAgentDfd(name);
var src = path + '/agent.js';
clippy.load._loadScript(src);
return dfd.promise();
};
clippy.load._loadScript = function (src) {
var script = document.createElement('script');
script.setAttribute('src', src);
script.setAttribute('async', 'async');
script.setAttribute('type', 'text/javascript');
document.head.appendChild(script);
};
clippy.load._getAgentDfd = function (name) {
var dfd = clippy.load._data[name];
if (!dfd) {
dfd = clippy.load._data[name] = $.Deferred();
}
return dfd;
};
clippy.ready = function (name, data) {
var dfd = clippy.load._getAgentDfd(name);
dfd.resolve(data);
};
clippy.soundsReady = function (name, data) {
var dfd = clippy.load._sounds[name];
if (!dfd) {
dfd = clippy.load._sounds[name] = $.Deferred();
}
dfd.resolve(data);
};
/******
* Tiny Queue
*
* @constructor
*/
clippy.Queue = function (onEmptyCallback) {
this._queue = [];
this._onEmptyCallback = onEmptyCallback;
};
clippy.Queue.prototype = {
/***
*
* @param {function(Function)} func
* @returns {jQuery.Deferred}
*/
queue:function (func) {
this._queue.push(func);
if (this._queue.length === 1 && !this._active) {
this._progressQueue();
}
},
_progressQueue:function () {
// stop if nothing left in queue
if (!this._queue.length) {
this._onEmptyCallback();
return;
}
var f = this._queue.shift();
this._active = true;
// execute function
var completeFunction = $.proxy(this.next, this);
f(completeFunction);
},
clear:function () {
this._queue = [];
},
next:function () {
this._active = false;
this._progressQueue();
}
};