mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-24 16:17:57 -05:00
919e0605e9
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.
511 lines
20 KiB
CoffeeScript
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)
|