View = require 'views/kinds/CocoView' template = require 'templates/play/level/hud' prop_template = require 'templates/play/level/hud_prop' action_template = require 'templates/play/level/hud_action' DialogueAnimator = require './dialogue_animator' spriteUtils = require 'lib/surface/sprite_utils' module.exports = class HUDView extends View id: 'thang-hud' template: template dialogueMode: false constructor: (options) -> @thangIDMap = {} super options subscriptions: 'surface:frame-changed': 'onFrameChanged' 'surface:sprite-selected': 'onSpriteSelected' 'sprite:speech-updated': 'onSpriteDialogue' 'level-sprite-clear-dialogue': 'onSpriteClearDialogue' 'level-disable-controls': 'onDisableControls' 'level-enable-controls': 'onEnableControls' 'level:shift-space-pressed': 'onShiftSpacePressed' 'god:new-world-created': 'onNewWorldCreated' 'surface:ticked': 'onTick' 'dialogue-sound-completed': 'onDialogueSoundCompleted' events: 'click': -> Backbone.Mediator.publish 'focus-editor' onFrameChanged: (e) -> @timeProgress = e.progress @update() onDisableControls: (e) -> return if e.controls and not ('hud' in e.controls) @disabled = true onEnableControls: (e) -> return if e.controls and not ('hud' in e.controls) @disabled = false onNewWorldCreated: (e) -> @thangIDMap = {} for thang in e.world.thangs if @thang?.id is thang.id #console.log('HUD updated thang for', thang.id) @thang = thang @createActions() @thangIDMap[thang.id] = thang.spriteName onSpriteSelected: (e) -> # TODO: this allows the surface and HUD selection to get out of sync if we select another unit while in dialogue mode return if @disabled or @dialogueMode @switchToThangElements() @setThang e.thang onSpriteDialogue: (e) -> return unless e.message spriteID = e.sprite.thang.id spriteName = e.sprite.thangType?.get('name') or e.sprite.thang.spriteName @setSpeaker spriteID, spriteName @startAnimation spriteID @setMessage(e.message, e.mood, e.responses) window.tracker?.trackEvent 'Heard Sprite', {speaker: spriteID, message: e.message, label: e.message}, ['Google Analytics'] startAnimation: (spriteID) => @speakerStage.removeAllChildren() #spriteData = spriteMap.dataForThang(spriteID) spriteData = null # we deleted SpriteMap, but haven't refactored to use vector animated portraits yet canvas = $('canvas', @$el) image = $('.speaker-image', @$el) if spriteData?.sprite_data?.animations.portrait image.hide() canvas.show() else image.show() canvas.hide() return onDialogueSoundCompleted: -> return unless @portraitSprite @portraitSprite.gotoAndPlay('portrait_idle') onTick: -> @speakerStage.update() onSpriteClearDialogue: -> @clearSpeaker() afterRender: => super() @$el.addClass 'no-selection' @speakerStage = new createjs.Stage($('canvas', @$el)[0]) setThang: (thang) -> unless @speaker if not thang? and not @thang? then return if thang? and @thang? and thang.id is @thang.id then return @thang = thang @$el.toggleClass 'no-selection', not @thang? clearTimeout @hintNextSelectionTimeout @$el.find('.no-selection-message').hide() if not @thang @hintNextSelectionTimeout = _.delay((=> @$el.find('.no-selection-message').slideDown('slow')), 10000) return @createAvatar @thang.id, @sprite @createProperties() @createActions() @update() @speaker = null setSpeaker: (speaker, speakerType) -> return if speaker is @speaker image = @$el.find '.speaker-image' spriteUtils.createAvatar @thangIDMap[speakerType] or speakerType, image @speaker = speaker @$el.removeClass 'no-selection' @switchToDialogueElements() clearSpeaker: -> if not @thang @$el.addClass 'no-selection' #console.log "clearSpeaker and have thang", @thang @setThang @thang @switchToThangElements() @speaker = null @bubble = null @update() createAvatar: (id) -> image = @$el.find '.thang-image' spriteUtils.createAvatar @thangIDMap[id] or id, image image.attr('title', id).parent().removeClass('team-ogres').removeClass('team-humans').addClass('team-' + @thang.team) createProperties: -> props = @$el.find('.thang-props') props.find(":not(.thang-name)").remove() props.find('.thang-name').text(if @thang.id is @thang.spriteName then @thang.id else "#{@thang.id} - #{@thang.spriteName}") for prop in @thang.hudProperties ? [] pel = @createPropElement prop continue unless pel? if pel.find('.bar').is('*') and props.find('.bar').is('*') props.find('.bar-prop').last().after pel # Keep bars together else props.append pel createActions: -> actions = @$el.find('.thang-actions tbody').empty() return unless @thang.world and not _.isEmpty @thang.actions @buildActionTimespans() for actionName, action of @thang.actions actions.append @createActionElement(actionName) @lastActionTimespans[actionName] = {} setMessage: (message, mood, responses) -> message = marked message clearInterval(@messageInterval) if @messageInterval @bubble = $('.dialogue-bubble', @$el) @bubble.removeClass(@lastMood) if @lastMood @lastMood = mood @bubble.text('') group = $('
') @bubble.append(group) if responses @lastResponses = responses for response in responses button = $('').text(response.text) button.addClass response.buttonClass if response.buttonClass group.append(button) response.button = $('button:last', group) else s = $.i18n.t('play_level.hud_continue', defaultValue: "Continue (press shift-space)") group.append($('')) @lastResponses = null @bubble.append($("

#{@speaker ? 'Captain Anya'}

