mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-04 21:01:06 -05:00
460 lines
18 KiB
CoffeeScript
460 lines
18 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
|
|
matchedShortName = true
|
|
else
|
|
name = _.string.humanize name
|
|
matchedShortName = false
|
|
|
|
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, matchedShortName: matchedShortName
|
|
|
|
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
|