codecombat/app/models/ThangType.coffee

458 lines
17 KiB
CoffeeScript

CocoModel = require './CocoModel'
SpriteBuilder = require 'lib/sprites/SpriteBuilder'
LevelComponent = require './LevelComponent'
utils = require 'core/utils'
buildQueue = []
module.exports = class ThangType extends CocoModel
@className: 'ThangType'
@schema: require 'schemas/models/thang_type'
@heroes:
captain: '529ec584c423d4e83b000014'
knight: '529ffbf1cf1818f2be000001'
'samurai': '53e12be0d042f23505c3023b'
'ninja': '52fc0ed77e01835453bd8f6c'
'forest-archer': '5466d4f2417c8b48a9811e87'
trapper: '5466d449417c8b48a9811e83'
librarian: '52fbf74b7e01835453bd8d8e'
'potion-master': '52e9adf7427172ae56002172'
sorcerer: '52fd1524c7e6cf99160e7bc9'
@heroClasses:
Warrior: ['captain', 'knight', 'samurai']
Ranger: ['ninja', 'forest-archer', 'trapper']
Wizard: ['librarian', 'potion-master', 'sorcerer']
@items:
'simple-boots': '53e237bf53457600003e3f05'
urlRoot: '/db/thang.type'
building: {}
initialize: ->
super()
@building = {}
@spriteSheets = {}
## Testing memory clearing
#f = =>
# console.info 'resetting raw data'
# @unset 'raw'
# @_previousAttributes.raw = null
#setTimeout f, 40000
resetRawData: ->
@set('raw', {shapes: {}, containers: {}, animations: {}})
resetSpriteSheetCache: ->
@buildActions()
@spriteSheets = {}
@building = {}
isFullyLoaded: ->
# TODO: Come up with a better way to identify when the model doesn't have everything needed to build the sprite. ie when it's a projection without all the required data.
return @get('actions') or @get('raster') # needs one of these two things
loadRasterImage: ->
return if @loadingRaster or @loadedRaster
return unless raster = @get('raster')
@rasterImage = $("<img src='/file/#{raster}' />")
@loadingRaster = true
@rasterImage.one('load', =>
@loadingRaster = false
@loadedRaster = true
@trigger('raster-image-loaded', @))
@rasterImage.one('error', =>
@loadingRaster = false
@trigger('raster-image-load-errored', @)
)
getActions: ->
return {} unless @isFullyLoaded()
return @actions or @buildActions()
buildActions: ->
return null unless @isFullyLoaded()
@actions = $.extend(true, {}, @get('actions'))
for name, action of @actions
action.name = name
for relatedName, relatedAction of action.relatedActions ? {}
relatedAction.name = action.name + '_' + relatedName
@actions[relatedAction.name] = relatedAction
@actions
fillOptions: (options) ->
options ?= {}
options = _.clone options
options.resolutionFactor ?= SPRITE_RESOLUTION_FACTOR
options.async ?= false
options.thang = null # Don't hold onto any bad Thang references.
options
buildSpriteSheet: (options) ->
return false unless @isFullyLoaded() and @get 'raw'
@options = @fillOptions options
key = @spriteSheetKey(@options)
if ss = @spriteSheets[key] then return ss
if @building[key]
@options = null
return key
@t0 = new Date().getTime()
@initBuild(options)
@addGeneralFrames() unless @options.portraitOnly
@addPortrait()
@building[key] = true
result = @finishBuild()
return result
initBuild: (options) ->
@buildActions() if not @actions
@vectorParser = new SpriteBuilder(@, options)
@builder = new createjs.SpriteSheetBuilder()
@builder.padding = 2
@frames = {}
addPortrait: ->
# The portrait is built very differently than the other animations, so it gets a separate function.
return unless @actions
portrait = @actions.portrait
return unless portrait
scale = portrait.scale or 1
pt = portrait.positions?.registration
rect = new createjs.Rectangle(pt?.x/scale or 0, pt?.y/scale or 0, 100/scale, 100/scale)
if portrait.animation
mc = @vectorParser.buildMovieClip portrait.animation
mc.nominalBounds = mc.frameBounds = null # override what the movie clip says on bounding
@builder.addMovieClip(mc, rect, scale)
frames = @builder._animations[portrait.animation].frames
frames = @mapFrames(portrait.frames, frames[0]) if portrait.frames?
@builder.addAnimation 'portrait', frames, true
else if portrait.container
s = @vectorParser.buildContainerFromStore(portrait.container)
frame = @builder.addFrame(s, rect, scale)
@builder.addAnimation 'portrait', [frame], false
addGeneralFrames: ->
framesMap = {}
for animation in @requiredRawAnimations()
name = animation.animation
mc = @vectorParser.buildMovieClip name
continue unless mc
@builder.addMovieClip mc, null, animation.scale * @options.resolutionFactor
framesMap[animation.scale + '_' + name] = @builder._animations[name].frames
for name, action of @actions when action.animation
continue if name is 'portrait'
scale = action.scale ? @get('scale') ? 1
frames = framesMap[scale + '_' + action.animation]
continue unless frames
frames = @mapFrames(action.frames, frames[0]) if action.frames?
next = true
next = action.goesTo if action.goesTo
next = false if action.loops is false
@builder.addAnimation name, frames, next
for name, action of @actions when action.container and not action.animation
continue if name is 'portrait'
scale = @options.resolutionFactor * (action.scale or @get('scale') or 1)
s = @vectorParser.buildContainerFromStore(action.container)
continue unless s
frame = @builder.addFrame(s, s.bounds, scale)
@builder.addAnimation name, [frame], false
requiredRawAnimations: ->
required = []
for name, action of @get('actions')
continue if name is 'portrait'
allActions = [action].concat(_.values (action.relatedActions ? {}))
for a in allActions when a.animation
scale = if name is 'portrait' then a.scale or 1 else a.scale or @get('scale') or 1
animation = {animation: a.animation, scale: scale}
animation.portrait = name is 'portrait'
unless _.find(required, (r) -> _.isEqual r, animation)
required.push animation
required
mapFrames: (frames, frameOffset) ->
return frames unless _.isString(frames) # don't accidentally do this again
(parseInt(f, 10) + frameOffset for f in frames.split(','))
finishBuild: ->
return if _.isEmpty(@builder._animations)
key = @spriteSheetKey(@options)
spriteSheet = null
if @options.async
buildQueue.push @builder
@builder.t0 = new Date().getTime()
@builder.buildAsync() unless buildQueue.length > 1
@builder.on 'complete', @onBuildSpriteSheetComplete, @, true, [@builder, key, @options]
@builder = null
return key
spriteSheet = @builder.build()
@logBuild @t0, false, @options.portraitOnly
@spriteSheets[key] = spriteSheet
@building[key] = false
@builder = null
@options = null
spriteSheet
onBuildSpriteSheetComplete: (e, data) ->
[builder, key, options] = data
@logBuild builder.t0, true, options.portraitOnly
buildQueue = buildQueue.slice(1)
buildQueue[0].t0 = new Date().getTime() if buildQueue[0]
buildQueue[0]?.buildAsync()
@spriteSheets[key] = e.target.spriteSheet
@building[key] = false
@trigger 'build-complete', {key: key, thangType: @}
@vectorParser = null
logBuild: (startTime, async, portrait) ->
kind = if async then 'Async' else 'Sync '
portrait = if portrait then '(Portrait)' else ''
name = _.string.rpad @get('name'), 20
time = _.string.lpad '' + new Date().getTime() - startTime, 6
console.debug "Built sheet: #{name} #{time}ms #{kind} #{portrait}"
spriteSheetKey: (options) ->
colorConfigs = []
for groupName, config of options.colorConfig or {}
colorConfigs.push "#{groupName}:#{config.hue}|#{config.saturation}|#{config.lightness}"
colorConfigs = colorConfigs.join ','
portraitOnly = !!options.portraitOnly
"#{@get('name')} - #{options.resolutionFactor} - #{colorConfigs} - #{portraitOnly}"
getPortraitImage: (spriteOptionsOrKey, size=100) ->
src = @getPortraitSource(spriteOptionsOrKey, size)
return null unless src
$('<img />').attr('src', src)
getPortraitSource: (spriteOptionsOrKey, size=100) ->
return @getPortraitURL() if @get('rasterIcon') or @get('raster')
stage = @getPortraitStage(spriteOptionsOrKey, size)
stage?.toDataURL()
getPortraitStage: (spriteOptionsOrKey, size=100) ->
canvas = $("<canvas width='#{size}' height='#{size}'></canvas>")
stage = new createjs.Stage(canvas[0])
return stage unless @isFullyLoaded()
key = spriteOptionsOrKey
key = if _.isString(key) then key else @spriteSheetKey(@fillOptions(key))
spriteSheet = @spriteSheets[key]
if not spriteSheet
options = if _.isPlainObject spriteOptionsOrKey then spriteOptionsOrKey else {}
options.portraitOnly = true
spriteSheet = @buildSpriteSheet(options)
return if _.isString spriteSheet
return unless spriteSheet
sprite = new createjs.Sprite(spriteSheet)
pt = @actions.portrait?.positions?.registration
sprite.regX = pt?.x or 0
sprite.regY = pt?.y or 0
sprite.framerate = @actions.portrait?.framerate ? 20
sprite.gotoAndStop 'portrait'
stage.addChild(sprite)
stage.update()
stage.startTalking = ->
sprite.gotoAndPlay 'portrait'
return # TODO: causes infinite recursion in new EaselJS
return if @tick
@tick = (e) => @update(e)
createjs.Ticker.addEventListener 'tick', @tick
stage.stopTalking = ->
sprite.gotoAndStop 'portrait'
return # TODO: just breaks in new EaselJS
@update()
createjs.Ticker.removeEventListener 'tick', @tick
@tick = null
stage
getVectorPortraitStage: (size=100) ->
return unless @actions
canvas = $("<canvas width='#{size}' height='#{size}'></canvas>")
stage = new createjs.Stage(canvas[0])
portrait = @actions.portrait
return unless portrait and (portrait.animation or portrait.container)
scale = portrait.scale or 1
vectorParser = new SpriteBuilder(@, {})
if portrait.animation
sprite = vectorParser.buildMovieClip portrait.animation
sprite.gotoAndStop(0)
else if portrait.container
sprite = vectorParser.buildContainerFromStore(portrait.container)
pt = portrait.positions?.registration
sprite.regX = pt?.x / scale or 0
sprite.regY = pt?.y / scale or 0
sprite.scaleX = sprite.scaleY = scale * size / 100
stage.addChild(sprite)
stage.update()
stage
uploadGenericPortrait: (callback, src) ->
src ?= @getPortraitSource()
return callback?() unless src and _.string.startsWith src, 'data:'
src = src.replace('data:image/png;base64,', '').replace(/\ /g, '+')
body =
filename: 'portrait.png'
mimetype: 'image/png'
path: "db/thang.type/#{@get('original')}"
b64png: src
force: 'true'
$.ajax('/file', {type: 'POST', data: body, success: callback or @onFileUploaded})
onFileUploaded: =>
console.log 'Image uploaded'
@loadUniversalWizard: ->
return @wizardType if @wizardType
wizOriginal = '52a00d55cf1818f2be00000b'
url = "/db/thang.type/#{wizOriginal}/version"
@wizardType = new module.exports()
@wizardType.url = -> url
@wizardType.fetch()
@wizardType
getPortraitURL: ->
if iconURL = @get('rasterIcon')
return "/file/#{iconURL}"
if rasterURL = @get('raster')
return "/file/#{rasterURL}"
"/file/db/thang.type/#{@get('original')}/portrait.png"
# Item functions
getAllowedSlots: ->
itemComponentRef = _.find(
@get('components') or [],
(compRef) -> compRef.original is LevelComponent.ItemID)
return itemComponentRef?.config?.slots or ['right-hand'] # ['right-hand'] is default
getAllowedHeroClasses: ->
return [heroClass] if heroClass = @get 'heroClass'
['Warrior', 'Ranger', 'Wizard']
getHeroStats: ->
# Translate from raw hero properties into appropriate display values for the PlayHeroesModal.
# Adapted from https://docs.google.com/a/codecombat.com/spreadsheets/d/1BGI1bzT4xHvWA81aeyIaCKWWw9zxn7-MwDdydmB5vw4/edit#gid=809922675
return unless heroClass = @get('heroClass')
components = @get('components') or []
unless equipsConfig = _.find(components, original: LevelComponent.EquipsID)?.config
return console.warn @get('name'), 'is not an equipping hero, but you are asking for its hero stats. (Did you project away components?)'
unless movesConfig = _.find(components, original: LevelComponent.MovesID)?.config
return console.warn @get('name'), 'is not a moving hero, but you are asking for its hero stats.'
unless programmableConfig = _.find(components, original: LevelComponent.ProgrammableID)?.config
return console.warn @get('name'), 'is not a Programmable hero, but you are asking for its hero stats.'
@classStatAverages ?=
attack: {Warrior: 7.5, Ranger: 5, Wizard: 2.5}
health: {Warrior: 7.5, Ranger: 5, Wizard: 3.5}
stats = {}
rawNumbers = attack: equipsConfig.attackDamageFactor ? 1, health: equipsConfig.maxHealthFactor ? 1, speed: movesConfig.maxSpeed
for prop in ['attack', 'health']
stat = rawNumbers[prop]
if stat < 1
classSpecificScore = 10 - 5 / stat
else
classSpecificScore = stat * 5
classAverage = @classStatAverages[prop][@get('heroClass')]
stats[prop] = Math.round(2 * ((classAverage - 2.5) + classSpecificScore / 2)) / 2 / 10
minSpeed = 4
maxSpeed = 16
speedRange = maxSpeed - minSpeed
speedPoints = rawNumbers.speed - minSpeed
stats.speed = Math.round(20 * speedPoints / speedRange) / 2 / 10
stats.skills = (_.string.titleize(_.string.humanize(skill)) for skill in programmableConfig.programmableProperties when skill isnt 'say')
stats
getFrontFacingStats: ->
components = @get('components') or []
unless itemConfig = _.find(components, original: LevelComponent.ItemID)?.config
console.warn @get('name'), 'is not an item, but you are asking for its stats.'
return props: [], stats: {}
stats = {}
props = itemConfig.programmableProperties ? []
props = props.concat itemConfig.moreProgrammableProperties ? []
props = _.without props, 'canCast', 'spellNames', 'spells'
for stat, modifiers of itemConfig.stats ? {}
stats[stat] = @formatStatDisplay stat, modifiers
for stat in itemConfig.extraHUDProperties ? []
stats[stat] ?= null # Find it in the other Components.
for component in components
continue unless config = component.config
for stat, value of stats when not value?
value = config[stat]
continue unless value?
stats[stat] = @formatStatDisplay stat, setTo: value
if stat is 'attackDamage'
dps = (value / (config.cooldown or 0.5)).toFixed(1)
stats[stat].display += " (#{dps} DPS)"
if config.programmableSnippets
props = props.concat config.programmableSnippets
for stat, value of stats when not value?
stats[stat] = name: stat, display: '???'
statKeys = _.keys(stats)
statKeys.sort()
props.sort()
sortedStats = {}
sortedStats[key] = stats[key] for key in statKeys
props: props, stats: sortedStats
formatStatDisplay: (name, modifiers) ->
i18nKey = {
maxHealth: 'health'
maxSpeed: 'speed'
healthReplenishRate: 'regeneration'
attackDamage: 'attack'
attackRange: 'range'
shieldDefenseFactor: 'blocks'
visualRange: 'range'
throwDamage: 'attack'
throwRange: 'range'
bashDamage: 'attack'
backstabDamage: 'backstab'
}[name]
if i18nKey
name = $.i18n.t 'choose_hero.' + i18nKey
else
name = _.string.humanize name
format = ''
format = 'm' if /(range|radius|distance|vision)$/i.test name
format ||= 's' if /cooldown$/i.test name
format ||= 'm/s' if /speed$/i.test name
format ||= '/s' if /(regeneration| rate)$/i.test name
value = modifiers.setTo
if /(blocks)$/i.test name
format ||= '%'
value = (value*100).toFixed(1)
value = value.join ', ' if _.isArray value
display = []
display.push "#{value}#{format}" if value?
display.push "+#{modifiers.addend}#{format}" if modifiers.addend > 0
display.push "#{modifiers.addend}#{format}" if modifiers.addend < 0
display.push "x#{modifiers.factor}" if modifiers.factor? and modifiers.factor isnt 1
display = display.join ', '
display = display.replace /9001m?/, 'Infinity'
name: name, display: display
isSilhouettedItem: ->
return console.error "Trying to determine whether #{@get('name')} should be a silhouetted item, but it has no gem cost." unless @get('gems') or @get('tier')
console.info "Add (or make sure you have fetched) a tier for #{@get('name')} to more accurately determine whether it is silhouetted." unless @get('tier')?
tier = @get 'tier'
if tier?
return @levelRequiredForItem() > me.level()
points = me.get('points')
expectedTotalGems = (points ? 0) * 1.5 # Not actually true, but roughly kinda close for tier 0, kinda tier 1
@get('gems') > (100 + expectedTotalGems) * 1.2
levelRequiredForItem: ->
return console.error "Trying to determine what level is required for #{@get('name')}, but it has no tier." unless @get('tier')?
itemTier = @get 'tier'
playerTier = itemTier / 2.5
playerLevel = me.constructor.levelForTier playerTier
#console.log 'Level required for', @get('name'), 'is', playerLevel, 'player tier', playerTier, 'because it is itemTier', itemTier, 'which is normally level', me.constructor.levelForTier(itemTier)
playerLevel