codecombat/app/lib/LevelLoader.coffee
phoenixeliot 870ae9a8a1 Add hero selector for courses mode
Use selected hero in Course mode play

Show selected hero on Courses (in progress)

Add hero select modal

Use short names, only show warriors

Use box-shadow instead of borders

Add tests for HeroSelectModal

Refactor modal opening test

Address code review feedback
2016-06-03 13:40:41 -07:00

517 lines
22 KiB
CoffeeScript

Level = require 'models/Level'
LevelComponent = require 'models/LevelComponent'
LevelSystem = require 'models/LevelSystem'
Article = require 'models/Article'
LevelSession = require 'models/LevelSession'
ThangType = require 'models/ThangType'
ThangNamesCollection = require 'collections/ThangNamesCollection'
CocoClass = require 'core/CocoClass'
AudioPlayer = require 'lib/AudioPlayer'
app = require 'core/application'
World = require 'lib/world/world'
utils = require 'core/utils'
# This is an initial stab at unifying loading and setup into a single place which can
# monitor everything and keep a LoadingScreen visible overall progress.
#
# Would also like to incorporate into here:
# * World Building
# * Sprite map generation
# * Connecting to Firebase
module.exports = class LevelLoader extends CocoClass
constructor: (options) ->
@t0 = new Date().getTime()
super()
@supermodel = options.supermodel
@supermodel.setMaxProgress 0.2
@levelID = options.levelID
@sessionID = options.sessionID
@opponentSessionID = options.opponentSessionID
@team = options.team
@headless = options.headless
@sessionless = options.sessionless
@fakeSessionConfig = options.fakeSessionConfig
@spectateMode = options.spectateMode ? false
@observing = options.observing
@courseID = options.courseID
@worldNecessities = []
@listenTo @supermodel, 'resource-loaded', @onWorldNecessityLoaded
@loadLevel()
@loadAudio()
@playJingle()
if @supermodel.finished()
@onSupermodelLoaded()
else
@listenToOnce @supermodel, 'loaded-all', @onSupermodelLoaded
# Supermodel (Level) Loading
loadLevel: ->
@level = @supermodel.getModel(Level, @levelID) or new Level _id: @levelID
if @level.loaded
@onLevelLoaded()
else
@level = @supermodel.loadModel(@level, 'level').model
@listenToOnce @level, 'sync', @onLevelLoaded
onLevelLoaded: ->
if (@courseID and @level.get('type', true) not in ['course', 'course-ladder']) or window.serverConfig.picoCTF
# Because we now use original hero levels for both hero and course levels, we fake being a course level in this context.
originalGet = @level.get
@level.get = ->
return 'course' if arguments[0] is 'type'
originalGet.apply @, arguments
if window.serverConfig.picoCTF
@supermodel.addRequestResource(url: '/picoctf/problems', success: (picoCTFProblems) =>
@level?.picoCTFProblem = _.find picoCTFProblems, pid: @level.get('picoCTFProblem')
).load()
if @sessionless
null
else if @fakeSessionConfig?
@loadFakeSession()
else
@loadSession()
@populateLevel()
# Session Loading
loadFakeSession: ->
if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop']
@sessionDependenciesRegistered = {}
initVals =
level:
original: @level.get('original')
majorVersion: @level.get('version').major
creator: me.id
state:
complete: false
scripts: {}
permissions: [
{target: me.id, access: 'owner'}
{target: 'public', access: 'write'}
]
codeLanguage: @fakeSessionConfig.codeLanguage or me.get('aceConfig')?.language or 'python'
_id: 'A Fake Session ID'
@session = new LevelSession initVals
@session.loaded = true
@fakeSessionConfig.callback? @session, @level
# TODO: set the team if we need to, for multiplayer
# TODO: just finish the part where we make the submit button do what is right when we are fake
# TODO: anything else to make teacher session-less play make sense when we are fake
# TODO: make sure we are not actually calling extra save/patch/put things throwing warnings because we know we are fake and so we shouldn't try to do that
for method in ['save', 'patch', 'put']
@session[method] = -> console.error "We shouldn't be doing a session.#{method}, since it's a fake session."
@session.fake = true
@loadDependenciesForSession @session
loadSession: ->
if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop']
@sessionDependenciesRegistered = {}
if @sessionID
url = "/db/level.session/#{@sessionID}"
url += "?interpret=true" if @spectateMode
else
url = "/db/level/#{@levelID}/session"
url += "?team=#{@team}" if @team
url += "?course=#{@courseID}" if @courseID
session = new LevelSession().setURL url
session.project = ['creator', 'team', 'heroConfig', 'codeLanguage', 'submittedCodeLanguage', 'state'] if @headless
@sessionResource = @supermodel.loadModel(session, 'level_session', {cache: false})
@session = @sessionResource.model
if @opponentSessionID
opponentURL = "/db/level.session/#{@opponentSessionID}?interpret=true"
opponentSession = new LevelSession().setURL opponentURL
opponentSession.project = session.project if @headless
@opponentSessionResource = @supermodel.loadModel(opponentSession, 'opponent_session', {cache: false})
@opponentSession = @opponentSessionResource.model
if @session.loaded
@session.setURL '/db/level.session/' + @session.id
@loadDependenciesForSession @session
else
@listenToOnce @session, 'sync', ->
@session.setURL '/db/level.session/' + @session.id
@loadDependenciesForSession @session
if @opponentSession
if @opponentSession.loaded
@loadDependenciesForSession @opponentSession
else
@listenToOnce @opponentSession, 'sync', @loadDependenciesForSession
loadDependenciesForSession: (session) ->
if me.id isnt session.get 'creator'
session.patch = session.save = -> console.error "Not saving session, since we didn't create it."
else if codeLanguage = utils.getQueryVariable 'codeLanguage'
session.set 'codeLanguage', codeLanguage
@loadCodeLanguagesForSession session
if compressed = session.get 'interpret'
uncompressed = LZString.decompressFromUTF16 compressed
code = session.get 'code'
code[if session.get('team') is 'humans' then 'hero-placeholder' else 'hero-placeholder-1'].plan = uncompressed
session.set 'code', code
session.unset 'interpret'
if session.get('codeLanguage') in ['io', 'clojure']
session.set 'codeLanguage', 'python'
if session is @session
@addSessionBrowserInfo session
# hero-ladder games require the correct session team in level:loaded
team = @team ? @session.get('team')
Backbone.Mediator.publish 'level:loaded', level: @level, team: team
Backbone.Mediator.publish 'level:session-loaded', level: @level, session: @session
@consolidateFlagHistory() if @opponentSession?.loaded
else if session is @opponentSession
@consolidateFlagHistory() if @session.loaded
if @level.get('type', true) in ['course'] # course-ladder is hard to handle because there's 2 sessions
heroConfig = me.get('heroConfig')
return if not heroConfig
url = "/db/thang.type/#{heroConfig.thangType}/version"
if heroResource = @maybeLoadURL(url, ThangType, 'thang')
@worldNecessities.push heroResource
return
return unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop']
heroConfig = session.get('heroConfig')
heroConfig ?= me.get('heroConfig') if session is @session and not @headless
heroConfig ?= {}
heroConfig.inventory ?= feet: '53e237bf53457600003e3f05' # If all else fails, assign simple boots.
heroConfig.thangType ?= '529ffbf1cf1818f2be000001' # If all else fails, assign Tharin as the hero.
session.set 'heroConfig', heroConfig unless _.isEqual heroConfig, session.get('heroConfig')
url = "/db/thang.type/#{heroConfig.thangType}/version"
if heroResource = @maybeLoadURL(url, ThangType, 'thang')
@worldNecessities.push heroResource
else
heroThangType = @supermodel.getModel url
@loadDefaultComponentsForThangType heroThangType
@loadThangsRequiredByThangType heroThangType
for itemThangType in _.values(heroConfig.inventory)
url = "/db/thang.type/#{itemThangType}/version?project=name,components,original,rasterIcon,kind"
if itemResource = @maybeLoadURL(url, ThangType, 'thang')
@worldNecessities.push itemResource
else
itemThangType = @supermodel.getModel url
@loadDefaultComponentsForThangType itemThangType
@loadThangsRequiredByThangType itemThangType
@sessionDependenciesRegistered[session.id] = true
if _.size(@sessionDependenciesRegistered) is 2 and @checkAllWorldNecessitiesRegisteredAndLoaded()
@onWorldNecessitiesLoaded()
loadCodeLanguagesForSession: (session) ->
codeLanguages = _.uniq _.filter [session.get('codeLanguage') or 'python', session.get('submittedCodeLanguage')]
for codeLanguage in codeLanguages
do (codeLanguage) =>
modulePath = "vendor/aether-#{codeLanguage}"
return unless application.moduleLoader?.load modulePath
languageModuleResource = @supermodel.addSomethingResource 'language_module'
onModuleLoaded = (e) ->
return unless e.id is modulePath
languageModuleResource.markLoaded()
@stopListening application.moduleLoader, 'loaded', onModuleLoaded # listenToOnce might work here instead, haven't tried
@listenTo application.moduleLoader, 'loaded', onModuleLoaded
addSessionBrowserInfo: (session) ->
return unless me.id is session.get 'creator'
return unless $.browser?
browser = {}
browser['desktop'] = $.browser.desktop if $.browser.desktop
browser['name'] = $.browser.name if $.browser.name
browser['platform'] = $.browser.platform if $.browser.platform
browser['version'] = $.browser.version if $.browser.version
session.set 'browser', browser
session.patch() unless session.fake
consolidateFlagHistory: ->
state = @session.get('state') ? {}
myFlagHistory = _.filter state.flagHistory ? [], team: @session.get('team')
opponentFlagHistory = _.filter @opponentSession.get('state')?.flagHistory ? [], team: @opponentSession.get('team')
state.flagHistory = myFlagHistory.concat opponentFlagHistory
@session.set 'state', state
# Grabbing the rest of the required data for the level
populateLevel: ->
thangIDs = []
componentVersions = []
systemVersions = []
articleVersions = []
flagThang = thangType: '53fa25f25bc220000052c2be', id: 'Placeholder Flag', components: []
for thang in (@level.get('thangs') or []).concat [flagThang]
thangIDs.push thang.thangType
@loadThangsRequiredByLevelThang(thang)
for comp in thang.components or []
componentVersions.push _.pick(comp, ['original', 'majorVersion'])
for system in @level.get('systems') or []
systemVersions.push _.pick(system, ['original', 'majorVersion'])
if indieSprites = system?.config?.indieSprites
for indieSprite in indieSprites
thangIDs.push indieSprite.thangType
unless @headless
for article in @level.get('documentation')?.generalArticles or []
articleVersions.push _.pick(article, ['original', 'majorVersion'])
objUniq = (array) -> _.uniq array, false, (arg) -> JSON.stringify(arg)
worldNecessities = []
@thangIDs = _.uniq thangIDs
@thangNames = new ThangNamesCollection(@thangIDs)
worldNecessities.push @supermodel.loadCollection(@thangNames, 'thang_names')
@listenToOnce @thangNames, 'sync', @onThangNamesLoaded
worldNecessities.push @sessionResource if @sessionResource?.isLoading
worldNecessities.push @opponentSessionResource if @opponentSessionResource?.isLoading
for obj in objUniq componentVersions
url = "/db/level.component/#{obj.original}/version/#{obj.majorVersion}"
worldNecessities.push @maybeLoadURL(url, LevelComponent, 'component')
for obj in objUniq systemVersions
url = "/db/level.system/#{obj.original}/version/#{obj.majorVersion}"
worldNecessities.push @maybeLoadURL(url, LevelSystem, 'system')
for obj in objUniq articleVersions
url = "/db/article/#{obj.original}/version/#{obj.majorVersion}"
@maybeLoadURL url, Article, 'article'
if obj = @level.get 'nextLevel' # TODO: update to get next level from campaigns, not this old property
url = "/db/level/#{obj.original}/version/#{obj.majorVersion}"
@maybeLoadURL url, Level, 'level'
@worldNecessities = @worldNecessities.concat worldNecessities
loadThangsRequiredByLevelThang: (levelThang) ->
@loadThangsRequiredFromComponentList levelThang.components
loadThangsRequiredByThangType: (thangType) ->
@loadThangsRequiredFromComponentList thangType.get('components')
loadThangsRequiredFromComponentList: (components) ->
return unless components
requiredThangTypes = []
for component in components when component.config
if component.original is LevelComponent.EquipsID
requiredThangTypes.push itemThangType for itemThangType in _.values (component.config.inventory ? {})
else if component.config.requiredThangTypes
requiredThangTypes = requiredThangTypes.concat component.config.requiredThangTypes
extantRequiredThangTypes = _.filter requiredThangTypes
if extantRequiredThangTypes.length < requiredThangTypes.length
console.error "Some Thang had a blank required ThangType in components list:", components
for thangType in extantRequiredThangTypes
url = "/db/thang.type/#{thangType}/version?project=name,components,original,rasterIcon,kind,prerenderedSpriteSheetData"
@worldNecessities.push @maybeLoadURL(url, ThangType, 'thang')
onThangNamesLoaded: (thangNames) ->
for thangType in thangNames.models
@loadDefaultComponentsForThangType(thangType)
@loadThangsRequiredByThangType(thangType)
@thangNamesLoaded = true
@onWorldNecessitiesLoaded() if @checkAllWorldNecessitiesRegisteredAndLoaded()
loadDefaultComponentsForThangType: (thangType) ->
return unless components = thangType.get('components')
for component in components
url = "/db/level.component/#{component.original}/version/#{component.majorVersion}"
@worldNecessities.push @maybeLoadURL(url, LevelComponent, 'component')
onWorldNecessityLoaded: (resource) ->
index = @worldNecessities.indexOf(resource)
if resource.name is 'thang'
@loadDefaultComponentsForThangType(resource.model)
@loadThangsRequiredByThangType(resource.model)
return unless index >= 0
@worldNecessities.splice(index, 1)
@worldNecessities = (r for r in @worldNecessities when r?)
@onWorldNecessitiesLoaded() if @checkAllWorldNecessitiesRegisteredAndLoaded()
checkAllWorldNecessitiesRegisteredAndLoaded: ->
return false unless _.filter(@worldNecessities).length is 0
return false unless @thangNamesLoaded
return false if @sessionDependenciesRegistered and not @sessionDependenciesRegistered[@session.id] and not @sessionless
return false if @sessionDependenciesRegistered and @opponentSession and not @sessionDependenciesRegistered[@opponentSession.id] and not @sessionless
true
onWorldNecessitiesLoaded: ->
@initWorld()
@supermodel.clearMaxProgress()
@trigger 'world-necessities-loaded'
return if @headless
thangsToLoad = _.uniq( (t.spriteName for t in @world.thangs when t.exists) )
nameModelTuples = ([thangType.get('name'), thangType] for thangType in @thangNames.models)
nameModelMap = _.zipObject nameModelTuples
@spriteSheetsToBuild ?= []
# for thangTypeName in thangsToLoad
# thangType = nameModelMap[thangTypeName]
# continue if not thangType or thangType.isFullyLoaded()
# thangType.fetch()
# thangType = @supermodel.loadModel(thangType, 'thang').model
# res = @supermodel.addSomethingResource 'sprite_sheet', 5
# res.thangType = thangType
# res.markLoading()
# @spriteSheetsToBuild.push res
@buildLoopInterval = setInterval @buildLoop, 5 if @spriteSheetsToBuild.length
maybeLoadURL: (url, Model, resourceName) ->
return if @supermodel.getModel(url)
model = new Model().setURL url
@supermodel.loadModel(model, resourceName)
onSupermodelLoaded: ->
return if @destroyed
console.log 'SuperModel for Level loaded in', new Date().getTime() - @t0, 'ms'
@loadLevelSounds()
@denormalizeSession()
buildLoop: =>
someLeft = false
for spriteSheetResource, i in @spriteSheetsToBuild ? []
continue if spriteSheetResource.spriteSheetKeys
someLeft = true
thangType = spriteSheetResource.thangType
if thangType.loaded and not thangType.loading
keys = @buildSpriteSheetsForThangType spriteSheetResource.thangType
if keys and keys.length
@listenTo spriteSheetResource.thangType, 'build-complete', @onBuildComplete
spriteSheetResource.spriteSheetKeys = keys
else
spriteSheetResource.markLoaded()
clearInterval @buildLoopInterval unless someLeft
onBuildComplete: (e) ->
resource = null
for resource in @spriteSheetsToBuild
break if e.thangType is resource.thangType
return console.error 'Did not find spriteSheetToBuildResource for', e unless resource
resource.spriteSheetKeys = (k for k in resource.spriteSheetKeys when k isnt e.key)
resource.markLoaded() if resource.spriteSheetKeys.length is 0
denormalizeSession: ->
return if @headless or @sessionDenormalized or @spectateMode or @sessionless or me.isSessionless()
# This is a way (the way?) PUT /db/level.sessions/undefined was happening
# See commit c242317d9
return if not @session.id
patch =
'levelName': @level.get('name')
'levelID': @level.get('slug') or @level.id
if me.id is @session.get 'creator'
patch.creatorName = me.get('name')
for key, value of patch
if @session.get(key) is value
delete patch[key]
unless _.isEmpty patch
@session.set key, value for key, value of patch
tempSession = new LevelSession _id: @session.id
tempSession.save(patch, {patch: true, type: 'PUT'})
@sessionDenormalized = true
# Building sprite sheets
buildSpriteSheetsForThangType: (thangType) ->
return if @headless
# TODO: Finish making sure the supermodel loads the raster image before triggering load complete, and that the cocosprite has access to the asset.
# if f = thangType.get('raster')
# queue = new createjs.LoadQueue()
# queue.loadFile('/file/'+f)
@grabThangTypeTeams() unless @thangTypeTeams
keys = []
for team in @thangTypeTeams[thangType.get('original')] ? [null]
spriteOptions = {resolutionFactor: SPRITE_RESOLUTION_FACTOR, async: true}
if thangType.get('kind') is 'Floor'
spriteOptions.resolutionFactor = 2
if team and color = @teamConfigs[team]?.color
spriteOptions.colorConfig = team: color
key = @buildSpriteSheet thangType, spriteOptions
if _.isString(key) then keys.push key
keys
grabThangTypeTeams: ->
@grabTeamConfigs()
@thangTypeTeams = {}
for thang in @level.get('thangs')
if @level.get('type', true) in ['hero', 'course'] and thang.id is 'Hero Placeholder'
continue # No team colors for heroes on single-player levels
for component in thang.components
if team = component.config?.team
@thangTypeTeams[thang.thangType] ?= []
@thangTypeTeams[thang.thangType].push team unless team in @thangTypeTeams[thang.thangType]
break
@thangTypeTeams
grabTeamConfigs: ->
for system in @level.get('systems')
if @teamConfigs = system.config?.teamConfigs
break
unless @teamConfigs
# Hack: pulled from Alliance System code. TODO: put in just one place.
@teamConfigs = {'humans': {'superteam': 'humans', 'color': {'hue': 0, 'saturation': 0.75, 'lightness': 0.5}, 'playable': true}, 'ogres': {'superteam': 'ogres', 'color': {'hue': 0.66, 'saturation': 0.75, 'lightness': 0.5}, 'playable': false}, 'neutral': {'superteam': 'neutral', 'color': {'hue': 0.33, 'saturation': 0.75, 'lightness': 0.5}}}
@teamConfigs
buildSpriteSheet: (thangType, options) ->
if thangType.get('name') is 'Wizard'
options.colorConfig = me.get('wizard')?.colorConfig or {}
thangType.buildSpriteSheet options
# World init
initWorld: ->
return if @initialized
@initialized = true
@world = new World()
@world.levelSessionIDs = if @opponentSessionID then [@sessionID, @opponentSessionID] else [@sessionID]
@world.submissionCount = @session?.get('state')?.submissionCount ? 0
@world.flagHistory = @session?.get('state')?.flagHistory ? []
@world.difficulty = @session?.get('state')?.difficulty ? 0
if @observing
@world.difficulty = Math.max 0, @world.difficulty - 1 # Show the difficulty they won, not the next one.
serializedLevel = @level.serialize(@supermodel, @session, @opponentSession)
@world.loadFromLevel serializedLevel, false
console.log 'World has been initialized from level loader.'
# Initial Sound Loading
playJingle: ->
return if @headless or not me.get('volume')
volume = 0.5
if me.level() < 3
volume = 0.25 # Start softly, since they may not be expecting it
# Apparently the jingle, when it tries to play immediately during all this loading, you can't hear it.
# Add the timeout to fix this weird behavior.
f = ->
jingles = ['ident_1', 'ident_2']
AudioPlayer.playInterfaceSound jingles[Math.floor Math.random() * jingles.length], volume
setTimeout f, 500
loadAudio: ->
return if @headless or not me.get('volume')
AudioPlayer.preloadInterfaceSounds ['victory']
loadLevelSounds: ->
return if @headless or not me.get('volume')
scripts = @level.get 'scripts'
return unless scripts
for script in scripts when script.noteChain
for noteGroup in script.noteChain when noteGroup.sprites
for sprite in noteGroup.sprites when sprite.say?.sound
AudioPlayer.preloadSoundReference(sprite.say.sound)
thangTypes = @supermodel.getModels(ThangType)
for thangType in thangTypes
for trigger, sounds of thangType.get('soundTriggers') or {} when trigger isnt 'say'
AudioPlayer.preloadSoundReference sound for sound in sounds
# everything else sound wise is loaded as needed as worlds are generated
progress: -> @supermodel.progress
destroy: ->
clearInterval @buildLoopInterval if @buildLoopInterval
super()