codecombat/app/views/play/level/LevelHUDView.coffee

387 lines
15 KiB
CoffeeScript
Raw Normal View History

2014-07-17 20:20:11 -04:00
CocoView = require 'views/kinds/CocoView'
2014-01-03 13:32:13 -05:00
template = require 'templates/play/level/hud'
prop_template = require 'templates/play/level/hud_prop'
action_template = require 'templates/play/level/hud_action'
DialogueAnimator = require './DialogueAnimator'
2014-01-03 13:32:13 -05:00
module.exports = class LevelHUDView extends CocoView
2014-01-03 13:32:13 -05:00
id: 'thang-hud'
template: template
dialogueMode: false
subscriptions:
'surface:frame-changed': 'onFrameChanged'
'level:disable-controls': 'onDisableControls'
'level:enable-controls': 'onEnableControls'
2014-01-03 13:32:13 -05:00
'surface:sprite-selected': 'onSpriteSelected'
'sprite:speech-updated': 'onSpriteDialogue'
'level:sprite-clear-dialogue': 'onSpriteClearDialogue'
2014-01-03 13:32:13 -05:00
'level:shift-space-pressed': 'onShiftSpacePressed'
'level:escape-pressed': 'onEscapePressed'
'sprite:dialogue-sound-completed': 'onDialogueSoundCompleted'
'sprite:thang-began-talking': 'onThangBeganTalking'
'sprite:thang-finished-talking': 'onThangFinishedTalking'
'god:new-world-created': 'onNewWorld'
2014-01-03 13:32:13 -05:00
events:
'click': 'onClick'
2014-01-03 13:32:13 -05:00
2014-02-11 17:58:45 -05:00
afterRender: ->
2014-01-09 14:04:22 -05:00
super()
@$el.addClass 'no-selection'
onClick: (e) ->
Backbone.Mediator.publish 'tome:focus-editor', {} unless $(e.target).parents('.thang-props').length
2014-01-03 13:32:13 -05:00
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
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()
2014-01-09 14:04:22 -05:00
@setThang e.thang, e.sprite?.thangType
2014-01-03 13:32:13 -05:00
onSpriteDialogue: (e) ->
return unless e.message
spriteID = e.sprite.thang.id
2014-01-09 14:04:22 -05:00
@setSpeaker e.sprite
@stage?.startTalking()
2014-01-03 13:32:13 -05:00
@setMessage(e.message, e.mood, e.responses)
window.tracker?.trackEvent 'Heard Sprite', {speaker: spriteID, message: e.message, label: e.message}, ['Google Analytics']
onDialogueSoundCompleted: ->
2014-01-09 14:04:22 -05:00
@stage?.stopTalking()
2014-01-03 13:32:13 -05:00
onSpriteClearDialogue: ->
@clearSpeaker()
onNewWorld: (e) ->
hadThang = @thang
@thang = e.world.thangMap[@thang.id] if @thang
if hadThang and not @thang
@setThang null, null
2014-08-22 17:59:32 -04:00
else if @thang
@createActions() # Make sure it updates its actions.
2014-01-09 14:04:22 -05:00
setThang: (thang, thangType) ->
2014-01-03 13:32:13 -05:00
unless @speaker
if not thang? and not @thang? then return
if thang? and @thang? and thang.id is @thang.id then return
2014-01-03 13:32:13 -05:00
@thang = thang
2014-01-09 14:04:22 -05:00
@thangType = thangType
2014-01-03 13:32:13 -05:00
@$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
2014-01-15 18:16:31 -05:00
@createAvatar thangType, @thang
2014-01-03 13:32:13 -05:00
@createProperties()
@createActions()
@update()
@speaker = null
2014-01-09 14:04:22 -05:00
setSpeaker: (speakerSprite) ->
return if speakerSprite is @speakerSprite
@speakerSprite = speakerSprite
@speaker = @speakerSprite.thang.id
@createAvatar @speakerSprite.thangType, @speakerSprite.thang, @speakerSprite.options.colorConfig
2014-01-03 13:32:13 -05:00
@$el.removeClass 'no-selection'
@switchToDialogueElements()
clearSpeaker: ->
if not @thang
@$el.addClass 'no-selection'
@setThang @thang, @thangType
2014-01-03 13:32:13 -05:00
@switchToThangElements()
@speaker = null
2014-01-09 14:04:22 -05:00
@speakerSprite = null
2014-01-03 13:32:13 -05:00
@bubble = null
@update()
createAvatar: (thangType, thang, colorConfig) ->
unless thangType.isFullyLoaded()
args = arguments
unless @listeningToCreateAvatar
@listenToOnce thangType, 'sync', -> @createAvatar(args...)
@listeningToCreateAvatar = true
return
@listeningToCreateAvatar = false
options = thang.getLankOptions() or {}
2014-01-15 18:16:31 -05:00
options.async = false
options.colorConfig = colorConfig if colorConfig
2014-01-09 14:04:22 -05:00
wrapper = @$el.find '.thang-canvas-wrapper'
team = @thang?.team or @speakerSprite?.thang?.team
wrapper.removeClass (i, css) -> (css.match(/\bteam-\S+/g) or []).join ' '
wrapper.addClass "team-#{team}"
if thangType.get('raster')
wrapper.empty().append($('<img />').attr('src', '/file/'+thangType.get('raster')))
else
2014-06-19 11:06:34 -04:00
return unless stage = thangType.getPortraitStage options
newCanvas = $(stage.canvas).addClass('thang-canvas')
wrapper.empty().append(newCanvas)
stage.update()
@stage?.stopTalking()
@stage = stage
2014-01-09 14:04:22 -05:00
onThangBeganTalking: (e) ->
return unless @stage and @thang is e.thang
@stage?.startTalking()
onThangFinishedTalking: (e) ->
return unless @stage and @thang is e.thang
@stage?.stopTalking()
2014-01-03 13:32:13 -05:00
createProperties: ->
props = @$el.find('.thang-props')
2014-06-30 22:16:26 -04:00
props.find(':not(.thang-name)').remove()
if @thang.id is 'Hero Placeholder'
name = {knight: 'Tharin', captain: 'Anya'}[@thang.type] ? 'Hero'
else
name = if @thang.type then "#{@thang.id} - #{@thang.type}" else @thang.id
props.find('.thang-name').text name
2014-03-10 12:37:05 -04:00
propNames = _.without @thang.hudProperties ? [], 'action'
nColumns = Math.ceil propNames.length / 5
columns = ($('<div class="thang-props-column"></div>').appendTo(props) for i in [0 ... nColumns])
for prop, i in propNames
continue if prop is 'action'
2014-01-03 13:32:13 -05:00
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
columns[i % nColumns].append pel
null
2014-01-03 13:32:13 -05:00
createActions: ->
actions = @$el.find('.thang-actions tbody').empty()
2014-03-28 18:23:12 -04:00
showActions = @thang.world and not @thang.notOfThisWorld and not _.isEmpty(@thang.actions) and 'action' in (@thang.hudProperties ? [])
@$el.find('.thang-actions').toggleClass 'secret', not showActions
return unless showActions
2014-01-03 13:32:13 -05:00
@buildActionTimespans()
for actionName, action of @thang.actions
actions.append @createActionElement(actionName)
@lastActionTimespans[actionName] = {}
setMessage: (message, mood, responses) ->
message = marked message
2014-05-02 18:51:07 -04:00
# Fix old HTML icons like <i class='icon-play'></i> in the Markdown
message = message.replace /&lt;i class=&#39;(.+?)&#39;&gt;&lt;\/i&gt;/, "<i class='$1'></i>"
2014-01-03 13:32:13 -05:00
clearInterval(@messageInterval) if @messageInterval
@bubble = $('.dialogue-bubble', @$el)
@bubble.removeClass(@lastMood) if @lastMood
@lastMood = mood
@bubble.text('')
group = $('<div class="enter secret"></div>')
2014-01-03 13:32:13 -05:00
@bubble.append(group)
if responses
@lastResponses = responses
for response in responses
button = $('<button class="btn btn-small banner"></button>').text(response.text)
button.addClass response.buttonClass if response.buttonClass
group.append(button)
response.button = $('button:last', group)
else
2014-06-30 22:16:26 -04:00
s = $.i18n.t('play_level.hud_continue', defaultValue: 'Continue (shift+space)')
sk = $.i18n.t('play_level.skip_tutorial', defaultValue: 'skip: esc')
if not @escapePressed
group.append('<span class="hud-hint">' + sk + '</span>')
2014-01-03 13:32:13 -05:00
group.append($('<button class="btn btn-small banner with-dot">' + s + ' <div class="dot"></div></button>'))
@lastResponses = null
if @speaker is 'Hero Placeholder'
# Doesn't work if it fires from a script; we don't really know who we are then.
name = {knight: 'Tharin', captain: 'Anya'}[@speakerSprite?.thang?.id] ? 'Hero'
else
name = @speaker
@bubble.append($("<h3>#{name}</h3>"))
2014-01-03 13:32:13 -05:00
@animator = new DialogueAnimator(message, @bubble)
@messageInterval = setInterval(@addMoreMessage, 1000 / 30) # 30 FPS
2014-01-03 13:32:13 -05:00
addMoreMessage: =>
if @animator.done()
clearInterval(@messageInterval)
@messageInterval = null
2014-06-30 22:16:26 -04:00
$('.enter', @bubble).removeClass('secret').css('opacity', 0.0).delay(500).animate({opacity: 1.0}, 500, @animateEnterButton)
2014-01-03 13:32:13 -05:00
if @lastResponses
buttons = $('.enter button')
for response, i in @lastResponses
channel = response.channel.replace 'level-set-playing', 'level:set-playing' # Easier than migrating all those victory buttons.
f = (r) => => setTimeout((-> Backbone.Mediator.publish(channel, r.event or {})), 10)
2014-01-03 13:32:13 -05:00
$(buttons[i]).click(f(response))
else
$('.enter', @bubble).click(-> Backbone.Mediator.publish('script:end-current-script', {}))
2014-01-03 13:32:13 -05:00
return
@animator.tick()
onShiftSpacePressed: (e) ->
@shiftSpacePressed = (@shiftSpacePressed || 0) + 1
# We don't need to handle script:end-current-script--that's done--but if we do have
2014-01-03 13:32:13 -05:00
# 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]
channel = r.channel.replace 'level-set-playing', 'level:set-playing'
_.delay (-> Backbone.Mediator.publish(channel, r.event or {})), 10
2014-01-03 13:32:13 -05:00
onEscapePressed: (e) ->
@escapePressed = true
2014-01-03 13:32:13 -05:00
animateEnterButton: =>
return unless @bubble
button = $('.enter', @bubble)
dot = $('.dot', button)
2014-06-30 22:16:26 -04:00
dot.animate({opacity: 0.2}, 300).animate({opacity: 1.9}, 600, @animateEnterButton)
2014-01-03 13:32:13 -05:00
switchToDialogueElements: ->
@dialogueMode = true
$('.thang-elem', @$el).addClass('secret')
@$el.find('.thang-canvas-wrapper').removeClass('secret')
2014-01-03 13:32:13 -05:00
$('.dialogue-area', @$el)
.removeClass('secret')
2014-06-30 22:16:26 -04:00
.animate({opacity: 1.0}, 200)
2014-01-03 13:32:13 -05:00
$('.dialogue-bubble', @$el)
.css('opacity', 0.0)
.delay(200)
2014-06-30 22:16:26 -04:00
.animate({opacity: 1.0}, 200)
2014-01-03 13:32:13 -05:00
clearTimeout @hintNextSelectionTimeout
switchToThangElements: ->
@dialogueMode = false
$('.thang-elem', @$el).removeClass('secret')
$('.dialogue-area', @$el).addClass('secret')
2014-01-03 13:32:13 -05:00
update: ->
return unless @thang and not @speaker
@$el.find('.thang-props-column').toggleClass 'nonexistent', not @thang.exists
if @thang.exists
@updatePropElement(prop, @thang[prop]) for prop in @thang.hudProperties ? []
2014-01-03 13:32:13 -05:00
# Update action timeline
@updateActions()
createPropElement: (prop) ->
2014-06-30 22:16:26 -04:00
if prop in ['maxHealth']
2014-01-03 13:32:13 -05:00
return null # included in the bar
context =
prop: prop
2014-09-04 18:14:27 -04:00
hasIcon: prop in ['health', 'pos', 'target', 'collectedThangIDs', 'gold', 'bountyGold', 'visualRange', 'attackDamage', 'attackRange', 'maxSpeed', 'attackNearbyEnemyRange']
2014-06-30 22:16:26 -04:00
hasBar: prop in ['health']
2014-01-03 13:32:13 -05:00
$(prop_template(context))
updatePropElement: (prop, val) ->
pel = @$el.find '.thang-props *[name=' + prop + ']'
2014-06-30 22:16:26 -04:00
if prop in ['maxHealth']
return # Don't show maxes--they're built into bar labels.
2014-06-30 22:16:26 -04:00
if prop in ['health']
max = @thang['max' + prop.charAt(0).toUpperCase() + prop.slice(1)]
regen = @thang[prop + 'ReplenishRate']
2014-01-03 13:32:13 -05:00
percent = Math.round 100 * val / max
2014-06-30 22:16:26 -04:00
pel.find('.bar').css 'width', percent + '%'
labelText = prop + ': ' + @formatValue(prop, val) + ' / ' + @formatValue(prop, max)
2014-01-03 13:32:13 -05:00
if regen
2014-06-30 22:16:26 -04:00
labelText += ' (+' + @formatValue(prop, regen) + '/s)'
pel.find('.bar-prop-value').text(Math.round(max)) if max
2014-01-03 13:32:13 -05:00
else
s = @formatValue(prop, val)
labelText = "#{prop}: #{s}"
if prop is 'attackDamage'
cooldown = @thang.actions.attack.cooldown
dps = @thang.attackDamage / cooldown
labelText += " / #{cooldown.toFixed(2)}s (DPS: #{dps.toFixed(2)})"
2014-01-03 13:32:13 -05:00
pel.find('.prop-value').text s
pel.attr 'title', labelText
2014-01-03 13:32:13 -05:00
pel
formatValue: (prop, val) ->
2014-06-30 22:16:26 -04:00
if prop is 'target' and not val
val = @thang['targetPos']
2014-01-03 13:32:13 -05:00
val = null if val?.isZero()
2014-06-30 22:16:26 -04:00
if prop is 'rotation'
return (val * 180 / Math.PI).toFixed(0) + '˚'
if prop.search(/Range$/) isnt -1
return val + 'm'
2014-01-03 13:32:13 -05:00
if typeof val is 'number'
if Math.round(val) == val or prop is 'gold' then return val.toFixed(0) # int
2014-01-03 13:32:13 -05:00
if -10 < val < 10 then return val.toFixed(2)
if -100 < val < 100 then return val.toFixed(1)
return val.toFixed(0)
2014-06-30 22:16:26 -04:00
if val and typeof val is 'object'
2014-01-03 13:32:13 -05:00
if val.id
return val.id
else if val.x and val.y
2014-01-29 11:38:37 -05:00
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
2014-01-03 13:32:13 -05:00
else if not val?
2014-06-30 22:16:26 -04:00
return 'No ' + prop
2014-01-03 13:32:13 -05:00
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 is actionName)
2014-01-03 13:32:13 -05:00
tableContainer = @$el.find('.table-container')
arrow = tableContainer.find('.progress-arrow')
@timelineWidth ||= tableContainer.find('tr:not(.secret) .action-timeline').width()
@actionArrowWidth ||= arrow.width()
right = (1 - (@timeProgress ? 0)) * @timelineWidth
arrow.css 'right', right - @actionArrowWidth / 2
2014-01-03 13:32:13 -05:00
tableContainer.find('.progress-line').css 'right', right
buildActionTimespans: ->
@lastActionTimespans = {}
@timespans = {}
dt = @thang.world.dt
actionHistory = @thang.world.actionsForThang @thang.id, true
2014-03-10 12:37:05 -04:00
[lastFrame, lastAction] = [0, null]
2014-01-03 13:32:13 -05:00
for hist in actionHistory.concat {frame: @thang.world.totalFrames, name: 'END'}
[newFrame, newAction] = [hist.frame, hist.name]
continue if newAction is lastAction
2014-03-10 12:37:05 -04:00
if newFrame > lastFrame and lastAction
# TODO: don't push it if it didn't exist until then
2014-01-03 13:32:13 -05:00
(@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 'secret', not timespans.length
2014-01-03 13:32:13 -05:00
@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 = $('<div></div>').css left: start * scale, right: (lifespan - end) * scale
timeline.append bar
ael
2014-01-09 14:04:22 -05:00
destroy: ->
@stage?.stopTalking()
2014-02-11 17:58:45 -05:00
clearInterval(@messageInterval) if @messageInterval
clearTimeout @hintNextSelectionTimeout if @hintNextSelectionTimeout
super()