codecombat/app/models/ThangType.coffee
Scott Erickson 919e0605e9 Add spriteSheets to ThangType, export modal to Thang Editor
Units can be exported as rastered sprite sheets. This is the first part of the project,
the second part will be having the game use them.
2015-10-12 16:47:48 -07:00

511 lines
20 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'
raider: '55527eb0b8abf4ba1fe9a107'
goliath: '55e1a6e876cb0948c96af9f8'
guardian: ''
ninja: '52fc0ed77e01835453bd8f6c'
'forest-archer': '5466d4f2417c8b48a9811e87'
trapper: '5466d449417c8b48a9811e83'
pixie: ''
assassin: ''
librarian: '52fbf74b7e01835453bd8d8e'
'potion-master': '52e9adf7427172ae56002172'
sorcerer: '52fd1524c7e6cf99160e7bc9'
necromancer: '55652fb3b9effa46a1f775fd'
'dark-wizard': ''
@heroClasses:
Warrior: ['captain', 'knight', 'samurai', 'raider', 'goliath', 'guardian']
Ranger: ['ninja', 'forest-archer', 'trapper', 'pixie', 'assassin']
Wizard: ['librarian', 'potion-master', 'sorcerer', 'necromancer', 'dark-wizard']
@items:
'simple-boots': '53e237bf53457600003e3f05'
urlRoot: '/db/thang.type'
building: {}
editableByArtisans: true
@defaultActions: ['idle', 'die', 'move', 'attack']
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()
getDefaultActions: ->
actions = []
for action in _.values(@getActions())
continue unless _.any ThangType.defaultActions, (prefix) ->
_.string.startsWith(action.name, prefix)
actions.push(action)
return actions
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>")
try
stage = new createjs.Stage(canvas[0])
catch err
console.error "Error trying to create #{@get('name')} avatar stage:", err, "with window as", window
return null
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] =
relative: Math.round(2 * ((classAverage - 2.5) + classSpecificScore / 2)) / 2 / 10
absolute: stat
pieces = ($.i18n.t "choose_hero.#{prop}_#{num}" for num in [1 .. 3])
percent = Math.round(stat * 100) + '%'
className = $.i18n.t "general.#{_.string.slugify @get('heroClass')}"
stats[prop].description = [pieces[0], percent, pieces[1], className, pieces[2]].join ' '
minSpeed = 4
maxSpeed = 16
speedRange = maxSpeed - minSpeed
speedPoints = rawNumbers.speed - minSpeed
stats.speed =
relative: Math.round(20 * speedPoints / speedRange) / 2 / 10
absolute: rawNumbers.speed
description: "#{$.i18n.t 'choose_hero.speed_1'} #{rawNumbers.speed} #{$.i18n.t 'choose_hero.speed_2'}"
stats.skills = (_.string.titleize(_.string.humanize(skill)) for skill in programmableConfig.programmableProperties when skill isnt 'say' and not /(Range|Pos|Radius|Damage)$/.test(skill))
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
getContainersForAnimation: (animation, action) ->
rawAnimation = @get('raw').animations[animation]
if not rawAnimation
console.error 'thang type', @get('name'), 'is missing animation', animation, 'from action', action
containers = rawAnimation.containers
for animation in @get('raw').animations[animation].animations
containers = containers.concat(@getContainersForAnimation(animation.gn, action))
return containers
getContainersForActions: (actionNames) ->
containersToRender = {}
actions = @getActions()
for actionName in actionNames
action = _.find(actions, {name: actionName})
if action.container
containersToRender[action.container] = true
else if action.animation
animationContainers = @getContainersForAnimation(action.animation, action)
containersToRender[container.gn] = true for container in animationContainers
return _.keys(containersToRender)