mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-04 12:51:12 -05:00
4dda1b67dd
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.
833 lines
32 KiB
CoffeeScript
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()
|