")) @animator = new DialogueAnimator(message, @bubble) @messageInterval = setInterval(@addMoreMessage, 20) addMoreMessage: => if @animator.done() clearInterval(@messageInterval) @messageInterval = null $('.enter', @bubble).removeClass("hide").css('opacity', 0.0).delay(500).animate({opacity:1.0}, 500, @animateEnterButton) if @lastResponses buttons = $('.enter button') for response, i in @lastResponses f = (r) => => setTimeout((-> Backbone.Mediator.publish(r.channel, r.event)), 10) $(buttons[i]).click(f(response)) else $('.enter', @bubble).click(-> Backbone.Mediator.publish('end-current-script')) return @animator.tick() onShiftSpacePressed: (e) -> # We don't need to handle end-current-script--that's done--but if we do have # custom buttons, then we need to trigger the one that should fire (the last one). # If we decide that always having the last one fire is bad, we should make it smarter. return unless @lastResponses?.length r = @lastResponses[@lastResponses.length - 1] _.delay (-> Backbone.Mediator.publish(r.channel, r.event)), 10 animateEnterButton: => return unless @bubble button = $('.enter', @bubble) dot = $('.dot', button) dot.animate({opacity:0.2}, 300).animate({opacity:1.9}, 600, @animateEnterButton) switchToDialogueElements: -> @dialogueMode = true $('.thang-elem', @$el).addClass('hide') $('.dialogue-area', @$el) .removeClass('hide') .animate({opacity:1.0}, 200) $('.dialogue-bubble', @$el) .css('opacity', 0.0) .delay(200) .animate({opacity:1.0}, 200) clearTimeout @hintNextSelectionTimeout switchToThangElements: -> @dialogueMode = false $('.thang-elem', @$el).removeClass('hide') $('.dialogue-area', @$el).addClass('hide') update: -> return unless @thang and not @speaker # Update avatar? # Update properties @updatePropElement(prop, @thang[prop]) for prop in @thang.hudProperties ? [] # Update action timeline @updateActions() createPropElement: (prop) -> if prop in ["maxHealth"] return null # included in the bar context = prop: prop hasIcon: prop in ["health", "pos", "target", "inventory"] hasBar: prop in ["health"] $(prop_template(context)) updatePropElement: (prop, val) -> pel = @$el.find '.thang-props *[name=' + prop + ']' if prop in ["health"] max = @thang["max" + prop.charAt(0).toUpperCase() + prop.slice(1)] regen = @thang[prop + "ReplenishRate"] percent = Math.round 100 * val / max pel.find('.bar').css 'width', percent + "%" labelText = prop + ": " + @formatValue(prop, val) + " / " + @formatValue(prop, max) if regen labelText += " (+" + @formatValue(prop, regen) + "/s)" pel.attr 'title', labelText else if prop in ["maxHealth"] return else s = @formatValue(prop, val) pel.find('.prop-value').text s pel.attr 'title', "#{prop}: #{s}" pel formatValue: (prop, val) -> if prop is "target" and not val val = @thang["targetPos"] val = null if val?.isZero() if prop is "rotation" return (val * 180 / Math.PI).toFixed(0) + "˚" if typeof val is 'number' if Math.round(val) == val then return val.toFixed(0) # int if -10 < val < 10 then return val.toFixed(2) if -100 < val < 100 then return val.toFixed(1) return val.toFixed(0) if val and typeof val is "object" if val.id return val.id else if val.x and val.y #return "x: #{val.x.toFixed(0)} y: #{val.y.toFixed(0)}" return "x: #{val.x.toFixed(0)} y: #{val.y.toFixed(0)}, z: #{val.z.toFixed(0)}" # Debugging: include z else if not val? return "No " + prop return val updateActions: -> return unless @thang.world and not _.isEmpty @thang.actions @buildActionTimespans() unless @timespans for actionName, action of @thang.actions @updateActionElement(actionName, @timespans[actionName], @thang.action.name is actionName) tableContainer = @$el.find('.table-container') timelineWidth = tableContainer.find('.action-timeline').width() right = (1 - (@timeProgress ? 0)) * timelineWidth arrow = tableContainer.find('.progress-arrow') arrow.css 'right', right - arrow.width() / 2 tableContainer.find('.progress-line').css 'right', right buildActionTimespans: -> @lastActionTimespans = {} @timespans = {} dt = @thang.world.dt actionHistory = @thang.world.actionsForThang @thang.id, true [lastFrame, lastAction] = [0, 'idle'] for hist in actionHistory.concat {frame: @thang.world.totalFrames, name: 'END'} [newFrame, newAction] = [hist.frame, hist.name] continue if newAction is lastAction if newFrame > lastFrame (@timespans[lastAction] ?= []).push [lastFrame * dt, newFrame * dt] [lastFrame, lastAction] = [newFrame, newAction] createActionElement: (action) -> $(action_template(action: action)) updateActionElement: (action, timespans, current) -> ael = @$el.find '.thang-actions *[name=' + action + ']' ael.toggleClass 'current-action', current timespans ?= [] lastTimespans = @lastActionTimespans[action] ? [] if @lastActionTimespans and timespans.length is lastTimespans.length changed = false for timespan, i in timespans if timespan[0] isnt lastTimespans[i][0] or timespan[1] isnt lastTimespans[i][1] changed = true break return unless changed ael.toggleClass 'hidden', not timespans.length @lastActionTimespans[action] = timespans timeline = ael.find('.action-timeline .timeline-wrapper').empty() lifespan = @thang.world.totalFrames / @thang.world.frameRate scale = timeline.width() / lifespan for [start, end] in timespans bar = $('
').css left: start * scale, right: (lifespan - end) * scale timeline.append bar ael