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(); } };