codecombat/app/lib/surface/Lank.coffee
Scott Erickson 4dda1b67dd Refactor ThangsTabView to use GameUIState for managing all Surface mouse events
Attempting to use a react-component-like system, where the Surface simply emits everything that
happens through the shared GameUIState, and the parent (in this case the ThangsTabView, but theoretically
anything that uses the surface) handles the events manually, to enforce desired behavior for that particular
context.

It's nice that all the event handling is centralized, but it's still a bit of a mess, and not thoroughly
stateful. But it's a start. This is in preparation for allowing multi-thang selection and manipulation
in the level editor.
2016-06-28 09:19:38 -07:00

833 lines
32 KiB
CoffeeScript

CocoClass = require 'core/CocoClass'
{createProgressBar} = require './sprite_utils'
Camera = require './Camera'
Mark = require './Mark'
Label = require './Label'
AudioPlayer = require 'lib/AudioPlayer'
{me} = require 'core/auth'
# We'll get rid of this once level's teams actually have colors
healthColors =
ogres: [64, 128, 212]
humans: [255, 0, 0]
neutral: [64, 212, 128]
# Sprite: EaselJS-based view/controller for Thang model
module.exports = Lank = class Lank extends CocoClass
thangType: null # ThangType instance
sprite: null
healthBar: null
marks: null
labels: null
ranges: null
options:
groundLayer: null
textLayer: null
floatingLayer: null
thang: null
camera: null
showInvisible: false
preloadSounds: true
possessed: false
flipped: false
flippedCount: 0
actionQueue: null
actions: null
rotation: 0
# Scale numbers
scaleFactorX: 1 # Current scale adjustment. This can change rapidly.
scaleFactorY: 1
targetScaleFactorX: 1 # What the scaleFactor is going toward during a tween.
targetScaleFactorY: 1
# ACTION STATE
# Actions have relations. If you say 'move', 'move_side' may play because of a direction
# relationship, and if you say 'cast', 'cast_begin' may happen first, or 'cast_end' after.
currentRootAction: null # action that, in general, is playing or will play
currentAction: null # related action that is right now playing
subscriptions:
'level:sprite-dialogue': 'onDialogue'
'level:sprite-clear-dialogue': 'onClearDialogue'
'level:set-letterbox': 'onSetLetterbox'
'surface:ticked': 'onSurfaceTicked'
'sprite:move': 'onMove'
constructor: (@thangType, options={}) ->
super()
spriteName = @thangType.get('name')
@isMissile = /(Missile|Arrow|Spear|Bolt)/.test(spriteName) and not /(Tower|Charge)/.test(spriteName)
@options = _.extend($.extend(true, {}, @options), options)
@gameUIState = @options.gameUIState
@handleEvents = @options.handleEvents
@setThang @options.thang
if @thang?
options = @thang?.getLankOptions?()
@options.colorConfig = options.colorConfig if options and options.colorConfig
console.error @toString(), 'has no ThangType!' unless @thangType
# this is a stub, use @setSprite to swap it out for something else later
@sprite = new createjs.Container
@actionQueue = []
@marks = {}
@labels = {}
@ranges = []
@handledDisplayEvents = {}
@age = 0
@stillLoading = true
if @thangType.isFullyLoaded() then @onThangTypeLoaded() else @listenToOnce(@thangType, 'sync', @onThangTypeLoaded)
toString: -> "<Lank: #{@thang?.id}>"
onThangTypeLoaded: ->
@stillLoading = false
if @options.preloadSounds
for trigger, sounds of @thangType.get('soundTriggers') or {} when trigger isnt 'say'
AudioPlayer.preloadSoundReference sound for sound in sounds when sound
if @thangType.get('raster')
@actions = {}
@isRaster = true
else
@actions = @thangType.getActions()
@createMarks()
@scaleFactorX = @thang.scaleFactorX if @thang?.scaleFactorX?
@scaleFactorX = @thang.scaleFactor if @thang?.scaleFactor?
@scaleFactorY = @thang.scaleFactorY if @thang?.scaleFactorY?
@scaleFactorY = @thang.scaleFactor if @thang?.scaleFactor?
@updateAction() unless @currentAction
setSprite: (newSprite) ->
if @sprite
@sprite.off 'animationend', @playNextAction
@sprite.destroy?()
if parent = @sprite.parent
parent.removeChild @sprite
if parent.spriteSheet is newSprite.spriteSheet
parent.addChild newSprite
# get the lank to update things
for prop in ['lastPos', 'currentRootAction']
delete @[prop]
@sprite = newSprite
if @thang and @thang.stateChanged is false
@thang.stateChanged = true
@configureMouse()
@sprite.on 'animationend', @playNextAction
@playAction(@currentAction) if @currentAction and not @stillLoading
@trigger 'new-sprite', @sprite
##################################################
# QUEUEING AND PLAYING ACTIONS
queueAction: (action) ->
# The normal way to have an action play
action = @actions[action] if _.isString(action)
action ?= @actions.idle
@actionQueue = []
@actionQueue.push @currentRootAction.relatedActions.end if @currentRootAction?.relatedActions?.end
@actionQueue.push action.relatedActions.begin if action.relatedActions?.begin
@actionQueue.push action
if action.goesTo and nextAction = @actions[action.goesTo]
@actionQueue.push nextAction if nextAction
@currentRootAction = action
@playNextAction()
onSurfaceTicked: (e) -> @age += e.dt
playNextAction: =>
return if @destroyed
@playAction(@actionQueue.splice(0, 1)[0]) if @actionQueue.length
playAction: (action) ->
return if @isRaster
@currentAction = action
return @hide() unless action.animation or action.container or action.relatedActions or action.goesTo
@show()
return @updateActionDirection() unless action.animation or action.container or action.goesTo
return if @sprite.placeholder
m = if action.container then 'gotoAndStop' else 'gotoAndPlay'
@sprite[m]?(action.name)
@updateScale()
@updateRotation()
hide: ->
@hiding = true
@updateAlpha()
show: ->
@hiding = false
@updateAlpha()
stop: ->
@sprite?.stop?()
mark.stop() for name, mark of @marks
@stopped = true
play: ->
@sprite?.play?()
mark.play() for name, mark of @marks
@stopped = false
update: (frameChanged) ->
# Gets the sprite to reflect what the current state of the thangs and surface are
return false if @stillLoading
thangUnchanged = @thang and @thang.stateChanged is false
if (frameChanged and not thangUnchanged) or (@thang and @thang.bobHeight) or @notOfThisWorld
@updatePosition()
return false if thangUnchanged
frameChanged = frameChanged or @targetScaleFactorX isnt @scaleFactorX or @targetScaleFactorY isnt @scaleFactorY
if frameChanged
@handledDisplayEvents = {}
@updateScale() # must happen before rotation
@updateAlpha()
@updateRotation()
@updateAction()
@updateStats()
@updateGold()
@showAreaOfEffects()
@showTextEvents()
@updateHealthBar()
@updateMarks()
@updateLabels()
@thang.stateChanged = false if @thang and @thang.stateChanged is true
return true
showAreaOfEffects: ->
return unless @thang?.currentEvents
for event in @thang.currentEvents
continue unless _.string.startsWith event, 'aoe-'
continue if @handledDisplayEvents[event]
@handledDisplayEvents[event] = true
args = JSON.parse(event[4...])
key = 'aoe-' + JSON.stringify(args[2..])
layerName = args[6] ? 'ground' # Can also specify 'floating'.
unless layer = @options[layerName + 'Layer']
console.error "#{@thang.id} couldn't find layer #{layerName}Layer for AOE effect #{key}; using ground layer."
layer = @options.groundLayer
unless key in layer.spriteSheet.getAnimations()
circle = new createjs.Shape()
radius = args[2] * Camera.PPM
if args.length is 4
circle.graphics.beginFill(args[3]).drawCircle(0, 0, radius)
else
startAngle = args[4] or 0
endAngle = args[5] or 2 * Math.PI
if startAngle is endAngle
startAngle = 0
endAngle = 2 * Math.PI
circle.graphics.beginFill(args[3])
.lineTo(0, 0)
.lineTo(radius * Math.cos(startAngle), radius * Math.sin(startAngle))
.arc(0, 0, radius, startAngle, endAngle)
.lineTo(0, 0)
layer.addCustomGraphic(key, circle, [-radius, -radius, radius*2, radius*2])
circle = new createjs.Sprite(layer.spriteSheet)
circle.gotoAndStop(key)
pos = @options.camera.worldToSurface {x: args[0], y: args[1]}
circle.x = pos.x
circle.y = pos.y
resFactor = layer.resolutionFactor
circle.scaleY = @options.camera.y2x * 0.7 / resFactor
circle.scaleX = 0.7 / resFactor
circle.alpha = 0.2
layer.addChild circle
createjs.Tween.get(circle)
.to({alpha: 0.6, scaleY: @options.camera.y2x / resFactor, scaleX: 1 / resFactor}, 100, createjs.Ease.circOut)
.to({alpha: 0, scaleY: 0, scaleX: 0}, 700, createjs.Ease.circIn)
.call =>
return if @destroyed
layer.removeChild circle
delete @handledDisplayEvents[event]
showTextEvents: ->
return unless @thang?.currentEvents
for event in @thang.currentEvents
continue unless _.string.startsWith event, 'text-'
continue if @handledDisplayEvents[event]
@handledDisplayEvents[event] = true
options = JSON.parse(event[5...])
label = new createjs.Text options.text, "bold #{options.size or 16}px Arial", options.color or '#FFF'
shadowColor = {humans: '#F00', ogres: '#00F', neutral: '#0F0', common: '#0F0'}[@thang.team] ? '#000'
label.shadow = new createjs.Shadow shadowColor, 1, 1, 3
offset = @getOffset 'aboveHead'
[label.x, label.y] = [@sprite.x + offset.x - label.getMeasuredWidth() / 2, @sprite.y + offset.y]
@options.textLayer.addChild label
window.labels ?= []
window.labels.push label
label.alpha = 0
createjs.Tween.get(label)
.to({y: label.y-2, alpha: 1}, 200, createjs.Ease.linear)
.to({y: label.y-12}, 1000, createjs.Ease.linear)
.to({y: label.y-22, alpha: 0}, 1000, createjs.Ease.linear)
.call =>
return if @destroyed
@options.textLayer.removeChild label
getBobOffset: ->
return 0 unless @thang.bobHeight
return @lastBobOffset if @stopped
return @lastBobOffset = @thang.bobHeight * (1 + Math.sin(@age * Math.PI / @thang.bobTime))
getWorldPosition: ->
p1 = if @possessed then @shadow.pos else @thang.pos
if bobOffset = @getBobOffset()
p1 = p1.copy?() or _.clone(p1)
p1.z += bobOffset
x: p1.x, y: p1.y, z: if @thang.isLand then 0 else p1.z - @thang.depth / 2
updatePosition: (whileLoading=false) ->
return if @stillLoading and not whileLoading
return unless @thang?.pos and @options.camera?
[p0, p1] = [@lastPos, @thang.pos]
return if p0 and p0.x is p1.x and p0.y is p1.y and p0.z is p1.z and not @thang.bobHeight
wop = @getWorldPosition()
sup = @options.camera.worldToSurface wop
[@sprite.x, @sprite.y] = [sup.x, sup.y]
@lastPos = p1.copy?() or _.clone(p1) unless whileLoading
@hasMoved = true
if @thangType.get('name') is 'Flag' and not @notOfThisWorld
# Let the pending flags know we're here (but not this call stack, they need to delete themselves, and we may be iterating sprites).
_.defer => Backbone.Mediator.publish 'surface:flag-appeared', sprite: @
updateScale: (force) ->
return unless @sprite
if @thangType.get('matchWorldDimensions') and @thang and @options.camera
if force or @thang.width isnt @lastThangWidth or @thang.height isnt @lastThangHeight or @thang.rotation isnt @lastThangRotation
bounds = @sprite.getBounds()
return unless bounds
@sprite.scaleX = @thang.width * Camera.PPM / bounds.width * (@options.camera.y2x + (1 - @options.camera.y2x) * Math.abs Math.cos @thang.rotation)
@sprite.scaleY = @thang.height * Camera.PPM / bounds.height * (@options.camera.y2x + (1 - @options.camera.y2x) * Math.abs Math.sin @thang.rotation)
@sprite.regX = bounds.width * 3 / 4 # Why not / 2? I don't know.
@sprite.regY = bounds.height * 3 / 4 # Why not / 2? I don't know.
unless @thang.spriteName is 'Beam'
@sprite.scaleX *= @thangType.get('scale') ? 1
@sprite.scaleY *= @thangType.get('scale') ? 1
[@lastThangWidth, @lastThangHeight, @lastThangRotation] = [@thang.width, @thang.height, @thang.rotation]
return
scaleX = scaleY = 1
if @isMissile
# Scales the arrow so it appears longer when flying parallel to horizon.
# To do that, we convert angle to [0, 90] (mirroring half-planes twice), then make linear function out of it:
# (a - x) / a: equals 1 when x = 0, equals 0 when x = a, monotonous in between. That gives us some sort of
# degenerative multiplier.
# For our purposes, a = 90 - the direction straight upwards.
# Then we use r + (1 - r) * x function with r = 0.5, so that
# maximal scale equals 1 (when x is at it's maximum) and minimal scale is 0.5.
# Notice that the value of r is empirical.
angle = @getRotation()
angle = -angle if angle < 0
angle = 180 - angle if angle > 90
scaleX = 0.5 + 0.5 * (90 - angle) / 90
# console.error 'No thang for', @ unless @thang
@sprite.scaleX = @sprite.baseScaleX * @scaleFactorX * scaleX
@sprite.scaleY = @sprite.baseScaleY * @scaleFactorY * scaleY
newScaleFactorX = @thang?.scaleFactorX ? @thang?.scaleFactor ? 1
newScaleFactorY = @thang?.scaleFactorY ? @thang?.scaleFactor ? 1
if @layer?.name is 'Land' or @thang?.spriteName is 'Beam'
@scaleFactorX = newScaleFactorX
@scaleFactorY = newScaleFactorY
else if @thang and (newScaleFactorX isnt @targetScaleFactorX or newScaleFactorY isnt @targetScaleFactorY)
@targetScaleFactorX = newScaleFactorX
@targetScaleFactorY = newScaleFactorY
createjs.Tween.removeTweens(@)
createjs.Tween.get(@).to({scaleFactorX: @targetScaleFactorX, scaleFactorY: @targetScaleFactorY}, 2000, createjs.Ease.elasticOut)
updateAlpha: ->
@sprite.alpha = if @hiding then 0 else 1
return unless @thang?.alpha?
return if @sprite.alpha is @thang.alpha
@sprite.alpha = @thang.alpha
if @options.showInvisible
@sprite.alpha = Math.max 0.5, @sprite.alpha
mark.updateAlpha @thang.alpha for name, mark of @marks
@healthBar?.alpha = @thang.alpha
updateRotation: (sprite) ->
rotationType = @thangType.get('rotationType')
return if rotationType is 'fixed'
rotation = @getRotation()
if @isMissile and @thang.velocity
# Rotates the arrow to see it arc based on velocity.z.
# Notice that rotation here does not affect thang's state - it is just the effect.
# Thang's rotation is always pointing where it is heading.
vz = @thang.velocity.z
if vz and speed = @thang.velocity.magnitude(true)
vx = @thang.velocity.x
heading = @thang.velocity.heading()
xFactor = Math.cos heading
zFactor = vz / Math.sqrt(vz * vz + vx * vx)
rotation -= xFactor * zFactor * 45
sprite ?= @sprite
return sprite.rotation = rotation if rotationType is 'free' or not rotationType
@updateIsometricRotation(rotation, sprite)
getRotation: ->
thang = if @possessed then @shadow else @thang
return @rotation if not thang?.rotation
rotation = thang?.rotation
rotation = (360 - (rotation * 180 / Math.PI) % 360) % 360
rotation -= 360 if rotation > 180
rotation
updateIsometricRotation: (rotation, sprite) ->
return unless @currentAction
return if _.string.endsWith(@currentAction.name, 'back')
return if _.string.endsWith(@currentAction.name, 'fore')
sprite.scaleX *= -1 if Math.abs(rotation) >= 90
##################################################
updateAction: ->
return if @isRaster or @actionLocked
action = @determineAction()
isDifferent = action isnt @currentRootAction or action is null
if not action and @thang?.actionActivated and not @stopLogging
console.error 'action is', action, 'for', @thang?.id, 'from', @currentRootAction, @thang.action, @thang.getActionName?()
@stopLogging = true
@queueAction(action) if action and (isDifferent or (@thang?.actionActivated and action.name isnt 'move'))
@updateActionDirection()
determineAction: ->
action = null
thang = if @possessed then @shadow else @thang
action = thang.action if thang?.acts
action ?= @currentRootAction.name if @currentRootAction?
action ?= 'idle'
unless @actions[action]?
@warnedFor ?= {}
console.warn 'Cannot show action', action, 'for', @thangType.get('name'), 'because it DNE' unless @warnedFor[action]
@warnedFor[action] = true
return if @action is 'idle' then null else 'idle'
#action = 'break' if @actions.break? and @thang?.erroredOut # This makes it looks like it's dead when it's not: bad in Brawlwood.
action = 'die' if @actions.die? and thang?.health? and thang.health <= 0
@actions[action]
updateActionDirection: (@wallGrid=null) ->
# wallGrid is only needed for wall grid face updates; should refactor if this works
return unless action = @getActionDirection()
@playAction(action) if action isnt @currentAction
lockAction: -> (@actionLocked=true)
getActionDirection: (rootAction=null) ->
rootAction ?= @currentRootAction
return null unless relatedActions = rootAction?.relatedActions ? {}
rotation = @getRotation()
if relatedActions['111111111111'] # has grid-surrounding-wall-based actions
if @wallGrid
@hadWallGrid = true
action = ''
tileSize = 4
[gx, gy] = [@thang.pos.x, @thang.pos.y]
for y in [gy + tileSize, gy, gy - tileSize, gy - tileSize * 2]
for x in [gx - tileSize, gx, gx + tileSize]
if x >= 0 and y >= 0 and x < @wallGrid.width and y < @wallGrid.height
wallThangs = @wallGrid.contents x, y
else
wallThangs = ['outside of the map yo']
if wallThangs.length is 0
if y is gy and x is gx
action += '1' # the center wall we're placing
else
action += '0'
else if wallThangs.length is 1
action += '1'
else
console.error 'Overlapping walls at', x, y, '...', wallThangs
action += '1'
matchedAction = '111111111111'
for relatedAction of relatedActions
if action.match(relatedAction.replace(/\?/g, '.'))
matchedAction = relatedAction
break
#console.log 'returning', matchedAction, 'for', @thang.id, 'at', gx, gy
return relatedActions[matchedAction]
else if @hadWallGrid
return null
else
keys = _.keys relatedActions
index = Math.max 0, Math.floor((179 + rotation) / 360 * keys.length)
#console.log 'Showing', relatedActions[keys[index]]
return relatedActions[keys[index]]
value = Math.abs(rotation)
direction = null
direction = 'side' if value <= 45 or value >= 135
direction = 'fore' if 135 > rotation > 45
direction = 'back' if -135 < rotation < -45
relatedActions[direction]
updateStats: ->
return unless @thang and @thang.health isnt @lastHealth
@lastHealth = @thang.health
if bar = @healthBar
healthPct = Math.max(@thang.health / @thang.maxHealth, 0)
bar.scaleX = healthPct / @options.floatingLayer.resolutionFactor
if @thang.showsName
@setNameLabel(if @thang.health <= 0 then '' else @thang.id)
else if @options.playerName
@setNameLabel @options.playerName
configureMouse: ->
@sprite.cursor = 'pointer' if @thang?.isSelectable
@sprite.mouseEnabled = @sprite.mouseChildren = false unless @thang?.isSelectable or @thang?.isLand
if @sprite.mouseEnabled
@sprite.on 'mousedown', @onMouseEvent, @, false, 'sprite:mouse-down'
@sprite.on 'click', @onMouseEvent, @, false, 'sprite:clicked'
@sprite.on 'dblclick', @onMouseEvent, @, false, 'sprite:double-clicked'
@sprite.on 'pressmove', @onMouseEvent, @, false, 'sprite:dragged'
@sprite.on 'pressup', @onMouseEvent, @, false, 'sprite:mouse-up'
onMouseEvent: (e, ourEventName) ->
return if @letterboxOn or not @sprite
p = @sprite
p = p.parent while p.parent
newEvent = sprite: @, thang: @thang, originalEvent: e, canvas: p.canvas
@trigger ourEventName, newEvent
Backbone.Mediator.publish ourEventName, newEvent
@gameUIState.trigger(ourEventName, newEvent)
addHealthBar: ->
return unless @thang?.health? and 'health' in (@thang?.hudProperties ? []) and @options.floatingLayer
team = @thang?.team or 'neutral'
key = "#{team}-health-bar"
unless key in @options.floatingLayer.spriteSheet.getAnimations()
healthColor = healthColors[team]
bar = createProgressBar(healthColor)
@options.floatingLayer.addCustomGraphic(key, bar, bar.bounds)
hadHealthBar = @healthBar
@healthBar = new createjs.Sprite(@options.floatingLayer.spriteSheet)
@healthBar.gotoAndStop(key)
offset = @getOffset 'aboveHead'
@healthBar.scaleX = @healthBar.scaleY = 1 / @options.floatingLayer.resolutionFactor
@healthBar.name = 'health bar'
@options.floatingLayer.addChild @healthBar
@updateHealthBar()
@lastHealth = null
if not hadHealthBar
@listenTo @options.floatingLayer, 'new-spritesheet', @addHealthBar
getActionProp: (prop, subProp, def=null) ->
# Get a property or sub-property from an action, falling back to ThangType
for val in [@currentAction?[prop], @thangType.get(prop)]
val = val[subProp] if val? and subProp
return val if val?
def
getOffset: (prop) ->
# Get the proper offset from either the current action or the ThangType
def = x: 0, y: {registration: 0, torso: -50, mouth: -60, aboveHead: -100}[prop]
pos = @getActionProp 'positions', prop, def
pos = x: pos.x, y: pos.y
if not @isRaster
scale = @getActionProp 'scale', null, 1
scale *= @sprite.parent.resolutionFactor if prop is 'registration'
pos.x *= scale
pos.y *= scale
if @thang and prop isnt 'registration'
pos.x *= @thang.scaleFactorX ? @thang.scaleFactor ? 1
pos.y *= @thang.scaleFactorY ? @thang.scaleFactor ? 1
# We might need to do this, but I don't have a good test case yet. TODO: figure out.
#if prop isnt @registration
# pos.x *= if @getActionProp 'flipX' then -1 else 1
# pos.y *= if @getActionProp 'flipY' then -1 else 1
pos
createMarks: ->
return unless @options.camera
if @thang
# TODO: Add back ranges
# allProps = []
# allProps = allProps.concat (@thang.hudProperties ? [])
# allProps = allProps.concat (@thang.programmableProperties ? [])
# allProps = allProps.concat (@thang.moreProgrammableProperties ? [])
#
# for property in allProps
# if m = property.match /.*(Range|Distance|Radius)$/
# if @thang[m[0]]? and @thang[m[0]] < 9001
# @ranges.push
# name: m[0]
# radius: @thang[m[0]]
#
# @ranges = _.sortBy @ranges, 'radius'
# @ranges.reverse()
#
# @addMark range.name for range in @ranges
# TODO: add back bounds
# @addMark('bounds').toggle true if @thang?.drawsBounds
@addMark('shadow').toggle true unless @thangType.get('shadow') is 0
updateMarks: ->
return unless @options.camera
@addMark 'repair', null, 'repair' if @thang?.erroredOut
@marks.repair?.toggle @thang?.erroredOut
if @selected
@marks[range['name']].toggle true for range in @ranges
else
@marks[range['name']].toggle false for range in @ranges
if @isMissile and @thang.action is 'die'
@marks.shadow?.hide()
mark.update() for name, mark of @marks
#@thang.effectNames = ['warcry', 'confuse', 'control', 'curse', 'fear', 'poison', 'paralyze', 'regen', 'sleep', 'slow', 'haste']
@updateEffectMarks() if @thang?.effectNames?.length or @previousEffectNames?.length
updateEffectMarks: ->
return if _.isEqual @thang.effectNames, @previousEffectNames
return if @stopped
for effect in @thang.effectNames
mark = @addMark effect, @options.floatingLayer, effect
mark.statusEffect = true
mark.toggle 'on'
mark.show()
if @previousEffectNames
for effect in @previousEffectNames
continue if effect in @thang.effectNames
mark = @marks[effect]
mark.toggle false
if @thang.effectNames.length > 1 and not @effectInterval
@rotateEffect()
@effectInterval = setInterval @rotateEffect, 1500
else if @effectInterval and @thang.effectNames.length <= 1
clearInterval @effectInterval
@effectInterval = null
@previousEffectNames = @thang.effectNames
rotateEffect: =>
effects = (m.name for m in _.values(@marks) when m.on and m.statusEffect and m.mark)
return unless effects.length
effects.sort()
@effectIndex ?= 0
@effectIndex = (@effectIndex + 1) % effects.length
@marks[effect].hide() for effect in effects
@marks[effects[@effectIndex]].show()
setHighlight: (to, delay) ->
@addMark 'highlight', @options.floatingLayer, 'highlight' if to
@marks.highlight?.highlightDelay = delay
@marks.highlight?.toggle to and not @dimmed
setDimmed: (@dimmed) ->
@marks.highlight?.toggle @marks.highlight.on and not @dimmed
setThang: (@thang) ->
@options.thang = @thang
setDebug: (debug) ->
return unless @thang?.collides and @options.camera?
@addMark 'debug', @options.floatingLayer if debug
if d = @marks.debug
d.toggle debug
d.updatePosition()
addLabel: (name, style) ->
@labels[name] ?= new Label sprite: @, camera: @options.camera, layer: @options.textLayer, style: style
@labels[name]
addMark: (name, layer, thangType=null) ->
@marks[name] ?= new Mark name: name, lank: @, camera: @options.camera, layer: layer ? @options.groundLayer, thangType: thangType
@marks[name]
notifySpeechUpdated: (e) ->
e = _.clone(e)
e.sprite = @
e.blurb ?= '...'
e.thang = @thang
Backbone.Mediator.publish 'sprite:speech-updated', e
isTalking: ->
Boolean @labels.dialogue?.text or @labels.say?.text
onDialogue: (e) ->
return unless @thang?.id is e.spriteID
unless @thang?.id is 'Hero Placeholder' # Don't show these for heroes, because they aren't actually first-person, just LevelDialogueView narration
label = @addLabel 'dialogue', Label.STYLE_DIALOGUE
label.setText e.blurb or '...'
sound = e.sound ? AudioPlayer.soundForDialogue e.message, @thangType.get 'soundTriggers'
@dialogueSoundInstance?.stop()
if @dialogueSoundInstance = @playSound sound, false
@dialogueSoundInstance.addEventListener 'complete', -> Backbone.Mediator.publish 'sprite:dialogue-sound-completed', {}
@notifySpeechUpdated e
onClearDialogue: (e) ->
return unless @labels.dialogue?.text
@labels.dialogue?.setText null
@dialogueSoundInstance?.stop()
@notifySpeechUpdated {}
onSetLetterbox: (e) ->
@letterboxOn = e.on
setNameLabel: (name) ->
label = @addLabel 'name', Label.STYLE_NAME
label.setText name
updateLabels: ->
return unless @thang
blurb = if @thang.health <= 0 then null else @thang.sayMessage # Dead men tell no tales
blurb = null if blurb in ['For Thoktar!', 'Bones!', 'Behead!', 'Destroy!', 'Die, humans!'] # Let's just hear, not see, these ones.
if /Hero Placeholder/.test(@thang.id)
labelStyle = Label.STYLE_DIALOGUE
else
labelStyle = @thang.labelStyle ? Label.STYLE_SAY
@addLabel 'say', labelStyle if blurb
if @labels.say?.setText blurb
@notifySpeechUpdated blurb: blurb
label.update() for name, label of @labels
updateGold: ->
# TODO: eventually this should be moved into some sort of team-based update
# rather than an each-thang-that-shows-gold-per-team thing.
return unless @thang
return if @thang.gold is @lastGold
gold = Math.floor @thang.gold ? 0
if @thang.world.age is 0
gold = @thang.world.initialTeamGold[@thang.team].gold
return if gold is @lastGold
@lastGold = gold
Backbone.Mediator.publish 'surface:gold-changed', {team: @thang.team, gold: gold, goldEarned: Math.floor(@thang.goldEarned ? 0)}
shouldMuteMessage: (m) ->
if me.getAnnouncesActionAudioGroup() in ['no-audio', 'just-take-damage']
return true if m in ['moveRight', 'moveUp', 'moveDown', 'moveLeft']
return true if /^attack /.test m
return true if /^Repeating loop/.test m
return true if /^findNearestEnemy/.test m
return false if m in ['moveRight', 'moveUp', 'moveDown', 'moveLeft']
@previouslySaidMessages ?= {}
t0 = @previouslySaidMessages[m] ? 0
t1 = new Date()
@previouslySaidMessages[m] = t1
return true if t1 - t0 < 5 * 1000
false
playSounds: (withDelay=true, volume=1.0) ->
for event in @thang.currentEvents ? []
if event is 'take-damage' and me.getAnnouncesActionAudioGroup() in ['no-audio', 'without-take-damage']
null # Skip playing it
else
@playSound event, withDelay, volume
if event is 'pay-bounty-gold' and @thang.bountyGold > 25 and @thang.team isnt me.team
AudioPlayer.playInterfaceSound 'coin_1', 0.25
if @thang.actionActivated and (action = @thang.getActionName()) isnt 'say'
@playSound action, withDelay, volume
if @thang.sayMessage and withDelay and not @thang.silent and not @shouldMuteMessage @thang.sayMessage # don't play sayMessages while scrubbing, annoying
offsetFrames = Math.abs(@thang.sayStartTime - @thang.world.age) / @thang.world.dt
if offsetFrames <= 2 # or (not withDelay and offsetFrames < 30)
sound = AudioPlayer.soundForDialogue @thang.sayMessage, @thangType.get 'soundTriggers'
@playSound sound, false, volume
playSound: (sound, withDelay=true, volume=1.0) ->
if _.isString sound
sound = @thangType.get('soundTriggers')?[sound]
if _.isArray sound
sound = sound[Math.floor Math.random() * sound.length]
return null unless sound
delay = if withDelay and sound.delay then 1000 * sound.delay / createjs.Ticker.getFPS() else 0
name = AudioPlayer.nameForSoundReference sound
AudioPlayer.preloadSoundReference sound
instance = AudioPlayer.playSound name, volume, delay, @getWorldPosition()
#console.log @thang?.id, 'played sound', name, 'with delay', delay, 'volume', volume, 'and got sound instance', instance
instance
onMove: (e) ->
return unless e.spriteID is @thang?.id
pos = e.pos
if _.isArray pos
pos = new Vector pos...
else if _.isString pos
return console.warn 'Couldn\'t find target sprite', pos, 'from', @options.sprites unless pos of @options.sprites
target = @options.sprites[pos].thang
heading = Vector.subtract(target.pos, @thang.pos).normalize()
distance = @thang.pos.distance target.pos
offset = Math.max(target.width, target.height, 2) / 2 + 3
pos = Vector.add(@thang.pos, heading.multiply(distance - offset))
Backbone.Mediator.publish 'level:sprite-clear-dialogue', {}
@onClearDialogue()
args = [pos]
args.push(e.duration) if e.duration?
@move(args...)
move: (pos, duration=2000, endAnimation='idle') =>
@updateShadow()
if not duration
createjs.Tween.removeTweens(@shadow.pos) if @lastTween
@lastTween = null
z = @shadow.pos.z
@shadow.pos = pos
@shadow.pos.z = z
@sprite.gotoAndPlay?(endAnimation)
return
@shadow.action = 'move'
@shadow.actionActivated = true
@pointToward(pos)
@possessed = true
@update true
ease = createjs.Ease.getPowInOut(2.2)
if @lastTween
ease = createjs.Ease.getPowOut(1.2)
createjs.Tween.removeTweens(@shadow.pos)
endFunc = =>
@lastTween = null
@sprite.gotoAndPlay(endAnimation) unless @stillLoading
@shadow.action = 'idle'
@update true
@possessed = false
@lastTween = createjs.Tween
.get(@shadow.pos)
.to({x: pos.x, y: pos.y}, duration, ease)
.call(endFunc)
pointToward: (pos) ->
@shadow.rotation = Math.atan2(pos.y - @shadow.pos.y, pos.x - @shadow.pos.x)
if (@shadow.rotation * 180 / Math.PI) % 90 is 0
@shadow.rotation += 0.01
updateShadow: ->
@shadow = {} if not @shadow
@shadow.pos = @thang.pos
@shadow.rotation = @thang.rotation
@shadow.action = @thang.action
@shadow.actionActivated = @thang.actionActivated
updateHealthBar: ->
return unless @healthBar
bounds = @healthBar.getBounds()
offset = @getOffset 'aboveHead'
@healthBar.x = @sprite.x - (-offset.x + bounds.width / 2 / @options.floatingLayer.resolutionFactor)
@healthBar.y = @sprite.y - (-offset.y + bounds.height / 2 / @options.floatingLayer.resolutionFactor)
destroy: ->
mark.destroy() for name, mark of @marks
label.destroy() for name, label of @labels
p.removeChild @healthBar if p = @healthBar?.parent
@sprite?.off 'animationend', @playNextAction
clearInterval @effectInterval if @effectInterval
@dialogueSoundInstance?.removeAllEventListeners()
super()