diff --git a/app/assets/images/pages/play/level/modal/share_level_parchment.png b/app/assets/images/pages/play/level/modal/share_level_parchment.png new file mode 100644 index 000000000..b76ae9fb8 Binary files /dev/null and b/app/assets/images/pages/play/level/modal/share_level_parchment.png differ diff --git a/app/assets/javascripts/web-dev-listener.js b/app/assets/javascripts/web-dev-listener.js new file mode 100644 index 000000000..58f11db96 --- /dev/null +++ b/app/assets/javascripts/web-dev-listener.js @@ -0,0 +1,164 @@ +// TODO: don't serve this script from codecombat.com; serve it from a harmless extra domain we don't have yet. + +window.addEventListener('message', receiveMessage, false); + +var concreteDom; +var virtualDom; +var goalStates; + +var allowedOrigins = [ + /https:\/\/codecombat\.com/, + /http:\/\/localhost:3000/, + /http:\/\/direct\.codecombat\.com/, + /http:\/\/staging\.codecombat\.com/, + /http:\/\/next\.codecombat\.com/, + /http:\/\/.*codecombat-staging-codecombat\.runnableapp\.com/, +]; + +function receiveMessage(event) { + var origin = event.origin || event.originalEvent.origin; // For Chrome, the origin property is in the event.originalEvent object. + var allowed = false; + allowedOrigins.forEach(function(pattern) { + allowed = allowed || pattern.test(origin); + }); + if (!allowed) { + console.log('Ignoring message from bad origin:', origin); + return; + } + var data = event.data; + var source = event.source; + switch (data.type) { + case 'create': + create(_.pick(data, 'dom', 'styles', 'scripts')); + checkGoals(data.goals, source, origin); + break; + case 'update': + if (virtualDom) + update(_.pick(data, 'dom', 'styles', 'scripts')); + else + create(_.pick(data, 'dom', 'styles', 'scripts')); + checkGoals(data.goals, source, origin); + break; + case 'log': + console.log(data.text); + break; + default: + console.log('Unknown message type:', data.type); + } +} + +function create({ dom, styles, scripts }) { + virtualDom = dom; + virtualStyles = styles; + virtualScripts = scripts; + concreteDom = deku.dom.create(dom); + concreteStyles = deku.dom.create(styles); + concreteScripts = deku.dom.create(scripts); + // TODO: target the actual HTML tag and combine our initial structure for styles/scripts/tags with theirs + // TODO: :after elements don't seem to work? (:before do) + $('body').first().empty().append(concreteDom); + $('#player-styles').first().empty().append(concreteStyles); + $('#player-scripts').first().empty().append(concreteScripts); +} + +function update({ dom, styles, scripts }) { + function dispatch() {} // Might want to do something here in the future + var context = {}; // Might want to use this to send shared state to every component + + var domChanges = deku.diff.diffNode(virtualDom, dom); + domChanges.reduce(deku.dom.update(dispatch, context), concreteDom); // Rerender + + var scriptChanges = deku.diff.diffNode(virtualScripts, scripts); + scriptChanges.reduce(deku.dom.update(dispatch, context), concreteScripts); // Rerender + + var styleChanges = deku.diff.diffNode(virtualStyles, styles); + styleChanges.reduce(deku.dom.update(dispatch, context), concreteStyles); // Rerender + + virtualDom = dom; + virtualStyles = styles; + virtualScripts = scripts; +} + +function checkGoals(goals, source, origin) { + // Check right now and also in one second, since our 1-second CSS transition might be affecting things until it is done. + doCheckGoals(goals, source, origin); + _.delay(function() { doCheckGoals(goals, source, origin); }, 1001); +} + +function doCheckGoals(goals, source, origin) { + var newGoalStates = {}; + var overallSuccess = true; + goals.forEach(function(goal) { + var $result = $(goal.html.selector); + //console.log('ran selector', goal.html.selector, 'to find element(s)', $result); + var success = true; + goal.html.valueChecks.forEach(function(check) { + //console.log(' ... and should make sure that the value of', check.eventProps, 'is', _.omit(check, 'eventProps'), '?', matchesCheck($result, check)) + success = success && matchesCheck($result, check); + }); + overallSuccess = overallSuccess && success; + newGoalStates[goal.id] = {status: success ? 'success' : 'incomplete'}; // No 'failure' state + }); + if (!_.isEqual(newGoalStates, goalStates)) { + goalStates = newGoalStates; + var overallStatus = overallSuccess ? 'success' : null; // Can't really get to 'failure', just 'incomplete', which is represented by null here + source.postMessage({type: 'goals-updated', goalStates: goalStates, overallStatus: overallStatus}, origin); + } +} + +function downTheChain(obj, keyChain) { + if (!obj) + return null; + if (!_.isArray(keyChain)) + return obj[keyChain]; + var value = obj; + while (keyChain.length && value) { + if (keyChain[0].match(/\(.*\)$/)) { + var args, argsString = keyChain[0].match(/\((.*)\)$/)[1]; + if (argsString) + args = eval(argsString).split(/, ?/g).filter(function(x) { return x !== ''; }); // TODO: can/should we avoid eval here? + else + args = []; + value = value[keyChain[0].split('(')[0]].apply(value, args); // value.text(), value.css('background-color'), etc. + } + else + value = value[keyChain[0]]; + keyChain = keyChain.slice(1); + } + return value; +}; + +function matchesCheck(value, check) { + var v = downTheChain(value, check.eventProps); + if ((check.equalTo != null) && v !== check.equalTo) { + return false; + } + if ((check.notEqualTo != null) && v === check.notEqualTo) { + return false; + } + if ((check.greaterThan != null) && !(v > check.greaterThan)) { + return false; + } + if ((check.greaterThanOrEqualTo != null) && !(v >= check.greaterThanOrEqualTo)) { + return false; + } + if ((check.lessThan != null) && !(v < check.lessThan)) { + return false; + } + if ((check.lessThanOrEqualTo != null) && !(v <= check.lessThanOrEqualTo)) { + return false; + } + if ((check.containingString != null) && (!v || v.search(check.containingString) === -1)) { + return false; + } + if ((check.notContainingString != null) && (v != null ? v.search(check.notContainingString) : void 0) !== -1) { + return false; + } + if ((check.containingRegexp != null) && (!v || v.search(new RegExp(check.containingRegexp)) === -1)) { + return false; + } + if ((check.notContainingRegexp != null) && (v != null ? v.search(new RegExp(check.notContainingRegexp)) : void 0) !== -1) { + return false; + } + return true; +} diff --git a/app/assets/javascripts/workers/aether_worker.js b/app/assets/javascripts/workers/aether_worker.js index 80c72bf54..285e6600d 100644 --- a/app/assets/javascripts/workers/aether_worker.js +++ b/app/assets/javascripts/workers/aether_worker.js @@ -19,6 +19,7 @@ var languagesImported = {}; var ensureLanguageImported = function(language) { if (languagesImported[language]) return; + if (language === 'html') return; importScripts("/javascripts/app/vendor/aether-" + language + ".js"); languagesImported[language] = true; }; diff --git a/app/assets/javascripts/workers/worker_world.js b/app/assets/javascripts/workers/worker_world.js index e40889e04..5857ba810 100644 --- a/app/assets/javascripts/workers/worker_world.js +++ b/app/assets/javascripts/workers/worker_world.js @@ -80,7 +80,7 @@ var myImportScripts = importScripts; var languagesImported = {}; var ensureLanguageImported = function(language) { if (languagesImported[language]) return; - if (language === 'javascript') return; // Only has JSHint, but we don't need to lint here. + if (language === 'javascript' || language === 'html') return; // Only has JSHint, but we don't need to lint here. myImportScripts("/javascripts/app/vendor/aether-" + language + ".js"); languagesImported[language] = true; }; diff --git a/app/assets/web-dev-iframe.html b/app/assets/web-dev-iframe.html new file mode 100644 index 000000000..304578e33 --- /dev/null +++ b/app/assets/web-dev-iframe.html @@ -0,0 +1,43 @@ + + + + + + + + + My CodeCombat Website + + + + + + + + + + + + + + + + + + + + + + + + + +

Loading...

+ + diff --git a/app/collections/RealTimeCollection.coffee b/app/collections/RealTimeCollection.coffee deleted file mode 100644 index a5af239ed..000000000 --- a/app/collections/RealTimeCollection.coffee +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = class RealTimeCollection extends Backbone.Firebase.Collection - constructor: (savePath) -> - # TODO: Don't hard code this here - # TODO: Use prod path in prod - @firebase = 'https://codecombat.firebaseio.com/test/db/' + savePath - super() diff --git a/app/core/ParticleMan.coffee b/app/core/ParticleMan.coffee index bcaebd72b..41be7829c 100644 --- a/app/core/ParticleMan.coffee +++ b/app/core/ParticleMan.coffee @@ -245,6 +245,12 @@ particleKinds['level-dungeon-game-dev'] = particleKinds['level-dungeon-game-dev- colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 +particleKinds['level-dungeon-web-dev'] = particleKinds['level-dungeon-web-dev-premium'] = ext particleKinds['level-dungeon-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 + particleKinds['level-dungeon-premium-item'] = ext particleKinds['level-dungeon-gate'], emitter: particleCount: 2000 @@ -300,6 +306,12 @@ particleKinds['level-forest-game-dev'] = particleKinds['level-forest-game-dev-pr colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 +particleKinds['level-forest-web-dev'] = particleKinds['level-forest-web-dev-premium'] = ext particleKinds['level-forest-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 + particleKinds['level-forest-premium-item'] = ext particleKinds['level-forest-gate'], emitter: particleCount: 2000 @@ -355,6 +367,12 @@ particleKinds['level-desert-game-dev'] = particleKinds['level-desert-game-dev-pr colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 +particleKinds['level-desert-web-dev'] = particleKinds['level-desert-web-dev-premium'] = ext particleKinds['level-desert-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 + particleKinds['level-mountain-premium-hero'] = ext particleKinds['level-mountain-premium'], emitter: particleCount: 200 @@ -395,6 +413,12 @@ particleKinds['level-mountain-game-dev'] = particleKinds['level-mountain-game-de colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 +particleKinds['level-mountain-web-dev'] = particleKinds['level-mountain-web-dev-premium'] = ext particleKinds['level-mountain-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 + particleKinds['level-glacier-premium-hero'] = ext particleKinds['level-glacier-premium'], emitter: particleCount: 200 @@ -435,6 +459,12 @@ particleKinds['level-glacier-game-dev'] = particleKinds['level-glacier-game-dev- colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 +particleKinds['level-glacier-web-dev'] = particleKinds['level-glacier-web-dev-premium'] = ext particleKinds['level-glacier-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 + particleKinds['level-volcano-premium-hero'] = ext particleKinds['level-volcano-premium'], emitter: particleCount: 200 @@ -474,3 +504,9 @@ particleKinds['level-volcano-game-dev'] = particleKinds['level-volcano-game-dev- colorStart: hsl 0.7, 0.75, 0.7 colorMiddle: hsl 0.7, 0.75, 0.5 colorEnd: hsl 0.7, 0.75, 0.3 + +particleKinds['level-volcano-web-dev'] = particleKinds['level-volcano-web-dev-premium'] = ext particleKinds['level-volcano-hero-ladder'], + emitter: + colorStart: hsl 0.7, 0.25, 0.7 + colorMiddle: hsl 0.7, 0.25, 0.5 + colorEnd: hsl 0.7, 0.25, 0.3 diff --git a/app/core/Router.coffee b/app/core/Router.coffee index eeb42e2c2..b80e7f671 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -126,13 +126,13 @@ module.exports = class CocoRouter extends Backbone.Router 'legal': go('LegalView') - 'multiplayer': go('MultiplayerView') - 'play(/)': go('play/CampaignView') # extra slash is to get Facebook app to work 'play/ladder/:levelID/:leagueType/:leagueID': go('ladder/LadderView') 'play/ladder/:levelID': go('ladder/LadderView') 'play/ladder': go('ladder/MainLadderView') 'play/level/:levelID': go('play/level/PlayLevelView') + 'play/game-dev-level/:levelID/:sessionID': go('play/level/PlayGameDevLevelView') + 'play/web-dev-level/:levelID/:sessionID': go('play/level/PlayWebDevLevelView') 'play/spectate/:levelID': go('play/SpectateView') 'play/:map': go('play/CampaignView') @@ -193,7 +193,7 @@ module.exports = class CocoRouter extends Backbone.Router @listenToOnce application.moduleLoader, 'load-complete', -> @routeDirectly(path, args, options) return - return @openView @notFoundView() if not ViewClass + return go('NotFoundView') if not ViewClass view = new ViewClass(options, args...) # options, then any path fragment args view.render() @openView(view) diff --git a/app/core/initialize.coffee b/app/core/initialize.coffee index 7acbc0cdc..b81841a6b 100644 --- a/app/core/initialize.coffee +++ b/app/core/initialize.coffee @@ -8,7 +8,6 @@ channelSchemas = 'errors': require 'schemas/subscriptions/errors' 'ipad': require 'schemas/subscriptions/ipad' 'misc': require 'schemas/subscriptions/misc' - 'multiplayer': require 'schemas/subscriptions/multiplayer' 'play': require 'schemas/subscriptions/play' 'surface': require 'schemas/subscriptions/surface' 'tome': require 'schemas/subscriptions/tome' @@ -165,5 +164,5 @@ window.onbeforeunload = (e) -> return leavingMessage else return - + $ -> init() diff --git a/app/core/treema-ext.coffee b/app/core/treema-ext.coffee index f64f6f3cb..685ccb1fd 100644 --- a/app/core/treema-ext.coffee +++ b/app/core/treema-ext.coffee @@ -2,6 +2,7 @@ CocoModel = require 'models/CocoModel' CocoCollection = require 'collections/CocoCollection' {me} = require('core/auth') locale = require 'locale/locale' +utils = require 'core/utils' initializeFilePicker = -> require('core/services/filepicker')() unless window.application.isIPadApp @@ -234,21 +235,14 @@ class ImageFileTreema extends TreemaNode.nodeMap.string @refreshDisplay() -codeLanguages = - javascript: 'ace/mode/javascript' - coffeescript: 'ace/mode/coffee' - python: 'ace/mode/python' - lua: 'ace/mode/lua' - java: 'ace/mode/java' - class CodeLanguagesObjectTreema extends TreemaNode.nodeMap.object childPropertiesAvailable: -> - (key for key in _.keys(codeLanguages) when not @data[key]? and not (key is 'javascript' and @workingSchema.skipJavaScript)) + (key for key in _.keys(utils.aceEditModes) when not @data[key]? and not (key is 'javascript' and @workingSchema.skipJavaScript)) class CodeLanguageTreema extends TreemaNode.nodeMap.string buildValueForEditing: (valEl, data) -> super(valEl, data) - valEl.find('input').autocomplete(source: _.keys(codeLanguages), minLength: 0, delay: 0, autoFocus: true) + valEl.find('input').autocomplete(source: _.keys(utils.aceEditModes), minLength: 0, delay: 0, autoFocus: true) valEl class CodeTreema extends TreemaNode.nodeMap.ace @@ -256,8 +250,8 @@ class CodeTreema extends TreemaNode.nodeMap.ace super(arguments...) @workingSchema.aceTabSize = 4 # TODO: Find a less hacky solution for this - @workingSchema.aceMode = mode if mode = codeLanguages[@keyForParent] - @workingSchema.aceMode = mode if mode = codeLanguages[@parent?.data?.language] + @workingSchema.aceMode = mode if mode = utils.aceEditModes[@keyForParent] + @workingSchema.aceMode = mode if mode = utils.aceEditModes[@parent?.data?.language] class CoffeeTreema extends CodeTreema constructor: -> diff --git a/app/core/utils.coffee b/app/core/utils.coffee index 07e90f4f5..166db035e 100644 --- a/app/core/utils.coffee +++ b/app/core/utils.coffee @@ -259,7 +259,7 @@ startsWithVowel = (s) -> s[0] in 'aeiouAEIOU' module.exports.filterMarkdownCodeLanguages = (text, language) -> return '' unless text currentLanguage = language or me.get('aceConfig')?.language or 'python' - excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'java', 'io'], currentLanguage + excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'java', 'io', 'html'], currentLanguage # Exclude language-specific code blocks like ```python (... code ...)``` for each non-target language. codeBlockExclusionRegex = new RegExp "```(#{excludedLanguages.join('|')})\n[^`]+```\n?", 'gm' # Exclude language-specific images like ![python - image description](image url) for each non-target language. @@ -290,13 +290,15 @@ module.exports.filterMarkdownCodeLanguages = (text, language) -> return text module.exports.aceEditModes = aceEditModes = - 'javascript': 'ace/mode/javascript' - 'coffeescript': 'ace/mode/coffee' - 'python': 'ace/mode/python' - 'java': 'ace/mode/java' - 'lua': 'ace/mode/lua' - 'java': 'ace/mode/java' + javascript: 'ace/mode/javascript' + coffeescript: 'ace/mode/coffee' + python: 'ace/mode/python' + lua: 'ace/mode/lua' + java: 'ace/mode/java' + html: 'ace/mode/html' +# These ACEs are used for displaying code snippets statically, like in SpellPaletteEntryView popovers +# and have short lifespans module.exports.initializeACE = (el, codeLanguage) -> contents = $(el).text().trim() editor = ace.edit el diff --git a/app/lib/Bus.coffee b/app/lib/Bus.coffee index ffe508754..2180dae4b 100644 --- a/app/lib/Bus.coffee +++ b/app/lib/Bus.coffee @@ -22,6 +22,7 @@ module.exports = Bus = class Bus extends CocoClass 'auth:me-synced': 'onMeSynced' connect: -> + # Put Firebase back in bower if you want to use this Backbone.Mediator.publish 'bus:connecting', {bus: @} Firebase.goOnline() @fireRef = new Firebase(Bus.fireHost + '/' + @docName) diff --git a/app/lib/God.coffee b/app/lib/God.coffee index 1371958f7..60c15c5b6 100644 --- a/app/lib/God.coffee +++ b/app/lib/God.coffee @@ -94,9 +94,9 @@ module.exports = class God extends CocoClass return if hadPreloader @angelsShare.workQueue = [] - work = + work = { userCodeMap: userCodeMap - level: @level + @level levelSessionIDs: @levelSessionIDs submissionCount: @lastSubmissionCount fixedSeed: @lastFixedSeed @@ -104,9 +104,10 @@ module.exports = class God extends CocoClass difficulty: @lastDifficulty goals: @angelsShare.goalManager?.getGoals() headless: @angelsShare.headless - preload: preload + preload synchronous: not Worker? # Profiling world simulation is easier on main thread, or we are IE9. - realTime: realTime + realTime + } @angelsShare.workQueue.push work angel.workIfIdle() for angel in @angelsShare.angels work @@ -114,9 +115,7 @@ module.exports = class God extends CocoClass getUserCodeMap: (spells) -> userCodeMap = {} for spellKey, spell of spells - for thangID, spellThang of spell.thangs - continue if spellThang.thang?.programmableMethods[spell.name].cloneOf - (userCodeMap[thangID] ?= {})[spell.name] = spellThang.aether.serialize() + (userCodeMap[spell.thang.thang.id] ?= {})[spell.name] = spell.thang.aether.serialize() userCodeMap diff --git a/app/lib/LevelBus.coffee b/app/lib/LevelBus.coffee index 741420cca..aab23bf16 100644 --- a/app/lib/LevelBus.coffee +++ b/app/lib/LevelBus.coffee @@ -41,7 +41,6 @@ module.exports = class LevelBus extends Bus @fireScriptsRef = @fireRef?.child('scripts') setSession: (@session) -> - @listenTo(@session, 'change:multiplayer', @onMultiplayerChanged) @timerIntervalID = setInterval(@incrementSessionPlaytime, 1000) onIdleChanged: (e) -> @@ -53,8 +52,7 @@ module.exports = class LevelBus extends Bus @session.set('playtime', (@session.get('playtime') ? 0) + 1) onPoint: -> - return true unless @session?.get('multiplayer') - super() + return true onMeSynced: => super() @@ -236,17 +234,11 @@ module.exports = class LevelBus extends Bus @changedSessionProperties.chat = true @saveSession() - onMultiplayerChanged: -> - @changedSessionProperties.multiplayer = true - @session.updatePermissions() - @changedSessionProperties.permissions = true - @saveSession() - # Debounced as saveSession reallySaveSession: -> return if _.isEmpty @changedSessionProperties # don't let peeking admins mess with the session accidentally - return unless @session.get('multiplayer') or @session.get('creator') is me.id + return unless @session.get('creator') is me.id return if @session.fake Backbone.Mediator.publish 'level:session-will-save', session: @session patch = {} diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee index 24feca40f..3b2712f13 100644 --- a/app/lib/LevelLoader.coffee +++ b/app/lib/LevelLoader.coffee @@ -53,6 +53,16 @@ module.exports = class LevelLoader extends CocoClass # Supermodel (Level) Loading + loadWorldNecessities: -> + # TODO: Actually trigger loading, instead of in the constructor + new Promise((resolve, reject) => + return resolve(@) if @world + @once 'world-necessities-loaded', => resolve(@) + @once 'world-necessity-load-failed', ({resource}) -> + { jqxhr } = resource + reject({message: jqxhr.responseJSON?.message or jqxhr.responseText or 'Unknown Error'}) + ) + loadLevel: -> @level = @supermodel.getModel(Level, @levelID) or new Level _id: @levelID if @level.loaded @@ -62,9 +72,18 @@ module.exports = class LevelLoader extends CocoClass @listenToOnce @level, 'sync', @onLevelLoaded onLevelLoaded: -> - if not @sessionless and @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course'] + if not @sessionless and @level.isType('hero', 'hero-ladder', 'hero-coop', 'course') @sessionDependenciesRegistered = {} - if (@courseID and @level.get('type', true) not in ['course', 'course-ladder']) or window.serverConfig.picoCTF + if @level.isType('web-dev') + @headless = true + if @sessionless + # When loading a web-dev level in the level editor, pretend it's a normal hero level so we can put down our placeholder Thang. + # TODO: avoid this whole roundabout Thang-based way of doing web-dev levels + originalGet = @level.get + @level.get = -> + return 'hero' if arguments[0] is 'type' + originalGet.apply @, arguments + if (@courseID and not @level.isType('course', 'course-ladder', 'game-dev', 'web-dev')) 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 = -> @@ -169,7 +188,7 @@ module.exports = class LevelLoader extends CocoClass @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 + if @level.isType('course') # course-ladder is hard to handle because there's 2 sessions heroThangType = me.get('heroConfig')?.thangType or ThangType.heroes.captain console.log "Course mode, loading custom hero: ", heroThangType if LOG url = "/db/thang.type/#{heroThangType}/version" @@ -178,7 +197,7 @@ module.exports = class LevelLoader extends CocoClass @worldNecessities.push heroResource @sessionDependenciesRegistered[session.id] = true return - return unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] + return unless @level.isType('hero', 'hero-ladder', 'hero-coop') heroConfig = session.get('heroConfig') heroConfig ?= me.get('heroConfig') if session is @session and not @headless heroConfig ?= {} @@ -332,8 +351,8 @@ module.exports = class LevelLoader extends CocoClass @worldNecessities = (r for r in @worldNecessities when r?) @onWorldNecessitiesLoaded() if @checkAllWorldNecessitiesRegisteredAndLoaded() - onWorldNecessityLoadFailed: (resource) -> - @trigger('world-necessity-load-failed', resource: resource) + onWorldNecessityLoadFailed: (event) -> + @trigger('world-necessity-load-failed', event) checkAllWorldNecessitiesRegisteredAndLoaded: -> return false unless _.filter(@worldNecessities).length is 0 @@ -401,7 +420,8 @@ module.exports = class LevelLoader extends CocoClass resource.markLoaded() if resource.spriteSheetKeys.length is 0 denormalizeSession: -> - return if @headless or @sessionDenormalized or @spectateMode or @sessionless or me.isSessionless() + return if @sessionDenormalized or @spectateMode or @sessionless or me.isSessionless() + return if @headless and not @level.isType('web-dev') # This is a way (the way?) PUT /db/level.sessions/undefined was happening # See commit c242317d9 return if not @session.id @@ -443,7 +463,7 @@ module.exports = class LevelLoader extends CocoClass @grabTeamConfigs() @thangTypeTeams = {} for thang in @level.get('thangs') - if @level.get('type', true) in ['hero', 'course'] and thang.id is 'Hero Placeholder' + if @level.isType('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 @@ -471,6 +491,7 @@ module.exports = class LevelLoader extends CocoClass initWorld: -> return if @initialized @initialized = true + return if @level.isType('web-dev') @world = new World() @world.levelSessionIDs = if @opponentSessionID then [@sessionID, @opponentSessionID] else [@sessionID] @world.submissionCount = @session?.get('state')?.submissionCount ? 0 diff --git a/app/lib/LevelSetupManager.coffee b/app/lib/LevelSetupManager.coffee index 78d0c7205..840967c12 100644 --- a/app/lib/LevelSetupManager.coffee +++ b/app/lib/LevelSetupManager.coffee @@ -74,7 +74,7 @@ module.exports = class LevelSetupManager extends CocoClass @session.set 'heroConfig', {"thangType":raider,"inventory":{}} @onInventoryModalPlayClicked() return - if @level.get('type', true) in ['course', 'course-ladder'] or window.serverConfig.picoCTF + if @level.isType('course', 'course-ladder', 'game-dev', 'web-dev') or window.serverConfig.picoCTF @onInventoryModalPlayClicked() return @heroesModal = new PlayHeroesModal({supermodel: @supermodel, session: @session, confirmButtonI18N: 'play.next', level: @level, hadEverChosenHero: @options.hadEverChosenHero}) diff --git a/app/lib/coursesHelper.coffee b/app/lib/coursesHelper.coffee index ad68f1fee..a6186ada5 100644 --- a/app/lib/coursesHelper.coffee +++ b/app/lib/coursesHelper.coffee @@ -195,6 +195,17 @@ module.exports = _.assign(progressData, progressMixin) return progressData + courseLabelsArray: (courses) -> + labels = [] + courseLabelIndexes = CS: 0, GD: 0, WD: 0 + for course in courses + acronym = switch + when /game-dev/.test(course.get('slug')) then 'GD' + when /web-dev/.test(course.get('slug')) then 'WD' + else 'CS' + labels.push acronym + ++courseLabelIndexes[acronym] + labels + progressMixin = get: (options={}) -> { classroom, course, level, user } = options diff --git a/app/lib/surface/Surface.coffee b/app/lib/surface/Surface.coffee index 01980a2d8..4f63e1ce3 100644 --- a/app/lib/surface/Surface.coffee +++ b/app/lib/surface/Surface.coffee @@ -10,7 +10,6 @@ Letterbox = require './Letterbox' Dimmer = require './Dimmer' CountdownScreen = require './CountdownScreen' PlaybackOverScreen = require './PlaybackOverScreen' -WaitingScreen = require './WaitingScreen' DebugDisplay = require './DebugDisplay' CoordinateDisplay = require './CoordinateDisplay' CoordinateGrid = require './CoordinateGrid' @@ -70,7 +69,6 @@ module.exports = Surface = class Surface extends CocoClass 'level:set-letterbox': 'onSetLetterbox' 'application:idle-changed': 'onIdleChanged' 'camera:zoom-updated': 'onZoomUpdated' - 'playback:real-time-playback-waiting': 'onRealTimePlaybackWaiting' 'playback:real-time-playback-started': 'onRealTimePlaybackStarted' 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' 'level:flag-color-selected': 'onFlagColorSelected' @@ -135,7 +133,6 @@ module.exports = Surface = class Surface extends CocoClass @countdownScreen = new CountdownScreen camera: @camera, layer: @screenLayer, showsCountdown: @world.showsCountdown @playbackOverScreen = new PlaybackOverScreen camera: @camera, layer: @screenLayer, playerNames: @options.playerNames @normalStage.addChildAt @playbackOverScreen.dimLayer, 0 # Put this below the other layers, actually, so we can more easily read text on the screen. - @waitingScreen = new WaitingScreen camera: @camera, layer: @screenLayer @initCoordinates() @webGLStage.enableMouseOver(10) @webGLStage.addEventListener 'stagemousemove', @onMouseMove @@ -570,7 +567,7 @@ module.exports = Surface = class Surface extends CocoClass scaleFactor = 1 if @options.stayVisible availableHeight = window.innerHeight - availableHeight -= $('.ad-container').outerHeight() + availableHeight -= $('.ad-container').outerHeight() availableHeight -= $('#game-area').outerHeight() - $('#canvas-wrapper').outerHeight() scaleFactor = availableHeight / newHeight if availableHeight < newHeight newWidth *= scaleFactor @@ -602,9 +599,6 @@ module.exports = Surface = class Surface extends CocoClass #- Real-time playback - onRealTimePlaybackWaiting: (e) -> - @onRealTimePlaybackStarted e - onRealTimePlaybackStarted: (e) -> return if @realTime @realTimeInputEvents.reset() @@ -741,7 +735,6 @@ module.exports = Surface = class Surface extends CocoClass @dimmer?.destroy() @countdownScreen?.destroy() @playbackOverScreen?.destroy() - @waitingScreen?.destroy() @coordinateDisplay?.destroy() @coordinateGrid?.destroy() @normalStage.clear() diff --git a/app/lib/surface/WaitingScreen.coffee b/app/lib/surface/WaitingScreen.coffee deleted file mode 100644 index 232ddb5f8..000000000 --- a/app/lib/surface/WaitingScreen.coffee +++ /dev/null @@ -1,65 +0,0 @@ -CocoClass = require 'core/CocoClass' -RealTimeCollection = require 'collections/RealTimeCollection' - -module.exports = class WaitingScreen extends CocoClass - subscriptions: - 'playback:real-time-playback-waiting': 'onRealTimePlaybackWaiting' - 'playback:real-time-playback-started': 'onRealTimePlaybackStarted' - 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' - 'real-time-multiplayer:player-status': 'onRealTimeMultiplayerPlayerStatus' - - constructor: (options) -> - super() - options ?= {} - @camera = options.camera - @layer = options.layer - @waitingText = options.text or 'Waiting...' - console.error @toString(), 'needs a camera.' unless @camera - console.error @toString(), 'needs a layer.' unless @layer - @build() - - onCastingBegins: (e) -> @show() unless e.preload - onCastingEnds: (e) -> @hide() - - toString: -> '' - - build: -> - @dimLayer = new createjs.Container() - @dimLayer.mouseEnabled = @dimLayer.mouseChildren = false - @dimLayer.addChild @dimScreen = new createjs.Shape() - @dimScreen.graphics.beginFill('rgba(0,0,0,0.5)').rect 0, 0, @camera.canvasWidth, @camera.canvasHeight - @dimLayer.alpha = 0 - @dimLayer.addChild @makeWaitingText() - - makeWaitingText: -> - size = Math.ceil @camera.canvasHeight / 8 - text = new createjs.Text @waitingText, "#{size}px Open Sans Condensed", '#F7B42C' - text.shadow = new createjs.Shadow '#000', Math.ceil(@camera.canvasHeight / 300), Math.ceil(@camera.canvasHeight / 300), Math.ceil(@camera.canvasHeight / 120) - text.textAlign = 'center' - text.textBaseline = 'middle' - text.x = @camera.canvasWidth / 2 - text.y = @camera.canvasHeight / 2 - @text = text - return text - - show: -> - return if @showing - @showing = true - @dimLayer.alpha = 0 - createjs.Tween.removeTweens @dimLayer - createjs.Tween.get(@dimLayer).to({alpha: 1}, 500) - @layer.addChild @dimLayer - - hide: -> - return unless @showing - @showing = false - createjs.Tween.removeTweens @dimLayer - createjs.Tween.get(@dimLayer).to({alpha: 0}, 500).call => @layer.removeChild @dimLayer unless @destroyed - - onRealTimeMultiplayerPlayerStatus: (e) -> @text.text = e.status - - onRealTimePlaybackWaiting: (e) -> @show() - - onRealTimePlaybackStarted: (e) -> @hide() - - onRealTimePlaybackEnded: (e) -> @hide() diff --git a/app/lib/world/GoalManager.coffee b/app/lib/world/GoalManager.coffee index 378068507..c701646b5 100644 --- a/app/lib/world/GoalManager.coffee +++ b/app/lib/world/GoalManager.coffee @@ -38,6 +38,7 @@ module.exports = class GoalManager extends CocoClass subscriptions: 'god:new-world-created': 'onNewWorldCreated' + 'god:new-html-goal-states': 'onNewHTMLGoalStates' 'level:restarted': 'onLevelRestarted' backgroundSubscriptions: @@ -86,6 +87,9 @@ module.exports = class GoalManager extends CocoClass @world = e.world @updateGoalStates(e.goalStates) if e.goalStates? + onNewHTMLGoalStates: (e) -> + @updateGoalStates(e.goalStates) if e.goalStates? + updateGoalStates: (newGoalStates) -> for goalID, goalState of newGoalStates continue unless @goalStates[goalID]? @@ -114,7 +118,7 @@ module.exports = class GoalManager extends CocoClass goalStates: @goalStates goals: @goals overallStatus: overallStatus - timedOut: @world.totalFrames is @world.maxTotalFrames and overallStatus not in ['success', 'failure'] + timedOut: @world? and (@world.totalFrames is @world.maxTotalFrames and overallStatus not in ['success', 'failure']) Backbone.Mediator.publish('goal-manager:new-goal-states', event) checkOverallStatus: (ignoreIncomplete=false) -> @@ -264,7 +268,7 @@ module.exports = class GoalManager extends CocoClass mostEagerGoal = _.min matchedGoals, 'worldEndsAfter' victory = overallStatus is 'success' tentative = overallStatus is 'success' - @world.endWorld victory, mostEagerGoal.worldEndsAfter, tentative if mostEagerGoal isnt Infinity + @world?.endWorld victory, mostEagerGoal.worldEndsAfter, tentative if mostEagerGoal isnt Infinity updateGoalState: (goalID, thangID, progressObjectName, frameNumber) -> # A thang has done something related to the goal! @@ -291,7 +295,7 @@ module.exports = class GoalManager extends CocoClass mostEagerGoal = _.min matchedGoals, 'worldEndsAfter' victory = overallStatus is 'success' tentative = overallStatus is 'success' - @world.endWorld victory, mostEagerGoal.worldEndsAfter, tentative if mostEagerGoal isnt Infinity + @world?.endWorld victory, mostEagerGoal.worldEndsAfter, tentative if mostEagerGoal isnt Infinity goalIsPositive: (goalID) -> # Positive goals are completed when all conditions are true (kill all these thangs) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index edd918388..29ce12d0e 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -318,7 +318,7 @@ write_this_down: "Write this down:" start_playing: "Start Playing!" sso_connected: "Successfully connected with:" - + recover: recover_account_title: "Recover Account" send_password: "Send Recovery Password" @@ -450,8 +450,6 @@ incomplete: "Incomplete" timed_out: "Ran out of time" failing: "Failing" - control_bar_multiplayer: "Multiplayer" - control_bar_join_game: "Join Game" reload: "Reload" reload_title: "Reload All Code?" reload_really: "Are you sure you want to reload this level back to the beginning?" @@ -480,10 +478,7 @@ tome_cast_button_running: "Running" tome_cast_button_ran: "Ran" tome_submit_button: "Submit" - tome_reload_method: "Reload original code for this method" # Title text for individual method reload button. - tome_select_method: "Select a Method" - tome_see_all_methods: "See all methods you can edit" # Title text for method list selector (shown when there are multiple programmable methods). - tome_select_a_thang: "Select Someone for " + tome_reload_method: "Reload original code to restart the level" # {change} tome_available_spells: "Available Spells" tome_your_skills: "Your Skills" tome_current_method: "Current Method" @@ -781,7 +776,7 @@ mission_description_1: "Programming is magic. It's the ability to create things from pure imagination. We started CodeCombat to give learners the feeling of wizardly power at their fingertips by using typed code." mission_description_2: "As it turns out, that enables them to learn faster too. WAY faster. It's like having a conversation instead of reading a manual. We want to bring that conversation to every school and to every student, because everyone should have the chance to learn the magic of programming." team_title: "Meet the CodeCombat team" - team_values: "We value open and respectful dialog, where the best idea wins. Our decisions are grounded in customer research and our process is focused on delivering tangible results for them. Everyone is hands-on, from our CEO to our Github contributors, because we value growth and learning in our team." + team_values: "We value open and respectful dialog, where the best idea wins. Our decisions are grounded in customer research and our process is focused on delivering tangible results for them. Everyone is hands-on, from our CEO to our GitHub contributors, because we value growth and learning in our team." nick_title: "Cofounder, CEO" nick_blurb: "Motivation Guru" matt_title: "Cofounder, CTO" @@ -802,6 +797,7 @@ phoenix_title: "Software Engineer" nolan_title: "Territory Manager" elliot_title: "Partnership Manager" + lisa_title: "Market Development Rep" retrostyle_title: "Illustration" retrostyle_blurb: "RetroStyle Games" jose_title: "Music" @@ -1477,6 +1473,29 @@ status_not_enrolled: "Not Enrolled" status_enrolled: "Expires on {{date}}" select_all: "Select All" + projects: "Projects" + + sharing: + game: "Game" + webpage: "Webpage" + share_game: "Share This Game" + share_web: "Share This Webpage" + victory_share_prefix: "Share this link to invite your friends & family to" + victory_share_game: "play your game level" + victory_share_web: "view your webpage" + victory_share_suffix: "." + victory_course_share_prefix: "This link will let your friends & family" + victory_course_share_game: "play the game" + victory_course_share_web: "view the webpage" + victory_course_share_suffix: "you just created." + copy_url: "Copy URL" + + game_dev: + creator: "Creator" + + web_dev: + image_gallery_title: "Image Gallery" + image_gallery_description: "Copy these images into your webpage, or find your own image URLs online." classes: archmage_title: "Archmage" @@ -1879,6 +1898,17 @@ vectors: "Vectors" while_loops: "While Loops" recursion: "Recursion" + basic_html: "Basic HTML" # TODO: these web-dev concepts will change, don't need to translate + basic_css: "Basic CSS" + basic_web_scripting: "Basic Web Scripting" + intermediate_html: "Intermediate HTML" + intermediate_css: "Intermediate CSS" + intermediate_web_scripting: "Intermediate Web Scripting" + advanced_html: "Advanced HTML" + advanced_css: "Advanced CSS" + advanced_web_scripting: "Advanced Web Scripting" + jquery: "jQuery" + bootstrap: "Bootstrap" delta: added: "Added" @@ -1890,16 +1920,6 @@ merge_conflict_with: "MERGE CONFLICT WITH" no_changes: "No Changes" - multiplayer: - multiplayer_title: "Multiplayer Settings" # We'll be changing this around significantly soon. Until then, it's not important to translate. - multiplayer_toggle: "Enable multiplayer" - multiplayer_toggle_description: "Allow others to join your game." - multiplayer_link_description: "Give this link to anyone to have them join you." - multiplayer_hint_label: "Hint:" - multiplayer_hint: " Click the link to select all, then press ⌘-C or Ctrl-C to copy the link." - multiplayer_coming_soon: "More multiplayer features to come!" - multiplayer_sign_in_leaderboard: "Sign in or create an account and get your solution on the leaderboard." - legal: page_title: "Legal" opensource_intro: "CodeCombat is completely open source." diff --git a/app/models/Classroom.coffee b/app/models/Classroom.coffee index ef47355b0..eef817bfb 100644 --- a/app/models/Classroom.coffee +++ b/app/models/Classroom.coffee @@ -74,7 +74,7 @@ module.exports = class Classroom extends CocoModel } getLevels: (options={}) -> - # options: courseID, withoutLadderLevels + # options: courseID, withoutLadderLevels, projectLevels Levels = require 'collections/Levels' courses = @get('courses') return new Levels() unless courses @@ -86,6 +86,8 @@ module.exports = class Classroom extends CocoModel levels = new Levels(_.flatten(levelObjects)) if options.withoutLadderLevels levels.remove(levels.filter((level) -> level.isLadder())) + if options.projectLevels + levels.remove(levels.filter((level) -> level.get('shareable') isnt 'project')) return levels getLadderLevel: (courseID) -> diff --git a/app/models/Course.coffee b/app/models/Course.coffee index 7cd6ee20c..86705745e 100644 --- a/app/models/Course.coffee +++ b/app/models/Course.coffee @@ -5,3 +5,10 @@ module.exports = class Course extends CocoModel @className: 'Course' @schema: schema urlRoot: '/db/course' + + fetchForCourseInstance: (courseInstanceID, opts) -> + options = { + url: "/db/course_instance/#{courseInstanceID}/course" + } + _.extend options, opts + @fetch options diff --git a/app/models/Level.coffee b/app/models/Level.coffee index 77c4ff84c..998e40c2c 100644 --- a/app/models/Level.coffee +++ b/app/models/Level.coffee @@ -34,7 +34,7 @@ module.exports = class Level extends CocoModel for tt in supermodel.getModels ThangType if tmap[tt.get('original')] or (tt.get('kind') isnt 'Hero' and tt.get('kind')? and tt.get('components') and not tt.notInLevel) or - (tt.get('kind') is 'Hero' and ((@get('type', true) in ['course', 'course-ladder']) or tt.get('original') in sessionHeroes)) + (tt.get('kind') is 'Hero' and (@isType('course', 'course-ladder', 'game-dev') or tt.get('original') in sessionHeroes)) o.thangTypes.push (original: tt.get('original'), name: tt.get('name'), components: $.extend(true, [], tt.get('components'))) @sortThangComponents o.thangTypes, o.levelComponents, 'ThangType' @fillInDefaultComponentConfiguration o.thangTypes, o.levelComponents @@ -59,7 +59,7 @@ module.exports = class Level extends CocoModel denormalize: (supermodel, session, otherSession) -> o = $.extend true, {}, @attributes - if o.thangs and @get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + if o.thangs and @isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') thangTypesWithComponents = (tt for tt in supermodel.getModels(ThangType) when tt.get('components')?) thangTypesByOriginal = _.indexBy thangTypesWithComponents, (tt) -> tt.get('original') # Optimization for levelThang in o.thangs @@ -68,7 +68,7 @@ module.exports = class Level extends CocoModel denormalizeThang: (levelThang, supermodel, session, otherSession, thangTypesByOriginal) -> levelThang.components ?= [] - isHero = /Hero Placeholder/.test(levelThang.id) and @get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] + isHero = /Hero Placeholder/.test(levelThang.id) and @isType('hero', 'hero-ladder', 'hero-coop') if isHero and otherSession # If it's a hero and there's another session, find the right session for it. # If there is no other session (playing against default code, or on single player), clone all placeholders. @@ -147,7 +147,7 @@ module.exports = class Level extends CocoModel levelThang.components.push placeholderComponent # Load the user's chosen hero AFTER getting stats from default char - if /Hero Placeholder/.test(levelThang.id) and @get('type', true) in ['course'] and not @headless and not @sessionless + if /Hero Placeholder/.test(levelThang.id) and @isType('course') and not @headless and not @sessionless heroThangType = me.get('heroConfig')?.thangType or ThangType.heroes.captain levelThang.thangType = heroThangType if heroThangType @@ -263,6 +263,9 @@ module.exports = class Level extends CocoModel isLadder: -> return @get('type')?.indexOf('ladder') > -1 + isType: (types...) -> + return @get('type', true) in types + fetchNextForCourse: ({ levelOriginalID, courseInstanceID, courseID, sessionID }, options={}) -> if courseInstanceID options.url = "/db/course_instance/#{courseInstanceID}/levels/#{levelOriginalID}/sessions/#{sessionID}/next" diff --git a/app/models/LevelSession.coffee b/app/models/LevelSession.coffee index 8b2b73104..9e845fa78 100644 --- a/app/models/LevelSession.coffee +++ b/app/models/LevelSession.coffee @@ -15,8 +15,6 @@ module.exports = class LevelSession extends CocoModel updatePermissions: -> permissions = @get 'permissions', true permissions = (p for p in permissions when p.target isnt 'public') - if @get('multiplayer') - permissions.push {target: 'public', access: 'write'} @set 'permissions', permissions getSourceFor: (spellKey) -> @@ -76,6 +74,7 @@ module.exports = class LevelSession extends CocoModel wait recordScores: (scores, level) -> + return unless scores state = @get 'state' oldTopScores = state.topScores ? [] newTopScores = [] @@ -93,3 +92,17 @@ module.exports = class LevelSession extends CocoModel newTopScores.push oldTopScore state.topScores = newTopScores @set 'state', state + + generateSpellsObject: (options={}) -> + {level} = options + {createAetherOptions} = require 'lib/aether_utils' + aetherOptions = createAetherOptions functionName: 'plan', codeLanguage: @get('codeLanguage'), skipProtectAPI: options.level?.isType('game-dev') + spellThang = thang: {id: 'Hero Placeholder'}, aether: new Aether aetherOptions + spells = "hero-placeholder/plan": thang: spellThang, name: 'plan' + source = @get('code')?['hero-placeholder']?.plan ? '' + try + spellThang.aether.transpile source + catch e + console.log "Couldn't transpile!\n#{source}\n", e + spellThang.aether.transpile '' + spells diff --git a/app/models/RealTimeModel.coffee b/app/models/RealTimeModel.coffee deleted file mode 100644 index 217c72f2e..000000000 --- a/app/models/RealTimeModel.coffee +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = class RealTimeModel extends Backbone.Firebase.Model - constructor: (savePath) -> - # TODO: Don't hard code this here - # TODO: Use prod path in prod - @firebase = 'https://codecombat.firebaseio.com/test/db/' + savePath - super() diff --git a/app/models/SuperModel.coffee b/app/models/SuperModel.coffee index aca0021bd..eb9fc5b82 100644 --- a/app/models/SuperModel.coffee +++ b/app/models/SuperModel.coffee @@ -247,6 +247,15 @@ module.exports = class SuperModel extends Backbone.Model getResource: (rid) -> return @resources[rid] + + # Promises + finishLoading: -> + new Promise (resolve, reject) => + return resolve(@) if @finished() + @once 'failed', ({resource}) -> + jqxhr = resource.jqxhr + reject({message: jqxhr.responseJSON?.message or jqxhr.responseText or 'Unknown Error'}) + @once 'loaded-all', => resolve(@) class Resource extends Backbone.Model constructor: (name, value=1) -> diff --git a/app/schemas/models/campaign.schema.coffee b/app/schemas/models/campaign.schema.coffee index d5e0a1c8a..82b0eb763 100644 --- a/app/schemas/models/campaign.schema.coffee +++ b/app/schemas/models/campaign.schema.coffee @@ -61,12 +61,13 @@ _.extend CampaignSchema.properties, { i18n: { type: 'object', format: 'hidden' } requiresSubscription: { type: 'boolean' } replayable: { type: 'boolean' } - type: {'enum': ['ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']} + type: {'enum': ['ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev']} slug: { type: 'string', format: 'hidden' } original: { type: 'string', format: 'hidden' } adventurer: { type: 'boolean' } practice: { type: 'boolean' } practiceThresholdMinutes: {type: 'number'} + shareable: { title: 'Shareable', type: ['string', 'boolean'], enum: [false, true, 'project'], description: 'Whether the level is not shareable, shareable, or a sharing-encouraged project level.' } adminOnly: { type: 'boolean' } disableSpaces: { type: ['boolean','number'] } hidesSubmitUntilRun: { type: 'boolean' } diff --git a/app/schemas/models/classroom.schema.coffee b/app/schemas/models/classroom.schema.coffee index 9f3e63ca7..e33563b40 100644 --- a/app/schemas/models/classroom.schema.coffee +++ b/app/schemas/models/classroom.schema.coffee @@ -25,7 +25,7 @@ _.extend ClassroomSchema.properties, levels: c.array { title: 'Levels' }, c.object { title: 'Level' }, { practice: {type: 'boolean'} practiceThresholdMinutes: {type: 'number'} - shareable: {type: 'boolean'} + shareable: { title: 'Shareable', type: ['string', 'boolean'], enum: [false, true, 'project'], description: 'Whether the level is not shareable, shareable, or a sharing-encouraged project level.' } type: c.shortString() original: c.objectId() name: {type: 'string'} diff --git a/app/schemas/models/level.coffee b/app/schemas/models/level.coffee index 707bf6cd4..75c31b35f 100644 --- a/app/schemas/models/level.coffee +++ b/app/schemas/models/level.coffee @@ -114,6 +114,9 @@ GoalSchema = c.object {title: 'Goal', description: 'A goal that the player can a targets: c.array {title: 'Targets', description: 'The target items which the Thangs must not collect.', minItems: 1}, thang codeProblems: c.array {title: 'Code Problems', description: 'A list of Thang IDs that should not have any code problems, or team names.', uniqueItems: true, minItems: 1, 'default': ['humans']}, thang linesOfCode: {title: 'Lines of Code', description: 'A mapping of Thang IDs or teams to how many many lines of code should be allowed (well, statements).', type: 'object', default: {humans: 10}, additionalProperties: {type: 'integer', description: 'How many lines to allow for this Thang.'}} + html: c.object {title: 'HTML', description: 'A jQuery selector and what its result should be'}, + selector: {type: 'string', description: 'jQuery selector to run on the user HTML, like "h1:first-child"'} + valueChecks: c.array {title: 'Value checks', description: 'Logical checks on the resulting value for this goal to pass.', format: 'event-prereqs'}, EventPrereqSchema ResponseSchema = c.object {title: 'Dialogue Button', description: 'A button to be shown to the user with the dialogue.', required: ['text']}, text: {title: 'Title', description: 'The text that will be on the button', 'default': 'Okay', type: 'string', maxLength: 30} @@ -313,7 +316,7 @@ _.extend LevelSchema.properties, icon: {type: 'string', format: 'image-file', title: 'Icon'} banner: {type: 'string', format: 'image-file', title: 'Banner'} goals: c.array {title: 'Goals', description: 'An array of goals which are visible to the player and can trigger scripts.'}, GoalSchema - type: c.shortString(title: 'Type', description: 'What kind of level this is.', 'enum': ['campaign', 'ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) + type: c.shortString(title: 'Type', description: 'What kind of level this is.', 'enum': ['campaign', 'ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev']) terrain: c.terrainString showsGuide: c.shortString(title: 'Shows Guide', description: 'If the guide is shown at the beginning of the level.', 'enum': ['first-time', 'always']) requiresSubscription: {title: 'Requires Subscription', description: 'Whether this level is available to subscribers only.', type: 'boolean'} @@ -325,8 +328,8 @@ _.extend LevelSchema.properties, replayable: {type: 'boolean', title: 'Replayable', description: 'Whether this (hero) level infinitely scales up its difficulty and can be beaten over and over for greater rewards.'} buildTime: {type: 'number', description: 'How long it has taken to build this level.'} practice: { type: 'boolean' } - shareable: { type: 'boolean', title: 'Shareable' } practiceThresholdMinutes: {type: 'number', description: 'Players with larger playtimes may be directed to a practice level.'} + shareable: { title: 'Shareable', type: ['string', 'boolean'], enum: [false, true, 'project'], description: 'Whether the level is not shareable, shareable, or a sharing-encouraged project level.' } # Admin flags adventurer: { type: 'boolean' } diff --git a/app/schemas/models/level_session.coffee b/app/schemas/models/level_session.coffee index 95be39816..2c7bbe1fe 100644 --- a/app/schemas/models/level_session.coffee +++ b/app/schemas/models/level_session.coffee @@ -37,8 +37,6 @@ _.extend LevelSessionSchema.properties, type: 'string' levelID: type: 'string' - multiplayer: - type: 'boolean' creator: c.objectId links: [ diff --git a/app/schemas/schemas.coffee b/app/schemas/schemas.coffee index f9be247c2..9df53b58a 100644 --- a/app/schemas/schemas.coffee +++ b/app/schemas/schemas.coffee @@ -261,4 +261,15 @@ me.concept = me.shortString enum: [ 'vectors' 'while_loops' 'recursion' + 'basic_html' + 'basic_css' + 'basic_web_scripting' + 'intermediate_html' + 'intermediate_css' + 'intermediate_web_scripting' + 'advanced_html' + 'advanced_css' + 'advanced_web_scripting' + 'jquery' + 'bootstrap' ] diff --git a/app/schemas/subscriptions/god.coffee b/app/schemas/subscriptions/god.coffee index 1c59ec44f..98628cb19 100644 --- a/app/schemas/subscriptions/god.coffee +++ b/app/schemas/subscriptions/god.coffee @@ -45,6 +45,10 @@ module.exports = 'god:streaming-world-updated': worldUpdatedEventSchema + 'god:new-html-goal-states': c.object {required: ['goalStates', 'overallStatus']}, + goalStates: goalStatesSchema + overallStatus: {type: ['string', 'null'], enum: ['success', 'failure', 'incomplete', null]} + 'god:goals-calculated': c.object {required: ['goalStates', 'god']}, god: {type: 'object'} goalStates: goalStatesSchema diff --git a/app/schemas/subscriptions/multiplayer.coffee b/app/schemas/subscriptions/multiplayer.coffee deleted file mode 100644 index fd88f449c..000000000 --- a/app/schemas/subscriptions/multiplayer.coffee +++ /dev/null @@ -1,21 +0,0 @@ -c = require 'schemas/schemas' - -module.exports = - 'real-time-multiplayer:created-game': c.object {title: 'Multiplayer created game', required: ['realTimeSessionID']}, - realTimeSessionID: {type: 'string'} - - 'real-time-multiplayer:joined-game': c.object {title: 'Multiplayer joined game', required: ['realTimeSessionID']}, - realTimeSessionID: {type: 'string'} - - 'real-time-multiplayer:left-game': c.object {title: 'Multiplayer left game'}, - userID: {type: 'string'} - - 'real-time-multiplayer:manual-cast': c.object {title: 'Multiplayer manual cast'} - - 'real-time-multiplayer:new-opponent-code': c.object {title: 'Multiplayer new opponent code', required: ['code', 'codeLanguage']}, - code: {type: 'object'} - codeLanguage: {type: 'string'} - team: {type: 'string'} - - 'real-time-multiplayer:player-status': c.object {title: 'Multiplayer player status', required: ['status']}, - status: {type: 'string'} diff --git a/app/schemas/subscriptions/play.coffee b/app/schemas/subscriptions/play.coffee index 1c4adc9b4..e03736613 100644 --- a/app/schemas/subscriptions/play.coffee +++ b/app/schemas/subscriptions/play.coffee @@ -96,8 +96,6 @@ module.exports = 'playback:stop-real-time-playback': c.object {} - 'playback:real-time-playback-waiting': c.object {} - 'playback:real-time-playback-started': c.object {} 'playback:real-time-playback-ended': c.object {} diff --git a/app/schemas/subscriptions/tome.coffee b/app/schemas/subscriptions/tome.coffee index 983b746a3..06008d209 100644 --- a/app/schemas/subscriptions/tome.coffee +++ b/app/schemas/subscriptions/tome.coffee @@ -39,8 +39,6 @@ module.exports = variableChain: c.array {}, {type: 'string'} frame: {type: 'integer', minimum: 0} - 'tome:toggle-spell-list': c.object {title: 'Toggle Spell List', description: 'Published when you toggle the dropdown for a thang\'s spells'} - 'tome:reload-code': c.object {title: 'Reload Code', description: 'Published when you reset a spell to its original source', required: []}, spell: {type: 'object'} @@ -91,10 +89,6 @@ module.exports = problems: {type: 'array'} isCast: {type: 'boolean'} - 'tome:spell-shown': c.object {title: 'Spell Shown', description: 'Published when we show a spell', required: ['thang', 'spell']}, - thang: {type: 'object'} - spell: {type: 'object'} - 'tome:change-language': c.object {title: 'Tome Change Language', description: 'Published when the Tome should update its programming language', required: ['language']}, language: {type: 'string'} reload: {type: 'boolean', description: 'Whether player code should reload to the default when the language changes.'} @@ -146,3 +140,7 @@ module.exports = lineOffsetPx: {type: ['number', 'undefined']} 'tome:hide-problem-alert': c.object {title: 'Hide Problem Alert'} 'tome:jiggle-problem-alert': c.object {title: 'Jiggle Problem Alert'} + + 'tome:html-updated': c.object {title: 'HTML Updated', required: ['html', 'create']}, + html: {type: 'string', description: 'The full HTML to display'} + create: {type: 'boolean', description: 'Whether we should (re)create the DOM (as opposed to updating it)'} diff --git a/app/styles/courses/course-details.sass b/app/styles/courses/course-details.sass index b8e76e217..179164b3b 100644 --- a/app/styles/courses/course-details.sass +++ b/app/styles/courses/course-details.sass @@ -35,3 +35,6 @@ h1 font-size: 48px + + .btn-view-project-level + margin-left: 10px; diff --git a/app/styles/courses/teacher-class-view.sass b/app/styles/courses/teacher-class-view.sass index c5d848b38..66f880ce5 100644 --- a/app/styles/courses/teacher-class-view.sass +++ b/app/styles/courses/teacher-class-view.sass @@ -177,7 +177,7 @@ // Course Progress tab - #course-progress-tab + #course-progress-tab, #student-projects-tab .course-overview-row margin-top: 50px border: thin solid gray @@ -221,7 +221,20 @@ .btn margin-top: 6.5px margin-bottom: 6.5px - + + #student-projects-tab + .student-levels-table + margin-top: 0px + + .student-info + margin-top: 5px + + .student-levels-row + padding-top: 10px + padding-bottom: 15px + + .btn-view-project-level + margin-left: 15px // Checkboxes .checkbox-flat diff --git a/app/styles/play/campaign-view.sass b/app/styles/play/campaign-view.sass index 91900ba65..5b2286cdd 100644 --- a/app/styles/play/campaign-view.sass +++ b/app/styles/play/campaign-view.sass @@ -6,10 +6,10 @@ $mapWidth: 2350 $levelDotWidth: 2% $levelDotHeight: $levelDotWidth * $mapWidth / $mapHeight $levelDotZ: $levelDotHeight * 0.25 -$levelDotHoverZ: $levelDotZ * 2 +$levelDotHoverZ: $levelDotZ * 1.5 $levelDotShadowWidth: 0.8 * $levelDotWidth $levelDotShadowHeight: 0.8 * $levelDotHeight -$levelClickRadius: 40px +$levelClickRadius: 20px $gameControlSize: 80px $gameControlMargin: 30px @@ -25,8 +25,10 @@ $gameControlMargin: 30px margin-bottom: -$levelDotHeight / 3 + $levelDotZ #campaign-view - width: 100% - height: 100% + top: 0 + right: 0 + bottom: 0 + left: 0 position: absolute .gradient @@ -105,7 +107,7 @@ $gameControlMargin: 30px .level-difficulty-banner-text position: absolute - bottom: 170% + bottom: 80% pointer-events: none color: rgb(246, 208, 2) text-shadow: 0px 1px 0px black @@ -117,8 +119,7 @@ $gameControlMargin: 30px img.banner position: absolute bottom: 38% - left: -50% - width: 200% + width: 100% pointer-events: none img.star @@ -182,7 +183,7 @@ $gameControlMargin: 30px border-radius: $levelClickRadius .tooltip - z-index: 2 + z-index: 3 pointer-events: none .tooltip-arrow @@ -402,7 +403,7 @@ $gameControlMargin: 30px .player-name margin-left: 45px - + a color: white @@ -616,6 +617,8 @@ $gameControlMargin: 30px .gameplay-container position: absolute + height: 100% + width: 100% body.ipad #campaign-view // iPad only supports up to Kithgard Gates for now. @@ -624,4 +627,3 @@ body.ipad #campaign-view body[lang='ru'] .portals h2 font-size: 26px - diff --git a/app/styles/play/level/control_bar.sass b/app/styles/play/level/control_bar.sass index a31844c8e..48f835534 100644 --- a/app/styles/play/level/control_bar.sass +++ b/app/styles/play/level/control_bar.sass @@ -124,37 +124,6 @@ @include rotate(-15deg) vertical-align: middle - .multiplayer-area-container - position: relative - width: 100% - height: 50px - pointer-events: none - - .multiplayer-area - min-width: 200px - max-width: 293px - height: 60px - margin: 0 auto - padding: 8px - border-style: solid - border-image: url(/images/level/control_bar_level_name_background.png) 30 fill round - border-width: 0 15px 15px 15px - text-align: center - position: absolute - left: 50% - cursor: pointer - pointer-events: all - @include translate(-50%, 0) - - .multiplayer-label - font-size: 12px - color: $control-yellow-highlight - margin-bottom: -5px - - .multiplayer-status - color: white - font-size: 18px - .buttons-area position: absolute right: 35px @@ -210,11 +179,6 @@ html.no-borderimage background: transparent url(/images/level/control_bar_level_name_background.png) background-size: contain background-repeat: no-repeat - #control-bar-view .multiplayer-area - border: 0 - background: transparent url(/images/level/control_bar_level_name_background.png) - background-size: contain - background-repeat: no-repeat body:not(.ipad) diff --git a/app/styles/play/level/loading.sass b/app/styles/play/level/loading.sass index d5d3f4537..39f584321 100644 --- a/app/styles/play/level/loading.sass +++ b/app/styles/play/level/loading.sass @@ -181,3 +181,13 @@ $UNVEIL_TIME: 1.2s left: 48px right: 77px width: auto + + +#level-view.web-dev + #loading-details.preview + @media screen and ( min-height: 900px ) + background: transparent + border: 1px solid transparent + border-width: 124px 76px 64px 40px + border-image: url(/images/level/code_editor_background.png) 124 76 64 40 fill round + padding: 0 35px 0 15px diff --git a/app/styles/play/level/modal/course-victory-modal.sass b/app/styles/play/level/modal/course-victory-modal.sass index 8e624e3c3..a67e783d3 100644 --- a/app/styles/play/level/modal/course-victory-modal.sass +++ b/app/styles/play/level/modal/course-victory-modal.sass @@ -9,6 +9,9 @@ padding-top: 0 width: 750px + @media screen and ( max-height: 625px ) + margin-top: -50px + .modal-content position: relative margin-top: -251px @@ -55,10 +58,14 @@ top: 80px margin-top: 80px + @media screen and ( max-height: 650px ) + padding-top: 10px + .well-parchment margin-top: 20px - + @media screen and ( max-height: 675px ) + margin-top: 0 html.no-borderimage diff --git a/app/styles/play/level/modal/hero-victory-modal.sass b/app/styles/play/level/modal/hero-victory-modal.sass index 692eef2bf..e76f3140d 100644 --- a/app/styles/play/level/modal/hero-victory-modal.sass +++ b/app/styles/play/level/modal/hero-victory-modal.sass @@ -298,6 +298,33 @@ height: 100% position: absolute + #share-level-container + width: 709px + height: 96px + background: transparent url(/images/pages/play/level/modal/share_level_parchment.png) + position: relative + text-align: left + padding: 12px 20px 0 20px + text-align: center + + .share-level-label + color: rgb(103, 92, 76) + text-transform: uppercase + font-weight: bold + font-family: $headings-font-family + font-size: 18px + margin-top: 13px + line-height: 18px + text-align: center + + #share-level-input + font-size: 12px + margin-top: 8px + + #share-level-btn + width: 100% + margin-top: 7px + //- Footer - other stuff diff --git a/app/styles/play/level/modal/progress-view.sass b/app/styles/play/level/modal/progress-view.sass index 61d9f89c3..096d0766e 100644 --- a/app/styles/play/level/modal/progress-view.sass +++ b/app/styles/play/level/modal/progress-view.sass @@ -4,10 +4,18 @@ color: black margin-bottom: 5px - p - margin-top: 30px + .next-level-description + p + margin-top: 30px .course-title white-space: nowrap text-overflow: ellipsis overflow: hidden + + #share-level-input + font-size: 12px + margin-top: 5px + + #share-level-btn + width: 100% diff --git a/app/styles/play/level/play-game-dev-level-view.sass b/app/styles/play/level/play-game-dev-level-view.sass new file mode 100644 index 000000000..c14a38add --- /dev/null +++ b/app/styles/play/level/play-game-dev-level-view.sass @@ -0,0 +1,22 @@ +#play-game-dev-level-view + #canvas-wrapper + width: 100% + position: relative + overflow: hidden + z-index: 0 + + #webgl-surface + background-color: #333 + + #normal-surface + position: absolute + top: 0 + left: 0 + pointer-events: none + + canvas#webgl-surface, canvas#normal-surface + display: block + z-index: 2 + + #play-btn + text-transform: uppercase diff --git a/app/styles/play/level/play-web-dev-level-view.sass b/app/styles/play/level/play-web-dev-level-view.sass new file mode 100644 index 000000000..ddba9ee6e --- /dev/null +++ b/app/styles/play/level/play-web-dev-level-view.sass @@ -0,0 +1,18 @@ +#play-web-dev-level-view + #web-surface-view + position: absolute + top: 0 + right: 0 + bottom: 0 + left: 0 + z-index: 0 + + #info-bar + position: absolute + right: 0 + bottom: 0 + left: 0 + height: 100px + z-index: 1 + background-color: transparent + text-align: center diff --git a/app/styles/play/level/tome/spell-palette-view.sass b/app/styles/play/level/tome/spell-palette-view.sass index 1ba004ff4..89fab6088 100644 --- a/app/styles/play/level/tome/spell-palette-view.sass +++ b/app/styles/play/level/tome/spell-palette-view.sass @@ -113,6 +113,14 @@ width: -webkit-calc(100% - 38px) width: calc(100% - 38px) + &.web-dev.hero .properties + .property-entry-item-group + width: 100px + + .spell-palette-entry-view + margin-left: 0 + width: 100px + @media only screen and (max-width: 1100px) #spell-palette-view // Make sure we have enough room for at least two columns diff --git a/app/styles/play/level/tome/spell_list_entry.sass b/app/styles/play/level/tome/spell-top-bar-view.sass similarity index 66% rename from app/styles/play/level/tome/spell_list_entry.sass rename to app/styles/play/level/tome/spell-top-bar-view.sass index 3f7e3e4df..39207901d 100644 --- a/app/styles/play/level/tome/spell_list_entry.sass +++ b/app/styles/play/level/tome/spell-top-bar-view.sass @@ -1,15 +1,7 @@ @import "app/styles/mixins" @import "app/styles/bootstrap/variables" -.spell-list-entry-view - .method-signature - background-color: transparent - border: 0 - font-size: 1.1em - display: inline-block - padding: 4px - -.spell-list-entry-view.spell-tab +#spell-top-bar-view $height: 87px $paddingTop: 10px $paddingBottom: 25px @@ -46,12 +38,6 @@ > *:not(.spell-tool-buttons) @include opacity(0.5) - .thang-avatar-view - width: $childSize - 10px - margin: 5px 0.4vw - display: inline-block - float: left - .btn.btn-small margin-top: 15px margin-right: 1.3vw @@ -97,46 +83,8 @@ .thang-avatar-wrapper border-width: 0 -.spell-list-entry-view:not(.spell-tab) - cursor: pointer - @include opacity(0.90) - clear: both - padding: 5px - position: relative - - &:hover - @include opacity(1) - background-color: hsla(240, 40, 80, 0.25) - - &.shows-top-divider:not(:first-child) - border-top: 1px dashed #ccc - - .method-signature - margin-top: 5px - - .thang-names - float: right - margin: 8px - font-variant: small-caps - color: darken(#ca8, 50%) - white-space: nowrap - overflow: hidden - text-overflow: ellipsis - font-size: 13px - max-width: 35% - text-align: right - - .thang-avatar-view - width: 40px - float: right - - .thang-avatar-wrapper - margin: 0 5px 0 0 - //margin: 2px 10px 2px 5px - - //html.no-borderimage -// .spell-list-entry-view.spell-tab +// .spell-top-bar-view // border-width: 0 // border-image: none // background: transparent url(/images/level/code_editor_tab_background.png) no-repeat diff --git a/app/styles/play/level/tome/spell_list.sass b/app/styles/play/level/tome/spell_list.sass deleted file mode 100644 index b66cd5f82..000000000 --- a/app/styles/play/level/tome/spell_list.sass +++ /dev/null @@ -1,20 +0,0 @@ -@import "app/styles/mixins" -@import "app/styles/bootstrap/variables" - -#spell-list-view - display: none - position: absolute - z-index: 10 - top: 50px - left: 0% - right: 10% - padding: 4% - border-style: solid - border-image: url(/images/level/popover_border_background.png) 16 12 fill round - border-width: 16px 12px - -html.no-borderimage - #spell-list-view - background: transparent url(/images/level/popover_background.png) - background-size: 100% 100% - border: 0 diff --git a/app/styles/play/level/tome/spell_list_entry_thangs.sass b/app/styles/play/level/tome/spell_list_entry_thangs.sass deleted file mode 100644 index abe03b44a..000000000 --- a/app/styles/play/level/tome/spell_list_entry_thangs.sass +++ /dev/null @@ -1,30 +0,0 @@ -@import "app/styles/mixins" -@import "app/styles/bootstrap/variables" - -.spell-list-entry-view - .spell-list-entry-thangs-view - position: absolute - z-index: 11 - top: 50px - right: -10% - max-width: 70% - max-height: 500px - overflow: scroll - padding: 4% - border-style: solid - border-image: url(/images/level/popover_border_background.png) 16 12 fill round - border-width: 16px 12px - - .thang-avatar-view - cursor: pointer - max-width: 100px - width: 20% - display: inline-block - - -html.no-borderimage - .spell-list-entry-view - .spell-list-entry-thangs-view - background: transparent url(/images/level/popover_background.png) - background-size: 100% 100% - border: 0 diff --git a/app/styles/play/level/tome/spell_palette_entry.sass b/app/styles/play/level/tome/spell_palette_entry.sass index 9c0604229..7a81978aa 100644 --- a/app/styles/play/level/tome/spell_palette_entry.sass +++ b/app/styles/play/level/tome/spell_palette_entry.sass @@ -54,6 +54,7 @@ body:not(.dialogue-view-active) .spell-palette-popover.popover // Only those popovers which are our direct children (spell documentation) + left: auto !important max-width: 600px padding: 0 border-style: solid @@ -81,7 +82,6 @@ body:not(.dialogue-view-active) @include animation(jiggle .3s infinite) &.pinned - left: auto !important top: 50px !important right: 45% // bottom: 151px diff --git a/app/styles/play/level/web-surface-view.sass b/app/styles/play/level/web-surface-view.sass new file mode 100644 index 000000000..e66a99e87 --- /dev/null +++ b/app/styles/play/level/web-surface-view.sass @@ -0,0 +1,6 @@ +#web-surface-view + background-color: white + + iframe + width: 100% + height: 100% diff --git a/app/styles/play/menu/multiplayer-view.sass b/app/styles/play/menu/multiplayer-view.sass deleted file mode 100644 index c1bfbee23..000000000 --- a/app/styles/play/menu/multiplayer-view.sass +++ /dev/null @@ -1,8 +0,0 @@ -#multiplayer-view - textarea - width: 100% - box-sizing: border-box - padding: 5px - text-align: center - height: 30px - font-size: 11px diff --git a/app/styles/play/modal/image-gallery-modal.sass b/app/styles/play/modal/image-gallery-modal.sass new file mode 100644 index 000000000..caf5d0eef --- /dev/null +++ b/app/styles/play/modal/image-gallery-modal.sass @@ -0,0 +1,11 @@ +@import "app/styles/mixins" + +#image-gallery-modal + .modal-dialog + width: 800px + + li + font-size: 12px + + .no-select + @include user-select(none) diff --git a/app/styles/play/play-level-view.sass b/app/styles/play/play-level-view.sass index 8b3c027ae..a0a560ff9 100644 --- a/app/styles/play/play-level-view.sass +++ b/app/styles/play/play-level-view.sass @@ -272,6 +272,29 @@ $level-resize-transition-time: 0.5s right: 45% z-index: 1000000 + &.web-dev + position: absolute + top: 0 + bottom: 0 + left: 0 + right: 0 + + #playback-view, #thang-hud, #level-dialogue-view, #play-footer, #level-footer-background, #level-footer-shadow + display: none + + .game-container, .level-content, #game-area, #canvas-wrapper + height: 100% + + #canvas-wrapper canvas + display: none + + #web-surface-view + position: absolute + top: 0 + right: 0 + left: 0 + bottom: 0 + html.fullscreen-editor #level-view #fullscreen-editor-background-screen diff --git a/app/templates/about.jade b/app/templates/about.jade index b66801346..b7f3d81ac 100644 --- a/app/templates/about.jade +++ b/app/templates/about.jade @@ -143,7 +143,7 @@ block content img(src="/images/pages/about/lisa_small.png").img-thumbnail .team-bio h6.label.team-name Lisa Wu - small Marketing Development Rep + small(data-i18n="about.lisa_title") br // Part time / contract @@ -274,22 +274,8 @@ block content li.small(data-i18n="about.jobs_benefit_7") .col-sm-6.col-md-5.col-md-offset-1.col-lg-4.col-lg-offset-0 .job-listing - h5 Marketing Manager - .text-center - small.label - | San Francisco • Fulltime - p.small We're looking for an amazing marketer to help us fill our funnel and keep the pipeline growing. As our Marketing Manager, you'll be responsible for driving traffic to our website through content creation and converting those visitors into leads and customers using both automated and personalized content. - a.job-link.btn.btn-lg.btn-navy(href="https://jobs.lever.co/codecombat/1033ec13-d4a0-498d-99e0-628afdb56fb5" rel="external") - span(data-i18n="about.learn_more") - .col-sm-6.col-md-5.col-md-offset-1.col-lg-4.col-lg-offset-0 - .job-listing - h5 Sales Representative - .text-center - small.label - | San Francisco • Fulltime - p.small School districts are scrambling to offer computer science classes to all their students as a core subject. They have had no solution, because they can't afford to hire enough programming teachers – until now. - a.job-link.btn.btn-lg.btn-navy(href="https://jobs.lever.co/codecombat/3f6ff123-16ce-4ecb-aba3-dcf4e8927c47" rel="external") - | Learn More + h5 (No Open Roles) + p.small Check back later for updates on new positions at CodeCombat. .col-sm-6.col-md-5.col-lg-4 .job-listing h5(data-i18n="about.jobs_custom_title") diff --git a/app/templates/admin.jade b/app/templates/admin.jade index 0e66c2128..f43601bd6 100644 --- a/app/templates/admin.jade +++ b/app/templates/admin.jade @@ -44,7 +44,7 @@ block content a(href="/admin/classroom-levels") Classroom Levels li button.classroom-progress-csv.btn.btn-sm.btn-success Classroom Progress CSV - input.classroom-progress-class-code(type=text value="") + input.classroom-progress-class-code(type=text placeholder="") li a(href="/admin/analytics") Dashboard li diff --git a/app/templates/courses/course-details.jade b/app/templates/courses/course-details.jade index 6118f447b..2ee0f69b0 100644 --- a/app/templates/courses/course-details.jade +++ b/app/templates/courses/course-details.jade @@ -104,13 +104,23 @@ block content tr td if previousLevelCompleted || view.teacherMode || !passedLastCompletedLevel || levelStatus - - var i18n = level.get('type') === 'course-ladder' ? 'play.compete' : 'home.play'; - button.btn.btn-success.btn-play-level(data-level-slug=level.get('slug'), data-i18n=i18n, data-level-id=level.get('original')) + - var i18nTag = level.isType('course-ladder') ? 'play.compete' : 'home.play'; + button.btn.btn-success.btn-play-level(data-level-slug=level.get('slug'), data-i18n=i18nTag, data-level-id=level.get('original')) + if level.get('shareable') + - var levelOriginal = level.get('original'); + - var session = view.levelSessions.find(function(session) { return session.get('level').original === levelOriginal }); + if session + - var url = '/play/' + level.get('type') + '-level/' + level.get('slug') + '/' + session.id + '?course=' + view.courseID; + a.btn.btn-warning.btn-view-project-level(href=url) + if level.isType('game-dev') + span(data-i18n='sharing.game') + else + span(data-i18n='sharing.webpage') td if view.userLevelStateMap[me.id] div= view.userLevelStateMap[me.id][level.get('original')] td #{level.get('practice') ? 'practice' : 'required'} - td #{levelNumber}. #{level.get('name').replace('Course: ', '')} + td #{levelNumber}. #{i18n(level.attributes, 'name').replace('Course: ', '')} td if view.levelConceptMap[level.get('original')] each concept in view.course.get('concepts') diff --git a/app/templates/courses/teacher-class-view.jade b/app/templates/courses/teacher-class-view.jade index e6e025574..d1581640a 100644 --- a/app/templates/courses/teacher-class-view.jade +++ b/app/templates/courses/teacher-class-view.jade @@ -122,6 +122,10 @@ block content li(class=(activeTab === "#enrollment-status-tab" ? 'active' : '')) a.course-progress-tab-btn(href='#enrollment-status-tab') .small-details.text-center(data-i18n='teacher.enrollment_status') + .tab-spacer + li(class=(activeTab === "#student-projects-tab" ? 'active' : '')) + a.course-progress-tab-btn(href='#student-projects-tab') + .small-details.text-center(data-i18n='teacher.projects') .tab-filler .tab-content @@ -129,8 +133,10 @@ block content +studentsTab else if activeTab === '#course-progress-tab' +courseProgressTab - else + else if activeTab === '#enrollment-status-tab' +enrollmentStatusTab + else + +studentProjectsTab else .text-center.m-t-5.m-b-5 @@ -150,11 +156,10 @@ mixin breadcrumbs mixin longLevelName(data) if data div.level-name - span.spr Course - span= data.courseNumber - span.spr , Level - span= data.levelNumber - span.spr : + span(data-i18n="courses.course") + span= ' ' + data.courseNumber + ', ' + span(data-i18n="play_level.level") + span= ' ' + data.levelNumber + ': ' span= data.levelName else div.level-name(data-i18n='teacher.not_applicable') @@ -223,6 +228,8 @@ mixin studentRow(student) +longLevelName(student.latestCompleteLevel) td if state.get('progressData') + - var courses = view.classroom.get('courses').map(function(c) { return view.courses.get(c._id); }); + - var courseLabelsArray = view.helper.courseLabelsArray(courses); each trimCourse, index in view.classroom.get('courses') - var course = view.courses.get(trimCourse._id); - var instance = view.courseInstances.findWhere({ courseID: course.id, classroomID: classroom.id }) @@ -230,7 +237,8 @@ mixin studentRow(student) - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, user: student }) - var levelsTotal = trimCourse.levels.length //- - var level = ??? - +studentCourseProgressDot(progress, levelsTotal, level, 'CS' + (index+1)) + - var label = courseLabelsArray[index]; + +studentCourseProgressDot(progress, levelsTotal, level, label) unless student.isEnrolled() +enrollStudentButton(student) //- td @@ -305,7 +313,7 @@ mixin courseOverview .course-overview-row .course-title.student-name span= course.get('name') - span : + span= ': ' span(data-i18n='teacher.course_overview') .course-overview-progress each level, index in levels @@ -324,7 +332,7 @@ mixin studentLevelsRow(student) each level, index in levels - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level, user: student }) - var levelNumber = view.classroom.getLevelNumber(level.get('original'), index + 1) - +studentLevelProgressDot(progress, level, levelNumber, session) + +studentLevelProgressDot(progress, level, levelNumber) mixin studentCourseProgressDot(progress, levelsTotal, level, label) //- TODO: Refactor with TeacherClassesView jade @@ -336,7 +344,7 @@ mixin studentCourseProgressDot(progress, levelsTotal, level, label) mixin allStudentsLevelProgressDot(progress, level, levelNumber) - dotClass = progress.completed ? 'forest' : (progress.started ? 'gold' : ''); - - levelName = level.get('name') + - levelName = i18n(level.attributes, 'name') - context = _.merge(progress, { levelName: levelName, levelNumber: levelNumber, numStudents: view.students.length }) .progress-dot.level-progress-dot(class=dotClass, data-html='true', data-title=view.allStudentsLevelProgressDotTemplate(context)) +progressDotLabel(levelNumber) @@ -344,7 +352,7 @@ mixin allStudentsLevelProgressDot(progress, level, levelNumber) mixin studentLevelProgressDot(progress, level, levelNumber) //- TODO: Refactor with TeacherClassesView jade - dotClass = progress.completed ? 'forest' : (progress.started ? 'gold' : ''); - - levelName = level.get('name') + - levelName = i18n(level.attributes, 'name') - context = _.merge(progress, { levelName: levelName, levelNumber: levelNumber, moment: moment }) .progress-dot.level-progress-dot(class=dotClass, data-html='true', data-title=view.singleStudentLevelProgressDotTemplate(context)) +progressDotLabel(levelNumber) @@ -430,3 +438,37 @@ mixin enrollmentStatusTab td.enroll-col if status !== 'enrolled' button.enroll-student-button.btn.btn-navy(data-i18n="teacher.enroll_student", data-user-id=student.id, data-event-action="Teachers Class Enrollment Enroll Student") + +mixin studentProjectsTab + #student-projects-tab.m-t-3 + if state.get('progressData') + .render-on-course-sync + .student-levels-table + +sortButtons + each student in state.get('students').models + +studentProjectsRow(student) + +mixin studentProjectsRow(student) + .row.student-levels-row.alternating-background + div.student-info.col-sm-3 + div.student-name= student.broadName() + div.student-email.small-details= student.get('email') + div.student-levels-progress.col-sm-9 + each trimCourse in view.classroom.get('courses') + - var course = view.courses.get(trimCourse._id); + - var levels = view.classroom.getLevels({courseID: course.id, projectLevels: true}).models + each level in levels + - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level, user: student }) + - var levelNumber = view.classroom.getLevelNumber(level.get('original'), index + 1) + +studentProjectLink(progress, level, levelNumber, course) + +mixin studentProjectLink(progress, level, levelNumber, course) + - var colorClass = progress.completed ? 'btn-primary' : (progress.started ? 'btn-warning' : 'btn-primary'); + - var levelName = i18n(level.attributes, 'name') + - var context = _.merge(progress, { levelName: levelName, levelNumber: levelNumber, moment: moment }) + - var title = view.singleStudentLevelProgressDotTemplate(context); + if context.session + - var url = '/play/' + level.get('type') + '-level/' + level.get('slug') + '/' + context.session.id + '?course=' + course.id; + a(class="btn btn-lg btn-view-project-level " + colorClass, href=url, data-title=title)= levelName + else + btn(class="btn btn-lg btn-view-project-level " + colorClass, data-title=title, disabled=true)= levelName diff --git a/app/templates/courses/teacher-classes-view.jade b/app/templates/courses/teacher-classes-view.jade index 82e11e3b9..ee8839edd 100644 --- a/app/templates/courses/teacher-classes-view.jade +++ b/app/templates/courses/teacher-classes-view.jade @@ -76,10 +76,13 @@ mixin classRow(classroom) if classroom.get('members').length == 0 +addStudentsButton(classroom) else + - var courses = classroom.get('courses').map(function(c) { return view.courses.get(c._id); }); + - var courseLabelsArray = view.helper.courseLabelsArray(courses); each trimCourse, index in classroom.get('courses') || [] - var course = view.courses.get(trimCourse._id); if view.courseInstances.findWhere({ classroomID: classroom.id, courseID: course.id }) - +progressDot(classroom, course, index) + - var label = courseLabelsArray[index]; + +progressDot(classroom, course, label) .view-class-arrow.col-xs-1 a.view-class-arrow-inner.glyphicon.glyphicon-chevron-right.view-class-btn(data-classroom-id=classroom.id data-event-action="Teachers Classes View Class Chevron") @@ -99,8 +102,7 @@ mixin createClassButton a.create-classroom-btn.btn.btn-lg.btn-primary(data-i18n='teacher.create_new_class') | Create a New Class -mixin progressDot(classroom, course, index) - //- TODO: Give classes abbreviations instead of using index? +mixin progressDot(classroom, course, label) //- TODO: inefficient. Cache this in the view? - courseInstance = view.courseInstances.findWhere({ courseID: course.id, classroomID: classroom.id }) - var total = classroom.get('members').length @@ -113,14 +115,11 @@ mixin progressDot(classroom, course, index) - dotClass = complete === total ? 'forest' : started ? 'gold' : ''; - var progressDotContext = {total: total, complete: complete}; .progress-dot(class=dotClass, data-title=view.progressDotTemplate(progressDotContext)) - +progressDotLabel(index) + +progressDotLabel(label) -mixin progressDotLabel(index) +mixin progressDotLabel(label) .dot-label - .text-h6 - | CS - span - = index + 1 + .text-h6= label mixin archivedClassRow(classroom) .class.row diff --git a/app/templates/editor/level/edit.jade b/app/templates/editor/level/edit.jade index e64012f06..207f446c9 100644 --- a/app/templates/editor/level/edit.jade +++ b/app/templates/editor/level/edit.jade @@ -64,7 +64,7 @@ block header a span.glyphicon-floppy-disk.glyphicon - if level.get('type') === 'ladder' + if level.isType('ladder') li.dropdown a(data-toggle='dropdown').play-with-team-parent span.glyphicon-play.glyphicon diff --git a/app/templates/play/ladder/play_modal.jade b/app/templates/play/ladder/play_modal.jade index 10118e10c..ecb48f01a 100644 --- a/app/templates/play/ladder/play_modal.jade +++ b/app/templates/play/ladder/play_modal.jade @@ -5,7 +5,7 @@ block modal-header-content block modal-body-content - if view.level.get('type') != 'course-ladder' + if !view.level.isType('course-ladder') h4.language-selection(data-i18n="ladder.select_your_language") Select your language! .form-group.select-group select#tome-language(name="language") diff --git a/app/templates/play/level/control_bar.jade b/app/templates/play/level/control_bar.jade index 639a389f8..61607bef6 100644 --- a/app/templates/play/level/control_bar.jade +++ b/app/templates/play/level/control_bar.jade @@ -7,24 +7,15 @@ .levels-link-area a.levels-link(href=homeLink || "/") .glyphicon.glyphicon-play - span(data-i18n=me.isSessionless() ? "nav.courses" : (ladderGame ? "general.ladder" : "nav.play")).home-text Levels + span(data-i18n=me.isSessionless() ? "nav.courses" : (ladderGame ? "general.ladder" : "nav.play")).home-text -if isMultiplayerLevel && !observing - .multiplayer-area-container - .multiplayer-area - .multiplayer-label(data-i18n="play_level.control_bar_multiplayer") - if multiplayerStatus - .multiplayer-status= multiplayerStatus - else - .multiplayer-status(data-i18n="play_level.control_bar_join_game") -else - .level-name-area-container - .level-name-area - .level-label(data-i18n="play_level.level") - .level-name(title=difficultyTitle || "") - span #{view.levelNumber ? view.levelNumber + '. ' : ''}#{worldName.replace('Course: ', '')} - if levelDifficulty - sup.level-difficulty= levelDifficulty +.level-name-area-container + .level-name-area + .level-label(data-i18n="play_level.level") + .level-name(title=difficultyTitle || "") + span #{view.levelNumber ? view.levelNumber + '. ' : ''}#{worldName.replace('Course: ', '')} + if levelDifficulty + sup.level-difficulty= levelDifficulty .buttons-area diff --git a/app/templates/play/level/modal/hero-victory-modal.jade b/app/templates/play/level/modal/hero-victory-modal.jade index 540962ddd..c1b651c82 100644 --- a/app/templates/play/level/modal/hero-victory-modal.jade +++ b/app/templates/play/level/modal/hero-victory-modal.jade @@ -48,7 +48,7 @@ block modal-body-content textarea(data-i18n="[placeholder]play_level.victory_review_placeholder") .clearfix - if level.get('type', true) === 'hero' || level.get('type') == 'hero-ladder' + if level.isType('hero', 'hero-ladder', 'game-dev', 'web-dev') for achievement in achievements - var animate = achievement.completed && !achievement.completedAWhileAgo .achievement-panel(class=achievement.completedAWhileAgo ? 'earned' : '' data-achievement-id=achievement.id data-animate=animate) @@ -108,6 +108,24 @@ block modal-footer-content .total-count#gem-total 0 .total-label(data-i18n="play_level.victory_gems_gained") Gems Gained + if view.shareURL + #share-level-container + span.share-level-label + span(data-i18n='sharing.victory_share_prefix') Share this link to invite your friends & family to + span= ' ' + a(href=view.shareURL, target='_blank') + if view.level.isType('game-dev') + span(data-i18n='sharing.victory_share_game') play your game level + else + span(data-i18n='sharing.victory_share_web') view your webpage + span(data-i18n='sharing.victory_share_suffix') . + .row + .col-sm-9 + input.text-h4.semibold.form-control.input-md#share-level-input(value=view.shareURL) + .col-sm-3 + button#share-level-btn.btn.btn-md.btn-success.btn-illustrated + span(data-i18n='sharing.copy_url') Copy URL + if me.get('anonymous') .sign-up-poke.hide .sign-up-blurb(data-i18n="play_level.victory_sign_up_poke") Want to save your code? Create a free account! @@ -118,7 +136,7 @@ block modal-footer-content .next-level-buttons if readyToRank .ladder-submission-view - else if level.get('type') === 'hero-ladder' + else if level.isType('hero-ladder') button.btn.btn-illustrated.btn-primary.btn-lg.return-to-ladder-button(data-href="/play/ladder/#{level.get('slug')}#my-matches", data-dismiss="modal", data-i18n="play_level.victory_return_to_ladder") Return to Ladder else button.btn.btn-illustrated.btn-success.btn-lg.world-map-button.next-level-button.hide#continue-button(data-i18n="common.continue") Continue diff --git a/app/templates/play/level/modal/image-gallery-modal.jade b/app/templates/play/level/modal/image-gallery-modal.jade new file mode 100644 index 000000000..7899ea8c2 --- /dev/null +++ b/app/templates/play/level/modal/image-gallery-modal.jade @@ -0,0 +1,23 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + h3(data-i18n="web_dev.image_gallery_title") + span(data-i18n="web_dev.image_gallery_description") + +block modal-body-content + dl.dl-horizontal + for image in view.images + dt + img(src=image.portraitURL) + dd + ul.list-unstyled + li + span.no-select= 'URL: ' + kbd= image.portraitURL + br + li + span.no-select= ': ' + kbd= '' + +block modal-footer-content + a(href='#', data-dismiss="modal", aria-hidden="true", data-i18n="modal.close").btn.btn-primary Close diff --git a/app/templates/play/level/modal/progress-view.jade b/app/templates/play/level/modal/progress-view.jade index 8c754fb33..35f331c8b 100644 --- a/app/templates/play/level/modal/progress-view.jade +++ b/app/templates/play/level/modal/progress-view.jade @@ -38,12 +38,37 @@ span : h2.text-uppercase= i18n(view.nextLevel.attributes, 'name').replace('Course: ', '') - div!= view.nextLevelDescription + div.next-level-description!= view.nextLevelDescription + + if view.shareURL + .well.well-sm.well-parchment + h3.text-uppercase + if view.level.isType('game-dev') + span(data-i18n='sharing.share_game') + else + span(data-i18n='sharing.share_web') + p + span(data-i18n='sharing.victory_course_share_prefix') + span= ' ' + a(href=view.shareURL, target='_blank') + if view.level.isType('game-dev') + span(data-i18n='sharing.victory_course_share_game') + else + span(data-i18n='sharing.victory_course_share_web') + span= ' ' + span(data-i18n='sharing.victory_course_share_suffix') + .row + .col-sm-9 + input.text-h4.semibold.form-control.input-lg#share-level-input(value=view.shareURL) + .col-sm-3 + button#share-level-btn.btn.btn-lg.btn-success.btn-illustrated + span(data-i18n='sharing.copy_url') .row .col-sm-5.col-sm-offset-2 - // TODO: Add this and rest of campaign functionality - // button#continue-btn.btn.btn-illustrated.btn-default.btn-block.btn-lg.text-uppercase View Leaderboards + // TODO: Add rest of campaign functionality + if view.level.get('type') === 'course-ladder' + button#ladder-btn.btn.btn-illustrated.btn-default.btn-block.btn-lg.text-uppercase Ladder .col-sm-5 if !view.nextLevel.isNew() button#next-level-btn.btn.btn-illustrated.btn-primary.btn-block.btn-lg.text-uppercase(data-i18n='play_level.next_level') diff --git a/app/templates/play/level/modal/victory.jade b/app/templates/play/level/modal/victory.jade index effaad52c..6e6b9c56b 100644 --- a/app/templates/play/level/modal/victory.jade +++ b/app/templates/play/level/modal/victory.jade @@ -13,7 +13,7 @@ block modal-body-content block modal-footer-content if readyToRank .ladder-submission-view - else if level.get('type') === 'ladder' + else if level.isType('ladder') a.btn.btn-primary(href="/play/ladder/#{level.get('slug')}#my-matches", data-dismiss="modal", data-i18n="play_level.victory_return_to_ladder") Return to Ladder else a.btn.btn-primary(href="/", data-dismiss="modal", data-i18n="play_level.victory_go_home") Go Home diff --git a/app/templates/play/level/play-game-dev-level-view.jade b/app/templates/play/level/play-game-dev-level-view.jade new file mode 100644 index 000000000..c4b80c67e --- /dev/null +++ b/app/templates/play/level/play-game-dev-level-view.jade @@ -0,0 +1,38 @@ +.container-fluid + .row + .col-xs-9 + #canvas-wrapper + canvas(width=924, height=589)#webgl-surface + canvas(width=924, height=589)#normal-surface + + .col-xs-3#info-col.style-flat + if view.state.get('errorMessage') + .alert.alert-danger= view.state.get('errorMessage') + + else if view.state.get('loading') + h1.m-y-1(data-i18n="common.loading") + .progress + .progress-bar(style="width: #{view.state.get('progress')}") + + else + h1.m-y-1 Info + ul + li + b + span(data-i18n="play_level.level") + span= ': ' + | #{view.level.get('name')} + + li + b + span(data-i18n="game_dev.creator") + span= ': ' + | #{view.session.get('creatorName')} + + - var playing = view.state.get('playing') + .m-y-3 + if playing + button#play-btn.btn.btn-lg.btn-burgandy(data-i18n="play_level.restart") + else + button#play-btn.btn.btn-lg.btn-navy(data-i18n="common.play") + diff --git a/app/templates/play/level/play-web-dev-level-view.jade b/app/templates/play/level/play-web-dev-level-view.jade new file mode 100644 index 000000000..4e87873de --- /dev/null +++ b/app/templates/play/level/play-web-dev-level-view.jade @@ -0,0 +1,11 @@ +#web-surface-view + +#info-bar.style-flat + if !view.supermodel.finished() + h1(data-i18n="common.loading") + + else + h1 + span(data-i18n="game_dev.creator") + span= ': ' + | #{view.session.get('creatorName')} diff --git a/app/templates/play/level/tome/spell_list_tab_entry.jade b/app/templates/play/level/tome/spell-top-bar-view.jade similarity index 50% rename from app/templates/play/level/tome/spell_list_tab_entry.jade rename to app/templates/play/level/tome/spell-top-bar-view.jade index 5f5b02af9..2af252e1e 100644 --- a/app/templates/play/level/tome/spell_list_tab_entry.jade +++ b/app/templates/play/level/tome/spell-top-bar-view.jade @@ -3,16 +3,10 @@ .hinge.hinge-2 .hinge.hinge-3 -if includeSpellList - .btn.btn-small.btn-illustrated.spell-list-button(data-i18n="[title]play_level.tome_see_all_methods", title="See all methods you can edit") - .glyphicon.glyphicon-chevron-down - -.thang-avatar-placeholder - .spell-tool-buttons - .btn.btn-small.btn-illustrated.btn-warning.reload-code(data-i18n="[title]play_level.tome_reload_method", title="Reload original code for this method") + .btn.btn-small.btn-illustrated.btn-warning.reload-code(data-i18n="[title]play_level.tome_reload_method") .glyphicon.glyphicon-repeat - span.spl(data-i18n="play_level.reload") Reload + span.spl(data-i18n="play_level.restart") if me.level() >= 15 .btn.btn-small.btn-illustrated.fullscreen-code(title=maximizeShortcutVerbose) @@ -27,4 +21,17 @@ if includeSpellList .btn.btn-small.btn-illustrated.hints-button span(data-i18n="play_level.hints") + if view.options.level.isType('web-dev') + .btn.btn-small.btn-illustrated.image-gallery-button + span(data-i18n='web_dev.image_gallery_title') + + if view.options.level.get('shareable') + - var url = '/play/' + view.options.level.get('type') + '-level/' + view.options.level.get('slug') + '/' + view.options.session.id; + - if (view.options.courseID) url += '?course=' + view.options.courseID; + a.btn.btn-small.btn-illustrated(href=url) + if view.options.level.isType('game-dev') + span(data-i18n='sharing.game') + else + span(data-i18n='sharing.webpage') + .clearfix diff --git a/app/templates/play/level/tome/spell_list.jade b/app/templates/play/level/tome/spell_list.jade deleted file mode 100644 index 84b28511a..000000000 --- a/app/templates/play/level/tome/spell_list.jade +++ /dev/null @@ -1 +0,0 @@ -h5(data-i18n="play_level.tome_select_method") Select a Method diff --git a/app/templates/play/level/tome/spell_list_entry.jade b/app/templates/play/level/tome/spell_list_entry.jade deleted file mode 100644 index 561591078..000000000 --- a/app/templates/play/level/tome/spell_list_entry.jade +++ /dev/null @@ -1,7 +0,0 @@ -if showTopDivider - // Don't repeat Thang names when not changed from previous entry - .thang-names(title=thangNames)= thangNames - -code #{spell.name}(#{parameters}) - - diff --git a/app/templates/play/level/tome/spell_list_entry_thangs.jade b/app/templates/play/level/tome/spell_list_entry_thangs.jade deleted file mode 100644 index bcc7804f1..000000000 --- a/app/templates/play/level/tome/spell_list_entry_thangs.jade +++ /dev/null @@ -1,3 +0,0 @@ -h4 - span(data-i18n="play_level.tome_select_a_thang") Select Someone for - code #{view.spell.name}(#{(view.spell.parameters || []).join(", ")}) diff --git a/app/templates/play/level/tome/spell_palette_entry_popover.jade b/app/templates/play/level/tome/spell_palette_entry_popover.jade index 5ae7d312c..dd5306643 100644 --- a/app/templates/play/level/tome/spell_palette_entry_popover.jade +++ b/app/templates/play/level/tome/spell_palette_entry_popover.jade @@ -95,7 +95,7 @@ if !selectedMethod else if language == 'io' span= (doc.ownerName == 'this' ? '' : doc.ownerName + ' ') + docName + '(' + argumentExamples.join(', ') + ')' -if (doc.type != 'function' && doc.type != 'snippet') || doc.name == 'now' +if (doc.type != 'function' && doc.type != 'snippet' && doc.owner != 'HTML' && doc.owner != 'CSS') || doc.name == 'now' p.value strong span(data-i18n="skill_docs.current_value") Current Value diff --git a/app/templates/play/level/tome/tome.jade b/app/templates/play/level/tome/tome.jade index c29f9e4f0..526d8bc4d 100644 --- a/app/templates/play/level/tome/tome.jade +++ b/app/templates/play/level/tome/tome.jade @@ -1,11 +1,7 @@ -#spell-list-tab-entry-view - -#spell-list-view +#spell-top-bar-view #cast-button-view #spell-view #spell-palette-view - - diff --git a/app/templates/play/level/web-surface-view.jade b/app/templates/play/level/web-surface-view.jade new file mode 100644 index 000000000..f2991ad44 --- /dev/null +++ b/app/templates/play/level/web-surface-view.jade @@ -0,0 +1 @@ +iframe(src="/web-dev-iframe.html") diff --git a/app/templates/play/menu/multiplayer-view.jade b/app/templates/play/menu/multiplayer-view.jade deleted file mode 100644 index c45a1259c..000000000 --- a/app/templates/play/menu/multiplayer-view.jade +++ /dev/null @@ -1,90 +0,0 @@ -if !ladderGame - .form - .form-group.checkbox - label(for="multiplayer") - input#multiplayer(name="multiplayer", type="checkbox", checked=multiplayer) - span(data-i18n="multiplayer.multiplayer_toggle") Enable multiplayer - span.help-block(data-i18n="multiplayer.multiplayer_toggle_description") Allow others to join your game. - - hr - - div#link-area - p(data-i18n="multiplayer.multiplayer_link_description") Give this link to anyone to have them join you. - - textarea.well#multiplayer-join-link(readonly=true)= joinLink - - p - strong(data-i18n="multiplayer.multiplayer_hint_label") Hint: - span(data-i18n="multiplayer.multiplayer_hint") Click the link to select all, then press ⌘-C or Ctrl-C to copy the link. - - p(data-i18n="multiplayer.multiplayer_coming_soon") More multiplayer features to come! - -if ladderGame - if me.get('anonymous') - p(data-i18n="multiplayer.multiplayer_sign_in_leaderboard") Sign in or create an account and get your solution on the leaderboard. - else if realTimeSessions && realTimeSessionsPlayers - button#create-game-button Create Game - - hr - - div#created-multiplayer-session - h3 Your Game - if currentRealTimeSession - div - span(style="margin:10px")= currentRealTimeSession.get('levelID') - span(style="margin:10px")= currentRealTimeSession.get('creatorName') - span(style="margin:10px")= currentRealTimeSession.get('state') - span(style="margin:10px")= currentRealTimeSession.id - button#leave-game-button(data-item=item) Leave Game - div - - var players = realTimeSessionsPlayers[currentRealTimeSession.id] - if players - span(style="margin:10px") Players: - - for (var i=0; i < players.length; i++) { - span(style="margin:10px")= players.at(i).get('name') - span(style="margin:10px")= players.at(i).get('team') - span(style="margin:10px")= players.at(i).get('state') - - } - else - span No Players? - else - div Click something above to create a game. - - hr - - div#open-games - h3 Open Games - //- TODO: do not let you join ones with same-team opponent - - var noOpenGames = true - - for (var i=0; i < realTimeSessions.length; i++) { - if (currentRealTimeSession && realTimeSessions.at(i).id == currentRealTimeSession.id) - - continue - if levelID === realTimeSessions.at(i).get('levelID') && realTimeSessions.at(i).get('state') === 'creating' - - var id = realTimeSessions.at(i).get('id') - - var players = realTimeSessionsPlayers[id] - if players && players.length === 1 - - noOpenGames = false - - var creatorName = realTimeSessions.at(i).get('creatorName') - - var creator = realTimeSessions.at(i).get('creator') - - var state = realTimeSessions.at(i).get('state') - - var item = realTimeSessions.at(i) - div - button#join-game-button(data-item=item) Join Game - span(style="margin:10px")= levelID - span(style="margin:10px")= creatorName - span(style="margin:10px")= state - span(style="margin:10px")= id - div - span(style="margin:10px") Players: - span(style="margin:10px")= players.at(0).get('name') - span(style="margin:10px")= players.at(0).get('team') - span(style="margin:10px")= players.at(0).get('state') - - } - if noOpenGames - div No games available. - - hr - - .ladder-submission-view - else - a.btn.btn-primary(href="/play/ladder/#{levelSlug}#my-matches", data-i18n="multiplayer.victory_go_ladder") Return to Ladder diff --git a/app/templates/play/play-level-view.jade b/app/templates/play/play-level-view.jade index a225ea79b..77508fcfb 100644 --- a/app/templates/play/play-level-view.jade +++ b/app/templates/play/play-level-view.jade @@ -24,6 +24,8 @@ if view.showAds() #canvas-wrapper canvas(width=924, height=589)#webgl-surface canvas(width=924, height=589)#normal-surface + + #web-surface-view #ascii-surface #canvas-left-gradient.gradient #canvas-top-gradient.gradient diff --git a/app/views/admin/MainAdminView.coffee b/app/views/admin/MainAdminView.coffee index dd4b33b32..f6be77f19 100644 --- a/app/views/admin/MainAdminView.coffee +++ b/app/views/admin/MainAdminView.coffee @@ -9,6 +9,7 @@ Campaigns = require 'collections/Campaigns' Classroom = require 'models/Classroom' CocoCollection = require 'collections/CocoCollection' Course = require 'models/Course' +Courses = require 'collections/Courses' LevelSessions = require 'collections/LevelSessions' User = require 'models/User' Users = require 'collections/Users' @@ -152,6 +153,7 @@ module.exports = class MainAdminView extends RootView $('.classroom-progress-csv').prop('disabled', true) classCode = $('.classroom-progress-class-code').val() classroom = null + courses = null courseLevels = [] sessions = null users = null @@ -161,12 +163,16 @@ module.exports = class MainAdminView extends RootView classroom = new Classroom({ _id: model.data._id }) Promise.resolve(classroom.fetch()) .then (model) => + courses = new Courses() + Promise.resolve(courses.fetch()) + .then (models) => for course, index in classroom.get('courses') for level in course.levels courseLevels.push courseIndex: index + 1 levelID: level.original slug: level.slug + courseSlug: courses.get(course._id).get('slug') users = new Users() Promise.resolve($.when(users.fetchForClassroom(classroom)...)) .then (models) => @@ -202,12 +208,19 @@ module.exports = class MainAdminView extends RootView columnLabels = "Username" currentLevel = 1 + courseLabelIndexes = CS: 1, GD: 0, WD: 0 lastCourseIndex = 1 + lastCourseLabel = 'CS1' for level in courseLevels unless level.courseIndex is lastCourseIndex currentLevel = 1 lastCourseIndex = level.courseIndex - columnLabels += ",CS#{level.courseIndex}.#{currentLevel++} #{level.slug}" + acronym = switch + when /game-dev/.test(level.courseSlug) then 'GD' + when /web-dev/.test(level.courseSlug) then 'WD' + else 'CS' + lastCourseLabel = acronym + ++courseLabelIndexes[acronym] + columnLabels += ",#{lastCourseLabel}.#{currentLevel++} #{level.slug}" csvContent = "data:text/csv;charset=utf-8,#{columnLabels}\n" for studentRow in userPlaytimes csvContent += studentRow.join(',') + "\n" diff --git a/app/views/clans/ClanDetailsView.coffee b/app/views/clans/ClanDetailsView.coffee index 8e8d7bc12..c9bcdb1e3 100644 --- a/app/views/clans/ClanDetailsView.coffee +++ b/app/views/clans/ClanDetailsView.coffee @@ -195,7 +195,7 @@ module.exports = class ClanDetailsView extends RootView if level.concepts? for concept in level.concepts @conceptsProgression.push concept unless concept in @conceptsProgression - if level.type is 'hero-ladder' and level.slug not in ['capture-their-flag'] + if level.type is 'hero-ladder' and level.slug not in ['capture-their-flag'] # Would use isType, but it's not a Level model @arenas.push level @campaignLevelProgressions.push campaignLevelProgression @render?() diff --git a/app/views/core/CocoView.coffee b/app/views/core/CocoView.coffee index 3ec65e5f0..3450ac721 100644 --- a/app/views/core/CocoView.coffee +++ b/app/views/core/CocoView.coffee @@ -496,6 +496,13 @@ module.exports = class CocoView extends Backbone.View playSound: (trigger, volume=1) -> Backbone.Mediator.publish 'audio-player:play-sound', trigger: trigger, volume: volume + tryCopy: -> + try + document.execCommand('copy') + catch err + message = 'Oops, unable to copy' + noty text: message, layout: 'topCenter', type: 'error', killer: false + mobileRELong = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i mobileREShort = /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i diff --git a/app/views/courses/CourseDetailsView.coffee b/app/views/courses/CourseDetailsView.coffee index 8a659103d..2f7e4c4f4 100644 --- a/app/views/courses/CourseDetailsView.coffee +++ b/app/views/courses/CourseDetailsView.coffee @@ -52,7 +52,7 @@ module.exports = class CourseDetailsView extends RootView @supermodel.trackRequest(@classroom.fetch()) levelsLoaded = @supermodel.trackRequest(@levels.fetchForClassroomAndCourse(classroomID, @courseID, { - data: { project: 'concepts,practice,type,slug,name,original,description' } + data: { project: 'concepts,practice,type,slug,name,original,description,shareable,i18n' } })) @supermodel.trackRequest($.when(levelsLoaded, sessionsLoaded).then(=> @@ -62,7 +62,7 @@ module.exports = class CourseDetailsView extends RootView # need to figure out the next course instance @courseComplete = true @courseInstances.comparator = 'courseID' - # TODO: make this logic use locked course content to figure out the next course, then fetch the + # TODO: make this logic use locked course content to figure out the next course, then fetch the # course instance for that @supermodel.trackRequest(@courseInstances.fetchForClassroom(classroomID).then(=> @nextCourseInstance = _.find @courseInstances.models, (ci) => ci.get('courseID') > @courseID @@ -84,9 +84,9 @@ module.exports = class CourseDetailsView extends RootView @levelConceptMap = {} for level in @levels.models @levelConceptMap[level.get('original')] ?= {} - for concept in level.get('concepts') + for concept in level.get('concepts') or [] @levelConceptMap[level.get('original')][concept] = true - if level.get('type') is 'course-ladder' + if level.isType('course-ladder') @arenaLevel = level # console.log 'onLevelSessionsSync' @@ -124,13 +124,13 @@ module.exports = class CourseDetailsView extends RootView for concept, state of conceptStateMap @conceptsCompleted[concept] ?= 0 @conceptsCompleted[concept]++ - + onClickPlayLevel: (e) -> levelSlug = $(e.target).closest('.btn-play-level').data('level-slug') levelID = $(e.target).closest('.btn-play-level').data('level-id') level = @levels.findWhere({original: levelID}) window.tracker?.trackEvent 'Students Class Course Play Level', category: 'Students', courseID: @courseID, courseInstanceID: @courseInstanceID, levelSlug: levelSlug, ['Mixpanel'] - if level.get('type') is 'course-ladder' + if level.isType('course-ladder') viewClass = 'views/ladder/LadderView' viewArgs = [{supermodel: @supermodel}, levelSlug] route = '/play/ladder/' + levelSlug diff --git a/app/views/courses/TeacherClassView.coffee b/app/views/courses/TeacherClassView.coffee index 711ef4c82..5cd64b0ec 100644 --- a/app/views/courses/TeacherClassView.coffee +++ b/app/views/courses/TeacherClassView.coffee @@ -23,6 +23,7 @@ CourseInstances = require 'collections/CourseInstances' module.exports = class TeacherClassView extends RootView id: 'teacher-class-view' template: template + helper: helper events: 'click .nav-tabs a': 'onClickNavTabLink' @@ -43,7 +44,7 @@ module.exports = class TeacherClassView extends RootView 'click .student-checkbox': 'onClickStudentCheckbox' 'keyup #student-search': 'onKeyPressStudentSearch' 'change .course-select, .bulk-course-select': 'onChangeCourseSelect' - + getInitialState: -> { sortAttribute: 'name' @@ -72,21 +73,21 @@ module.exports = class TeacherClassView extends RootView @singleStudentCourseProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-single-student-course' @singleStudentLevelProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-single-student-level' @allStudentsLevelProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-all-students-single-level' - + @debouncedRender = _.debounce @render - + @state = new State(@getInitialState()) @updateHash @state.get('activeTab') # TODO: Don't push to URL history (maybe don't use url fragment for default tab) - + @classroom = new Classroom({ _id: classroomID }) @supermodel.trackRequest @classroom.fetch() @onKeyPressStudentSearch = _.debounce(@onKeyPressStudentSearch, 200) - + @students = new Users() @listenTo @classroom, 'sync', -> jqxhrs = @students.fetchForClassroom(@classroom, removeDeleted: true) @supermodel.trackRequests jqxhrs - + @classroom.sessions = new LevelSessions() requests = @classroom.sessions.fetchForAllClassroomMembers(@classroom) @supermodel.trackRequests(requests) @@ -96,7 +97,7 @@ module.exports = class TeacherClassView extends RootView value = @state.get('sortValue') if value is 'name' return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir) - + if value is 'progress' # TODO: I would like for this to be in the Level model, # but it doesn't know about its own courseNumber. @@ -105,7 +106,7 @@ module.exports = class TeacherClassView extends RootView return -dir if not level1 return dir if not level2 return dir * (level1.courseNumber - level2.courseNumber or level1.levelNumber - level2.levelNumber) - + if value is 'status' statusMap = { expired: 0, 'not-enrolled': 1, enrolled: 2 } diff = statusMap[student1.prepaidStatus()] - statusMap[student2.prepaidStatus()] @@ -114,13 +115,13 @@ module.exports = class TeacherClassView extends RootView @courses = new Courses() @supermodel.trackRequest @courses.fetch() - + @courseInstances = new CourseInstances() @supermodel.trackRequest @courseInstances.fetchForClassroom(classroomID) @levels = new Levels() - @supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts,practice'}}) - + @supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts,practice,shareable,i18n'}}) + @attachMediatorEvents() window.tracker?.trackEvent 'Teachers Class Loaded', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] @@ -160,11 +161,11 @@ module.exports = class TeacherClassView extends RootView course.instance = @courseInstances.findWhere({ courseID: course.id, classroomID: @classroom.id }) course.members = course.instance?.get('members') or [] null - + onLoaded: -> @removeDeletedStudents() # TODO: Move this to mediator listeners? For both classroom and students? @calculateProgressAndLevels() - + # render callback setup @listenTo @courseInstances, 'sync change update', @debouncedRender @listenTo @state, 'sync change', -> @@ -174,17 +175,17 @@ module.exports = class TeacherClassView extends RootView @debouncedRender() @listenTo @students, 'sort', @debouncedRender super() - + afterRender: -> super(arguments...) - $('.progress-dot').each (i, el) -> + $('.progress-dot, .btn-view-project-level').each (i, el) -> dot = $(el) dot.tooltip({ html: true container: dot }).delegate '.tooltip', 'mousemove', -> dot.tooltip('hide') - + calculateProgressAndLevels: -> return unless @supermodel.progress is 1 # TODO: How to structure this in @state? @@ -192,14 +193,14 @@ module.exports = class TeacherClassView extends RootView # TODO: this is a weird hack studentsStub = new Users([ student ]) student.latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, studentsStub) - + earliestIncompleteLevel = helper.calculateEarliestIncomplete(@classroom, @courses, @courseInstances, @students) latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, @students) - + classroomsStub = new Classrooms([ @classroom ]) progressData = helper.calculateAllProgress(classroomsStub, @courses, @courseInstances, @students) # conceptData: helper.calculateConceptsCovered(classroomsStub, @courses, @campaigns, @courseInstances, @students) - + @state.set { earliestIncompleteLevel latestCompleteLevel @@ -212,7 +213,7 @@ module.exports = class TeacherClassView extends RootView hash = $(e.target).closest('a').attr('href') @updateHash(hash) @state.set activeTab: hash - + updateHash: (hash) -> return if application.testing window.location.hash = hash @@ -227,17 +228,10 @@ module.exports = class TeacherClassView extends RootView @$('#join-url-input').val(@state.get('joinURL')).select() @tryCopy() - tryCopy: -> - try - document.execCommand('copy') - catch err - message = 'Oops, unable to copy' - noty text: message, layout: 'topCenter', type: 'error', killer: false - onClickUnarchive: -> window.tracker?.trackEvent 'Teachers Class Unarchive', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] @classroom.save { archived: false } - + onClickEditClassroom: (e) -> window.tracker?.trackEvent 'Teachers Class Edit Class Started', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] classroom = @classroom @@ -328,9 +322,11 @@ module.exports = class TeacherClassView extends RootView window.tracker?.trackEvent 'Teachers Class Export CSV', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] courseLabels = "" courseOrder = [] - for course, index in @classroom.get('courses') - courseLabels += "CS#{index + 1} Playtime," - courseOrder.push(course._id) + courses = (@courses.get(c._id) for c in @classroom.get('courses')) + courseLabelsArray = helper.courseLabelsArray courses + for course, index in courses + courseLabels += "#{courseLabelsArray[index]} Playtime," + courseOrder.push(course.id) csvContent = "data:text/csv;charset=utf-8,Username,Email,Total Playtime,#{courseLabels}Concepts\n" levelCourseMap = {} for trimCourse in @classroom.get('courses') @@ -396,6 +392,7 @@ module.exports = class TeacherClassView extends RootView not @students.get(userID).isEnrolled() assigningToNobody = selectedIDs.length is 0 @state.set errors: { assigningToNobody, assigningToUnenrolled } + return if assigningToNobody @assignCourse courseID, members window.tracker?.trackEvent 'Teachers Class Students Assign Selected', category: 'Teachers', classroomID: @classroom.id, courseID: courseID, ['Mixpanel'] @@ -459,7 +456,7 @@ module.exports = class TeacherClassView extends RootView enrolledUsers = @students.filter (user) -> user.isEnrolled() stats.enrolledUsers = _.size(enrolledUsers) - + return stats studentStatusString: (student) -> diff --git a/app/views/courses/TeacherClassesView.coffee b/app/views/courses/TeacherClassesView.coffee index b281a2ce0..5dc0c77ee 100644 --- a/app/views/courses/TeacherClassesView.coffee +++ b/app/views/courses/TeacherClassesView.coffee @@ -17,6 +17,7 @@ helper = require 'lib/coursesHelper' module.exports = class TeacherClassesView extends RootView id: 'teacher-classes-view' template: template + helper: helper events: 'click .edit-classroom': 'onClickEditClassroom' diff --git a/app/views/editor/component/ThangComponentConfigView.coffee b/app/views/editor/component/ThangComponentConfigView.coffee index dc5498b41..2182e2a8c 100644 --- a/app/views/editor/component/ThangComponentConfigView.coffee +++ b/app/views/editor/component/ThangComponentConfigView.coffee @@ -46,7 +46,7 @@ module.exports = class ThangComponentConfigView extends CocoView schema.default ?= {} _.merge schema.default, @additionalDefaults if @additionalDefaults - if @level?.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + if @level?.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') schema.required = [] treemaOptions = supermodel: @supermodel diff --git a/app/views/editor/level/LevelEditView.coffee b/app/views/editor/level/LevelEditView.coffee index bf86e6825..32c45bf9b 100644 --- a/app/views/editor/level/LevelEditView.coffee +++ b/app/views/editor/level/LevelEditView.coffee @@ -37,6 +37,7 @@ require 'vendor/aether-python' require 'vendor/aether-coffeescript' require 'vendor/aether-lua' require 'vendor/aether-java' +require 'vendor/aether-html' module.exports = class LevelEditView extends RootView id: 'editor-level-view' diff --git a/app/views/editor/level/thangs/LevelThangEditView.coffee b/app/views/editor/level/thangs/LevelThangEditView.coffee index 84429d644..46da5067d 100644 --- a/app/views/editor/level/thangs/LevelThangEditView.coffee +++ b/app/views/editor/level/thangs/LevelThangEditView.coffee @@ -41,7 +41,7 @@ module.exports = class LevelThangEditView extends CocoView level: @level world: @world - if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] then options.thangType = thangType + if @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') then options.thangType = thangType @thangComponentEditView = new ThangComponentsEditView options @listenTo @thangComponentEditView, 'components-changed', @onComponentsChanged diff --git a/app/views/editor/level/thangs/ThangsTabView.coffee b/app/views/editor/level/thangs/ThangsTabView.coffee index 1e794a40d..a62c28460 100644 --- a/app/views/editor/level/thangs/ThangsTabView.coffee +++ b/app/views/editor/level/thangs/ThangsTabView.coffee @@ -251,7 +251,7 @@ module.exports = class ThangsTabView extends CocoView @dragged = 0 @willUnselectSprite = false @gameUIState.set('canDragCamera', true) - + if @addThangLank?.thangType.get('kind') is 'Wall' @paintingWalls = true @gameUIState.set('canDragCamera', false) @@ -259,7 +259,7 @@ module.exports = class ThangsTabView extends CocoView else if @addThangLank # We clicked on the background when we had an add Thang selected, so add it @addThang @addThangType, @addThangLank.thang.pos - + else if e.onBackground @gameUIState.set('selected', []) @@ -331,18 +331,18 @@ module.exports = class ThangsTabView extends CocoView @onSpriteContextMenu e clearInterval(@movementInterval) if @movementInterval? @movementInterval = null - + return unless _.any(selected) - + for singleSelected in selected pos = singleSelected.thang.pos - + thang = _.find(@level.get('thangs') ? [], {id: singleSelected.thang.id}) path = "#{@pathForThang(thang)}/components/original=#{LevelComponent.PhysicalID}" physical = @thangsTreema.get path continue if not physical or (physical.config.pos.x is pos.x and physical.config.pos.y is pos.y) @thangsTreema.set path + '/config/pos', x: pos.x, y: pos.y, z: pos.z - + if @willUnselectSprite clickedSprite = _.find(selected, {sprite: e.sprite}) @gameUIState.set('selected', _.without(selected, clickedSprite)) @@ -379,7 +379,7 @@ module.exports = class ThangsTabView extends CocoView thang = selected?.thang previousSprite?.setNameLabel?(null) unless previousSprite is sprite - + if thang and not (@addThangLank and @addThangType.get('name') in overlappableThangTypeNames) # We clicked on a Thang (or its Treema), so select the Thang @selectAddThang(null, true) @@ -619,7 +619,7 @@ module.exports = class ThangsTabView extends CocoView onTreemaThangSelected: (e, selectedTreemas) => selectedThangTreemas = _.filter(selectedTreemas, (t) -> t instanceof ThangNode) thangIDs = (node.data.id for node in selectedThangTreemas) - lanks = (@surface.lankBoss.lanks[thangID] for thangID in thangIDs when thangID) + lanks = (@surface.lankBoss.lanks[thangID] for thangID in thangIDs when thangID) selected = ({ thang: lank.thang, sprite: lank } for lank in lanks when lank) @gameUIState.set('selected', selected) @@ -636,14 +636,14 @@ module.exports = class ThangsTabView extends CocoView if batchInsert if thangType.get('name') is 'Hero Placeholder' thangID = 'Hero Placeholder' - return if not (@level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) or @getThangByID(thangID) + return if not @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') or @getThangByID(thangID) else thangID = "Random #{thangType.get('name')} #{@thangsBatch.length}" else thangID = Thang.nextID(thangType.get('name'), @world) until thangID and not @getThangByID(thangID) if @cloneSourceThang components = _.cloneDeep @getThangByID(@cloneSourceThang.id).components - else if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + else if @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') components = [] # Load them all from default ThangType Components else components = _.cloneDeep thangType.get('components') ? [] diff --git a/app/views/editor/verifier/VerifierTest.coffee b/app/views/editor/verifier/VerifierTest.coffee index 926ed1dee..6f0922b5f 100644 --- a/app/views/editor/verifier/VerifierTest.coffee +++ b/app/views/editor/verifier/VerifierTest.coffee @@ -78,7 +78,7 @@ module.exports = class VerifierTest extends CocoClass @listenToOnce @god, 'infinite-loop', @fail @listenToOnce @god, 'user-code-problem', @onUserCodeProblem @listenToOnce @god, 'goals-calculated', @processSingleGameResults - @god.createWorld @generateSpellsObject() + @god.createWorld @session.generateSpellsObject() @updateCallback? state: 'running' processSingleGameResults: (e) -> @@ -118,18 +118,6 @@ module.exports = class VerifierTest extends CocoClass @updateCallback? state: @state @scheduleCleanup() - generateSpellsObject: -> - aetherOptions = createAetherOptions functionName: 'plan', codeLanguage: @session.get('codeLanguage') - spellThang = aether: new Aether aetherOptions - spells = "hero-placeholder/plan": thangs: {'Hero Placeholder': spellThang}, name: 'plan' - source = @session.get('code')['hero-placeholder'].plan - try - spellThang.aether.transpile source - catch e - console.log "Couldn't transpile!\n#{source}\n", e - spellThang.aether.transpile '' - spells - scheduleCleanup: -> setTimeout @cleanup, 100 diff --git a/app/views/editor/verifier/VerifierView.coffee b/app/views/editor/verifier/VerifierView.coffee index 32c8e22e2..423ea104d 100644 --- a/app/views/editor/verifier/VerifierView.coffee +++ b/app/views/editor/verifier/VerifierView.coffee @@ -51,7 +51,7 @@ module.exports = class VerifierView extends RootView for campaign in @campaigns.models when campaign.get('type') in ['course', 'hero'] and campaign.get('slug') isnt 'picoctf' @levelsByCampaign[campaign.get('slug')] ?= {levels: [], checked: true} campaignInfo = @levelsByCampaign[campaign.get('slug')] - for levelID, level of campaign.get('levels') when level.type not in ['hero-ladder', 'course-ladder', 'game-dev'] + for levelID, level of campaign.get('levels') when level.type not in ['hero-ladder', 'course-ladder', 'game-dev', 'web-dev'] # Would use isType, but it's not a Level model campaignInfo.levels.push level.slug filterCodeLanguages: -> diff --git a/app/views/ladder/LadderPlayModal.coffee b/app/views/ladder/LadderPlayModal.coffee index 77a87fd2d..a60b10d08 100644 --- a/app/views/ladder/LadderPlayModal.coffee +++ b/app/views/ladder/LadderPlayModal.coffee @@ -26,8 +26,8 @@ module.exports = class LadderPlayModal extends ModalView initialize: (options, @level, @session, @team) -> @otherTeam = if @team is 'ogres' then 'humans' else 'ogres' - @startLoadingChallengersMaybe() @wizardType = ThangType.loadUniversalWizard() + @startLoadingChallengersMaybe() @levelID = @level.get('slug') or @level.id @language = @session?.get('codeLanguage') ? me.get('aceConfig')?.language ? 'python' @languages = [ diff --git a/app/views/ladder/MainLadderView.coffee b/app/views/ladder/MainLadderView.coffee index b4108ee42..171f8b3ee 100644 --- a/app/views/ladder/MainLadderView.coffee +++ b/app/views/ladder/MainLadderView.coffee @@ -21,7 +21,7 @@ module.exports = class MainLadderView extends RootView @campaigns = campaigns @sessions = @supermodel.loadCollection(new LevelSessionsCollection(), 'your_sessions', {cache: false}, 0).model - @listenToOnce @sessions, 'sync', @onSessionsLoaded + @listenToOnce @sessions, 'sync', @onSessionsLoaded @getLevelPlayCounts() @@ -94,52 +94,6 @@ heroArenas = [ } ] -oldArenas = [ - { - name: 'Criss-Cross' - difficulty: 5 - id: 'criss-cross' - image: '/file/db/level/5391f3d519dc22b8082159b2/banner2.png' - description: 'Participate in a bidding war with opponents to reach the other side!' - } - { - name: 'Greed' - difficulty: 4 - id: 'greed' - image: '/file/db/level/53558b5a9914f5a90d7ccddb/greed_banner.jpg' - description: 'Liked Dungeon Arena and Gold Rush? Put them together in this economic arena!' - } - { - name: 'Sky Span (Testing)' - difficulty: 3 - id: 'sky-span' - image: '/file/db/level/53c80fce0ddbef000084c667/sky-Span-banner.jpg' - description: 'Preview version of an upgraded Dungeon Arena. Help us with hero balance before release!' - } - { - name: 'Dungeon Arena' - difficulty: 3 - id: 'dungeon-arena' - image: '/file/db/level/53173f76c269d400000543c2/Level%20Banner%20Dungeon%20Arena.jpg' - description: 'Play head-to-head against fellow Wizards in a dungeon melee!' - } - { - name: 'Gold Rush' - difficulty: 3 - id: 'gold-rush' - image: '/file/db/level/533353722a61b7ca6832840c/Gold-Rush.png' - description: 'Prove you are better at collecting gold than your opponent!' - } - { - name: 'Brawlwood' - difficulty: 4 - id: 'brawlwood' - image: '/file/db/level/52d97ecd32362bc86e004e87/Level%20Banner%20Brawlwood.jpg' - description: 'Combat the armies of other Wizards in a strategic forest arena! (Fast computer required.)' - } -] - campaigns = [ {id: 'multiplayer', name: 'Multiplayer Arenas', description: '... in which you code head-to-head against other players.', levels: heroArenas} - #{id: 'old_multiplayer', name: '(Deprecated) Old Multiplayer Arenas', description: 'Relics of a more civilized age. No simulations are run for these older, hero-less multiplayer arenas.', levels: oldArenas} ] diff --git a/app/views/ladder/SimulateTabView.coffee b/app/views/ladder/SimulateTabView.coffee index d30c3f9b1..65c73d171 100644 --- a/app/views/ladder/SimulateTabView.coffee +++ b/app/views/ladder/SimulateTabView.coffee @@ -24,7 +24,7 @@ module.exports = class SimulateTabView extends CocoView onLoaded: -> super() @render() - if (document.location.hash is '#simulate' or @options.level.get('type') is 'course-ladder') and not @simulator + if (document.location.hash is '#simulate' or @options.level.isType('course-ladder')) and not @simulator @startSimulating() afterRender: -> diff --git a/app/views/play/CampaignView.coffee b/app/views/play/CampaignView.coffee index 931a5244c..20062873a 100644 --- a/app/views/play/CampaignView.coffee +++ b/app/views/play/CampaignView.coffee @@ -397,8 +397,9 @@ module.exports = class CampaignView extends RootView @particleMan.removeEmitters() @particleMan.attach @$el.find('.map') for level in @campaign.renderedLevels ? {} - particleKey = ['level', @terrain.replace('-branching-test', '')] - particleKey.push level.type if level.type and not (level.type in ['hero', 'course']) + terrain = @terrain.replace('-branching-test', '').replace(/(game|web)-dev-\d/, 'forest') + particleKey = ['level', terrain] + particleKey.push level.type if level.type and not (level.type in ['hero', 'course']) # Would use isType, but it's not a Level model particleKey.push 'replayable' if level.replayable particleKey.push 'premium' if level.requiresSubscription particleKey.push 'gate' if level.slug in ['kithgard-gates', 'siege-of-stonehold', 'clash-of-clones', 'summits-gate'] @@ -532,7 +533,7 @@ module.exports = class CampaignView extends RootView levelElement = $(e.target).parents('.level-info-container') levelSlug = levelElement.data('level-slug') level = _.find _.values(@campaign.get('levels')), slug: levelSlug - if level.type in ['hero-ladder', 'course-ladder'] + if level.type in ['hero-ladder', 'course-ladder'] # Would use isType, but it's not a Level model Backbone.Mediator.publish 'router:navigate', route: "/play/ladder/#{levelSlug}", viewClass: 'views/ladder/LadderView', viewArgs: [{supermodel: @supermodel}, levelSlug] else @showLeaderboard levelSlug diff --git a/app/views/play/SpectateView.coffee b/app/views/play/SpectateView.coffee index 272b69d5b..f5a28fac8 100644 --- a/app/views/play/SpectateView.coffee +++ b/app/views/play/SpectateView.coffee @@ -144,9 +144,6 @@ module.exports = class SpectateLevelView extends RootView if c then myCode[thang][spell] = c else delete myCode[thang][spell] @session.set('code', myCode) - if @session.get('multiplayer') and @otherSession? - # For now, ladderGame will disallow multiplayer, because session code combining doesn't play nice yet. - @session.set 'multiplayer', false onLevelStarted: (e) -> go = => @@ -181,7 +178,7 @@ module.exports = class SpectateLevelView extends RootView @insertSubView new GoldView {} @insertSubView new HUDView {level: @level} - @insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.get('type') in ['hero-ladder', 'course-ladder'] + @insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.isType('hero-ladder', 'course-ladder') @insertSubView @controlBar = new ControlBarView {worldName: utils.i18n(@level.attributes, 'name'), session: @session, level: @level, supermodel: @supermodel, spectateGame: true} # callbacks diff --git a/app/views/play/level/ControlBarView.coffee b/app/views/play/level/ControlBarView.coffee index 8254e9b18..bda779e4d 100644 --- a/app/views/play/level/ControlBarView.coffee +++ b/app/views/play/level/ControlBarView.coffee @@ -7,17 +7,13 @@ Classroom = require 'models/Classroom' Course = require 'models/Course' CourseInstance = require 'models/CourseInstance' GameMenuModal = require 'views/play/menu/GameMenuModal' -RealTimeModel = require 'models/RealTimeModel' -RealTimeCollection = require 'collections/RealTimeCollection' LevelSetupManager = require 'lib/LevelSetupManager' -GameMenuModal = require 'views/play/menu/GameMenuModal' module.exports = class ControlBarView extends CocoView id: 'control-bar-view' template: template subscriptions: - 'bus:player-states-changed': 'onPlayerStatesChanged' 'level:disable-controls': 'onDisableControls' 'level:enable-controls': 'onEnableControls' 'ipad:memory-warning': 'onIPadMemoryWarning' @@ -28,7 +24,6 @@ module.exports = class ControlBarView extends CocoView 'click': -> Backbone.Mediator.publish 'tome:focus-editor', {} 'click .levels-link-area': 'onClickHome' 'click .home a': 'onClickHome' - 'click .multiplayer-area': 'onClickMultiplayer' 'click #control-bar-sign-up-button': 'onClickSignupButton' constructor: (options) -> @@ -45,7 +40,7 @@ module.exports = class ControlBarView extends CocoView @observing = options.session.get('creator') isnt me.id @levelNumber = '' - if @level.get('type') is 'course' and @level.get('campaignIndex')? + if @level.isType('course', 'game-dev', 'web-dev') and @level.get('campaignIndex')? @levelNumber = @level.get('campaignIndex') + 1 if @courseInstanceID @courseInstance = new CourseInstance(_id: @courseInstanceID) @@ -64,9 +59,6 @@ module.exports = class ControlBarView extends CocoView @supermodel.trackRequest(@campaign.fetch()) ) super options - if @level.get('type') in ['hero-ladder', 'course-ladder'] and me.isAdmin() - @isMultiplayerLevel = true - @multiplayerStatusManager = new MultiplayerStatusManager @levelID, @onMultiplayerStateChanged if @level.get 'replayable' @listenTo @session, 'change-difficulty', @onSessionDifficultyChanged @@ -79,25 +71,10 @@ module.exports = class ControlBarView extends CocoView setBus: (@bus) -> - onPlayerStatesChanged: (e) -> - # TODO: this doesn't fire any more. Replacement? - return unless @bus is e.bus - numPlayers = _.keys(e.players).length - return if numPlayers is @numPlayers - @numPlayers = numPlayers - text = 'Multiplayer' - text += " (#{numPlayers})" if numPlayers > 1 - $('#multiplayer-button', @$el).text(text) - - onMultiplayerStateChanged: => @render?() - getRenderData: (c={}) -> super c c.worldName = @worldName - c.multiplayerEnabled = @session.get('multiplayer') - c.ladderGame = @level.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] - if c.isMultiplayerLevel = @isMultiplayerLevel - c.multiplayerStatus = @multiplayerStatusManager?.status + c.ladderGame = @level.isType('ladder', 'hero-ladder', 'course-ladder') if @level.get 'replayable' c.levelDifficulty = @session.get('state')?.difficulty ? 0 if @observing @@ -110,23 +87,17 @@ module.exports = class ControlBarView extends CocoView if me.isSessionless() @homeLink = "/teachers/courses" @homeViewClass = "views/courses/TeacherCoursesView" - else if @level.get('type', true) in ['ladder', 'ladder-tutorial', 'hero-ladder', 'course-ladder'] + else if @level.isType('ladder', 'ladder-tutorial', 'hero-ladder', 'course-ladder') levelID = @level.get('slug')?.replace(/\-tutorial$/, '') or @level.id @homeLink = '/play/ladder/' + levelID @homeViewClass = 'views/ladder/LadderView' @homeViewArgs.push levelID if leagueID = @getQueryVariable 'league' - leagueType = if @level.get('type') is 'course-ladder' then 'course' else 'clan' + leagueType = if @level.isType('course-ladder') then 'course' else 'clan' @homeViewArgs.push leagueType @homeViewArgs.push leagueID @homeLink += "/#{leagueType}/#{leagueID}" - else if @level.get('type', true) in ['hero', 'hero-coop'] or window.serverConfig.picoCTF - @homeLink = '/play' - @homeViewClass = 'views/play/CampaignView' - campaign = @level.get 'campaign' - @homeLink += '/' + campaign - @homeViewArgs.push campaign - else if @level.get('type', true) in ['course'] + else if @level.isType('course') or @courseID @homeLink = '/courses' @homeViewClass = 'views/courses/CoursesView' if @courseID @@ -136,7 +107,12 @@ module.exports = class ControlBarView extends CocoView if @courseInstanceID @homeLink += "/#{@courseInstanceID}" @homeViewArgs.push @courseInstanceID - #else if @level.get('type', true) is 'game-dev' # TODO + else if @level.isType('hero', 'hero-coop', 'game-dev', 'web-dev') or window.serverConfig.picoCTF + @homeLink = '/play' + @homeViewClass = 'views/play/CampaignView' + campaign = @level.get 'campaign' + @homeLink += '/' + campaign + @homeViewArgs.push campaign else @homeLink = '/' @homeViewClass = 'views/HomeView' @@ -153,16 +129,13 @@ module.exports = class ControlBarView extends CocoView @setupManager.open() onClickHome: (e) -> - if @level.get('type', true) in ['course'] + if @level.isType('course') category = if me.isTeacher() then 'Teachers' else 'Students' window.tracker?.trackEvent 'Play Level Back To Levels', category: category, levelSlug: @levelSlug, ['Mixpanel'] e.preventDefault() e.stopImmediatePropagation() Backbone.Mediator.publish 'router:navigate', route: @homeLink, viewClass: @homeViewClass, viewArgs: @homeViewArgs - onClickMultiplayer: (e) -> - @showGameMenuModal e, 'multiplayer' - onClickSignupButton: (e) -> window.tracker?.trackEvent 'Started Signup', category: 'Play Level', label: 'Control Bar', level: @levelID @@ -183,62 +156,4 @@ module.exports = class ControlBarView extends CocoView destroy: -> @setupManager?.destroy() - @multiplayerStatusManager?.destroy() super() - -# MultiplayerStatusManager ###################################################### -# -# Manages the multiplayer status, and calls @statusChangedCallback when it changes. -# -# It monitors these: -# Real-time multiplayer players -# Internal multiplayer status -# -# Real-time state variables: -# @playersCollection - Real-time multiplayer players -# -# TODO: Not currently using player counts. Should remove if we keep simple design. -# -class MultiplayerStatusManager - - constructor: (@levelID, @statusChangedCallback) -> - @status = '' - # @players = {} - # @playersCollection = new RealTimeCollection('multiplayer_players/' + @levelID) - # @playersCollection.on 'add', @onPlayerAdded - # @playersCollection.each (player) => @onPlayerAdded player - Backbone.Mediator.subscribe 'real-time-multiplayer:player-status', @onMultiplayerPlayerStatus - - destroy: -> - Backbone.Mediator.unsubscribe 'real-time-multiplayer:player-status', @onMultiplayerPlayerStatus - # @playersCollection?.off 'add', @onPlayerAdded - # player.off 'change', @onPlayerChanged for id, player of @players - - onMultiplayerPlayerStatus: (e) => - @status = e.status - @statusChangedCallback() - - # onPlayerAdded: (player) => - # unless player.id is me.id - # @players[player.id] = new RealTimeModel('multiplayer_players/' + @levelID + '/' + player.id) - # @players[player.id].on 'change', @onPlayerChanged - # @countPlayers player - # - # onPlayerChanged: (player) => - # @countPlayers player - # - # countPlayers: (changedPlayer) => - # # TODO: save this stale hearbeat threshold setting somewhere - # staleHeartbeat = new Date() - # staleHeartbeat.setMinutes staleHeartbeat.getMinutes() - 3 - # @playerCount = 0 - # @playersCollectionAvailable = 0 - # @playersCollectionUnavailable = 0 - # @playersCollection.each (player) => - # # Assume changedPlayer is fresher than entry in @playersCollection collection - # player = changedPlayer if changedPlayer? and player.id is changedPlayer.id - # unless staleHeartbeat >= new Date(player.get('heartbeat')) - # @playerCount++ - # @playersCollectionAvailable++ if player.get('state') is 'available' - # @playersCollectionUnavailable++ if player.get('state') is 'unavailable' - # @statusChangedCallback() diff --git a/app/views/play/level/LevelChatView.coffee b/app/views/play/level/LevelChatView.coffee index 06c218294..122b15b7a 100644 --- a/app/views/play/level/LevelChatView.coffee +++ b/app/views/play/level/LevelChatView.coffee @@ -18,6 +18,7 @@ module.exports = class LevelChatView extends CocoView constructor: (options) -> @levelID = options.levelID @session = options.session + # TODO: we took out session.multiplayer, so this will not fire. If we want to resurrect it, we'll of course need a new way of activating chat. @listenTo(@session, 'change:multiplayer', @updateMultiplayerVisibility) @sessionID = options.sessionID @bus = LevelBus.get(@levelID, @sessionID) diff --git a/app/views/play/level/LevelFlagsView.coffee b/app/views/play/level/LevelFlagsView.coffee index 9bdb04c4a..83b2a71ce 100644 --- a/app/views/play/level/LevelFlagsView.coffee +++ b/app/views/play/level/LevelFlagsView.coffee @@ -1,9 +1,6 @@ CocoView = require 'views/core/CocoView' template = require 'templates/play/level/level-flags-view' {me} = require 'core/auth' -RealTimeCollection = require 'collections/RealTimeCollection' - -multiplayerFlagDelay = 0.5 # Long, static second delay for now; should be more than enough. module.exports = class LevelFlagsView extends CocoView id: 'level-flags-view' @@ -17,7 +14,6 @@ module.exports = class LevelFlagsView extends CocoView 'god:new-world-created': 'onNewWorld' 'god:streaming-world-updated': 'onNewWorld' 'surface:remove-flag': 'onRemoveFlag' - 'real-time-multiplayer:joined-game': 'onJoinedMultiplayerGame' events: 'click .green-flag': -> @onFlagSelected color: 'green', source: 'button' @@ -60,9 +56,8 @@ module.exports = class LevelFlagsView extends CocoView return unless @flagColor and @realTime @playSound 'menu-button-click' # TODO: different flag placement sound? pos = x: e.worldPos.x, y: e.worldPos.y - delay = if @realTimeFlags then multiplayerFlagDelay else 0 now = @world.dt * @world.frames.length - flag = player: me.id, team: me.team, color: @flagColor, pos: pos, time: now + delay, active: true, source: 'click' + flag = player: me.id, team: me.team, color: @flagColor, pos: pos, time: now, active: true, source: 'click' @flags[@flagColor] = flag @flagHistory.push flag @realTimeFlags?.create flag @@ -75,9 +70,8 @@ module.exports = class LevelFlagsView extends CocoView onRemoveFlag: (e) -> delete @flags[e.color] - delay = if @realTimeFlags then multiplayerFlagDelay else 0 now = @world.dt * @world.frames.length - flag = player: me.id, team: me.team, color: e.color, time: now + delay, active: false, source: 'click' + flag = player: me.id, team: me.team, color: e.color, time: now, active: false, source: 'click' @flagHistory.push flag Backbone.Mediator.publish 'level:flag-updated', flag #console.log e.color, 'deleted at time', flag.time @@ -85,31 +79,3 @@ module.exports = class LevelFlagsView extends CocoView onNewWorld: (event) -> return unless event.world.name is @world.name @world = @options.world = event.world - - onJoinedMultiplayerGame: (e) -> - @realTimeFlags = new RealTimeCollection("multiplayer_level_sessions/#{@levelID}/#{e.realTimeSessionID}/flagHistory") - @realTimeFlags.on 'add', @onRealTimeMultiplayerFlagAdded - @realTimeFlags.on 'remove', @onRealTimeMultiplayerFlagRemoved - - onLeftMultiplayerGame: (e) -> - if @realTimeFlags - @realTimeFlags.off 'add', @onRealTimeMultiplayerFlagAdded - @realTimeFlags.off 'remove', @onRealTimeMultiplayerFlagRemoved - @realTimeFlags = null - - onRealTimeMultiplayerFlagAdded: (e) => - if e.get('player') != me.id - # TODO: what is @flags used for? - # Build local flag from Backbone.Model flag - flag = - player: e.get('player') - team: e.get('team') - color: e.get('color') - pos: e.get('pos') - time: e.get('time') - active: e.get('active') - #source: 'click'? e.get('source')? nothing? - @flagHistory.push flag - Backbone.Mediator.publish 'level:flag-updated', flag - - onRealTimeMultiplayerFlagRemoved: (e) => diff --git a/app/views/play/level/LevelGoalsView.coffee b/app/views/play/level/LevelGoalsView.coffee index 1c37e1245..d4749ae88 100644 --- a/app/views/play/level/LevelGoalsView.coffee +++ b/app/views/play/level/LevelGoalsView.coffee @@ -49,7 +49,7 @@ module.exports = class LevelGoalsView extends CocoView goals = [] for goal in e.goals state = e.goalStates[goal.id] - continue if goal.optional and @level.get('type', true) is 'course' and state.status isnt 'success' + continue if goal.optional and @level.isType('course') and state.status isnt 'success' if goal.hiddenGoal continue if goal.optional and state.status isnt 'success' continue if not goal.optional and state.status isnt 'failure' diff --git a/app/views/play/level/LevelHUDView.coffee b/app/views/play/level/LevelHUDView.coffee index 661da3aaf..b2cad7500 100644 --- a/app/views/play/level/LevelHUDView.coffee +++ b/app/views/play/level/LevelHUDView.coffee @@ -100,7 +100,7 @@ module.exports = class LevelHUDView extends CocoView @stage?.stopTalking() createProperties: -> - if @options.level.get('type') in ['game-dev'] + if @options.level.isType('game-dev') name = 'Game' # TODO: we don't need the HUD at all else if @thang.id in ['Hero Placeholder', 'Hero Placeholder 1'] name = @thangType?.getHeroShortName() or 'Hero' diff --git a/app/views/play/level/LevelLoadingView.coffee b/app/views/play/level/LevelLoadingView.coffee index eb9024d14..6e47711a0 100644 --- a/app/views/play/level/LevelLoadingView.coffee +++ b/app/views/play/level/LevelLoadingView.coffee @@ -59,7 +59,7 @@ module.exports = class LevelLoadingView extends CocoView goalList = goalContainer.find('ul') goalCount = 0 for goalID, goal of @level.get('goals') when (not goal.team or goal.team is (e.team or 'humans')) and not goal.hiddenGoal - continue if goal.optional and @level.get('type', true) is 'course' + continue if goal.optional and @level.isType('course') name = utils.i18n goal, 'name' goalList.append $('
  • ' + name + '
  • ') ++goalCount @@ -169,7 +169,7 @@ module.exports = class LevelLoadingView extends CocoView @playSound 'loading-view-unveil', 0.5 @$el.find('.left-wing').css left: '-100%', backgroundPosition: 'right -400px top 0' @$el.find('.right-wing').css right: '-100%', backgroundPosition: 'left -400px top 0' - $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration) + $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration) unless @level.isType('web-dev') unveilIntro: => return if @destroyed or not @intro or @unveiled diff --git a/app/views/play/level/LevelPlaybackView.coffee b/app/views/play/level/LevelPlaybackView.coffee index 6547a683b..811d431a1 100644 --- a/app/views/play/level/LevelPlaybackView.coffee +++ b/app/views/play/level/LevelPlaybackView.coffee @@ -21,7 +21,6 @@ module.exports = class LevelPlaybackView extends CocoView 'tome:cast-spells': 'onTomeCast' 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' 'playback:stop-real-time-playback': 'onStopRealTimePlayback' - 'real-time-multiplayer:manual-cast': 'onRealTimeMultiplayerCast' events: 'click #music-button': 'onToggleMusic' @@ -110,11 +109,6 @@ module.exports = class LevelPlaybackView extends CocoView Backbone.Mediator.publish 'playback:real-time-playback-started', {} @playSound 'real-time-playback-start' - onRealTimeMultiplayerCast: (e) -> - @realTime = true - @togglePlaybackControls false - Backbone.Mediator.publish 'playback:real-time-playback-waiting', {} - onWindowResize: (s...) => @barWidth = $('.progress', @$el).width() diff --git a/app/views/play/level/PlayGameDevLevelView.coffee b/app/views/play/level/PlayGameDevLevelView.coffee new file mode 100644 index 000000000..9de9fb440 --- /dev/null +++ b/app/views/play/level/PlayGameDevLevelView.coffee @@ -0,0 +1,92 @@ +RootView = require 'views/core/RootView' + +GameUIState = require 'models/GameUIState' +God = require 'lib/God' +LevelLoader = require 'lib/LevelLoader' +GoalManager = require 'lib/world/GoalManager' +ScriptManager = require 'lib/scripts/ScriptManager' +Surface = require 'lib/surface/Surface' +ThangType = require 'models/ThangType' +Level = require 'models/Level' +LevelSession = require 'models/LevelSession' +State = require 'models/State' + +TEAM = 'humans' + +module.exports = class PlayGameDevLevelView extends RootView + id: 'play-game-dev-level-view' + template: require 'templates/play/level/play-game-dev-level-view' + + events: + 'click #play-btn': 'onClickPlayButton' + + initialize: (@options, @levelID, @sessionID) -> + @state = new State({ + loading: true + progress: 0 + }) + + @supermodel.on 'update-progress', (progress) => + @state.set({progress: (progress*100).toFixed(1)+'%'}) + @level = new Level() + @session = new LevelSession() + @gameUIState = new GameUIState() + @courseID = @getQueryVariable 'course' + @god = new God({ @gameUIState }) + @levelLoader = new LevelLoader({ @supermodel, @levelID, @sessionID, observing: true, team: TEAM, @courseID }) + @listenTo @state, 'change', _.debounce(-> @renderSelectors('#info-col')) + + @levelLoader.loadWorldNecessities() + + .then (levelLoader) => + { @level, @session, @world } = levelLoader + @god.setLevel(@level.serialize {@supermodel, @session}) + @god.setWorldClassMap(@world.classMap) + @goalManager = new GoalManager(@world, @level.get('goals'), @team) + @god.setGoalManager(@goalManager) + @god.angelsShare.firstWorld = false # HACK + me.team = TEAM + @session.set 'team', TEAM + @scriptManager = new ScriptManager({ + scripts: @world.scripts or [], view: @, @session, levelID: @level.get('slug')}) + @scriptManager.loadFromSession() # Should we? TODO: Figure out how scripts work for game dev levels + @supermodel.finishLoading() + + .then (supermodel) => + @levelLoader.destroy() + @levelLoader = null + webGLSurface = @$('canvas#webgl-surface') + normalSurface = @$('canvas#normal-surface') + @surface = new Surface(@world, normalSurface, webGLSurface, { + thangTypes: @supermodel.getModels(ThangType) + levelType: @level.get('type', true) + @gameUIState + }) + worldBounds = @world.getBounds() + bounds = [{x: worldBounds.left, y: worldBounds.top}, {x: worldBounds.right, y: worldBounds.bottom}] + @surface.camera.setBounds(bounds) + @surface.camera.zoomTo({x: 0, y: 0}, 0.1, 0) + @surface.setWorld(@world) + @scriptManager.initializeCamera() + @renderSelectors '#info-col' + @spells = @session.generateSpellsObject level: @level + @state.set('loading', false) + + .catch ({message}) => + console.error message + @state.set('errorMessage', message) + + onClickPlayButton: -> + @god.createWorld(@spells, false, true) + Backbone.Mediator.publish('playback:real-time-playback-started', {}) + Backbone.Mediator.publish('level:set-playing', {playing: true}) + @state.set('playing', true) + + destroy: -> + @levelLoader?.destroy() + @surface?.destroy() + @god?.destroy() + @goalManager?.destroy() + @scriptManager?.destroy() + delete window.world # not sure where this is set, but this is one way to clean it up + super() diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index 45340f0d7..a775a2d53 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -44,6 +44,7 @@ LevelSetupManager = require 'lib/LevelSetupManager' ContactModal = require 'views/core/ContactModal' HintsView = require './HintsView' HintsState = require './HintsState' +WebSurfaceView = require './WebSurfaceView' PROFILE_ME = false @@ -72,14 +73,10 @@ module.exports = class PlayLevelView extends RootView 'level:started': 'onLevelStarted' 'level:loading-view-unveiling': 'onLoadingViewUnveiling' 'level:loading-view-unveiled': 'onLoadingViewUnveiled' + 'level:loaded': 'onLevelLoaded' 'level:session-loaded': 'onSessionLoaded' - 'playback:real-time-playback-waiting': 'onRealTimePlaybackWaiting' 'playback:real-time-playback-started': 'onRealTimePlaybackStarted' 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' - 'real-time-multiplayer:created-game': 'onRealTimeMultiplayerCreatedGame' - 'real-time-multiplayer:joined-game': 'onRealTimeMultiplayerJoinedGame' - 'real-time-multiplayer:left-game': 'onRealTimeMultiplayerLeftGame' - 'real-time-multiplayer:manual-cast': 'onRealTimeMultiplayerCast' 'ipad:memory-warning': 'onIPadMemoryWarning' 'store:item-purchased': 'onItemPurchased' @@ -137,7 +134,6 @@ module.exports = class PlayLevelView extends RootView load: -> @loadStartTime = new Date() - @god = new God({@gameUIState}) levelLoaderOptions = supermodel: @supermodel, levelID: @levelID, sessionID: @sessionID, opponentSessionID: @opponentSessionID, team: @getQueryVariable('team'), observing: @observing, courseID: @courseID if me.isSessionless() levelLoaderOptions.fakeSessionConfig = {} @@ -145,6 +141,10 @@ module.exports = class PlayLevelView extends RootView @listenToOnce @levelLoader, 'world-necessities-loaded', @onWorldNecessitiesLoaded @listenTo @levelLoader, 'world-necessity-load-failed', @onWorldNecessityLoadFailed + onLevelLoaded: (e) -> + @god = new God({@gameUIState}) unless e.level.isType('web-dev') + @setUpGod() if @waitingToSetUpGod + trackLevelLoadEnd: -> return if @isEditorPreview @loadEndTime = new Date() @@ -185,15 +185,13 @@ module.exports = class PlayLevelView extends RootView onWorldNecessitiesLoaded: -> # Called when we have enough to build the world, but not everything is loaded @grabLevelLoaderData() - team = @getQueryVariable('team') ? @session.get('team') ? @world.teamForPlayer(0) + team = @getQueryVariable('team') ? @session.get('team') ? @world?.teamForPlayer(0) ? 'humans' @loadOpponentTeam(team) @setupGod() @setTeam team @initGoalManager() @insertSubviews() @initVolume() - @listenTo(@session, 'change:multiplayer', @onMultiplayerChanged) - @register() @controlBar.setBus(@bus) @initScriptManager() @@ -203,9 +201,12 @@ module.exports = class PlayLevelView extends RootView grabLevelLoaderData: -> @session = @levelLoader.session - @world = @levelLoader.world @level = @levelLoader.level - @$el.addClass 'hero' if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + if @level.isType('web-dev') + @$el.addClass 'web-dev' # Hide some of the elements we won't be using + return + @world = @levelLoader.world + @$el.addClass 'hero' if @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev') # TODO: figure out what this does and comment it @$el.addClass 'flags' if _.any(@world.thangs, (t) -> (t.programmableProperties and 'findFlags' in t.programmableProperties) or t.inventory?.flag) or @level.get('slug') is 'sky-span' # TODO: Update terminology to always be opponentSession or otherSession # TODO: E.g. if it's always opponent right now, then variable names should be opponentSession until we have coop play @@ -239,11 +240,11 @@ module.exports = class PlayLevelView extends RootView myCode[thang] ?= {} if c then myCode[thang][spell] = c else delete myCode[thang][spell] @session.set('code', myCode) - if @session.get('multiplayer') and @otherSession? - # For now, ladderGame will disallow multiplayer, because session code combining doesn't play nice yet. - @session.set 'multiplayer', false setupGod: -> + return if @level.isType('web-dev') + return @waitingToSetUpGod = true unless @god + @waitingToSetUpGod = undefined @god.setLevel @level.serialize {@supermodel, @session, @otherSession, headless: false, sessionless: false} @god.setLevelSessionIDs if @otherSession then [@session.id, @otherSession.id] else [@session.id] @god.setWorldClassMap @world.classMap @@ -258,22 +259,23 @@ module.exports = class PlayLevelView extends RootView initGoalManager: -> @goalManager = new GoalManager(@world, @level.get('goals'), @team) - @god.setGoalManager @goalManager + @god?.setGoalManager @goalManager insertSubviews: -> @hintsState = new HintsState({ hidden: true }, { @session, @level }) - @insertSubView @tome = new TomeView { @levelID, @session, @otherSession, thangs: @world.thangs, @supermodel, @level, @observing, @courseID, @courseInstanceID, @god, @hintsState } - @insertSubView new LevelPlaybackView session: @session, level: @level + @insertSubView @tome = new TomeView { @levelID, @session, @otherSession, thangs: @world?.thangs ? [], @supermodel, @level, @observing, @courseID, @courseInstanceID, @god, @hintsState } + @insertSubView new LevelPlaybackView session: @session, level: @level unless @level.isType('web-dev') @insertSubView new GoalsView {level: @level} @insertSubView new LevelFlagsView levelID: @levelID, world: @world if @$el.hasClass 'flags' - @insertSubView new GoldView {} unless @level.get('slug') in ['wakka-maul'] - @insertSubView new HUDView {level: @level} + @insertSubView new GoldView {} unless @level.get('slug') in ['wakka-maul'] unless @level.isType('web-dev') + @insertSubView new HUDView {level: @level} unless @level.isType('web-dev') @insertSubView new LevelDialogueView {level: @level, sessionID: @session.id} @insertSubView new ChatView levelID: @levelID, sessionID: @session.id, session: @session @insertSubView new ProblemAlertView session: @session, level: @level, supermodel: @supermodel - @insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.get('type') in ['hero-ladder', 'course-ladder'] + @insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.isType('hero-ladder', 'course-ladder') @insertSubView @controlBar = new ControlBarView {worldName: utils.i18n(@level.attributes, 'name'), session: @session, level: @level, supermodel: @supermodel, courseID: @courseID, courseInstanceID: @courseInstanceID} @insertSubView @hintsView = new HintsView({ @session, @level, @hintsState }), @$('.hints-view') + @insertSubView @webSurface = new WebSurfaceView {level: @level, @goalManager} if @level.isType('web-dev') #_.delay (=> Backbone.Mediator.publish('level:set-debug', debug: true)), 5000 if @isIPadApp() # if me.displayName() is 'Nick' initVolume: -> @@ -282,6 +284,7 @@ module.exports = class PlayLevelView extends RootView Backbone.Mediator.publish 'level:set-volume', volume: volume initScriptManager: -> + return if @level.isType('web-dev') @scriptManager = new ScriptManager({scripts: @world.scripts or [], view: @, session: @session, levelID: @level.get('slug')}) @scriptManager.loadFromSession() @@ -289,9 +292,7 @@ module.exports = class PlayLevelView extends RootView @bus = LevelBus.get(@levelID, @session.id) @bus.setSession(@session) @bus.setSpells @tome.spells - if @session.get('multiplayer') and not me.isAdmin() - @session.set 'multiplayer', false # Temp: multiplayer has bugged out some sessions, so ignoring it. - @bus.connect() if @session.get('multiplayer') + #@bus.connect() if @session.get('multiplayer') # TODO: session's multiplayer flag removed; connect bus another way if we care about it # Load Completed Setup ###################################################### @@ -310,13 +311,11 @@ module.exports = class PlayLevelView extends RootView else if e.level.get('slug') is 'assembly-speed' raider = '55527eb0b8abf4ba1fe9a107' e.session.set 'heroConfig', {"thangType":raider,"inventory":{}} - else if e.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] and not _.size e.session.get('heroConfig')?.inventory ? {} + else if e.level.isType('hero', 'hero-ladder', 'hero-coop') and not _.size e.session.get('heroConfig')?.inventory ? {} @setupManager?.destroy() @setupManager = new LevelSetupManager({supermodel: @supermodel, level: e.level, levelID: @levelID, parent: @, session: e.session, courseID: @courseID, courseInstanceID: @courseInstanceID}) @setupManager.open() - @onRealTimeMultiplayerLevelLoaded e.session if e.level.get('type') in ['hero-ladder', 'course-ladder'] - onLoaded: -> _.defer => @onLevelLoaderLoaded() @@ -325,14 +324,17 @@ module.exports = class PlayLevelView extends RootView return unless @levelLoader.progress() is 1 # double check, since closing the guide may trigger this early # Save latest level played. - if not @observing and not (@levelLoader.level.get('type') in ['ladder', 'ladder-tutorial']) + if not @observing and not (@levelLoader.level.isType('ladder', 'ladder-tutorial')) me.set('lastLevel', @levelID) me.save() application.tracker?.identify() @saveRecentMatch() if @otherSession @levelLoader.destroy() @levelLoader = null - @initSurface() + if @level.isType('web-dev') + Backbone.Mediator.publish 'level:started', {} + else + @initSurface() saveRecentMatch: -> allRecentlyPlayedMatches = storage.load('recently-played-matches') ? {} @@ -360,7 +362,7 @@ module.exports = class PlayLevelView extends RootView @surface.camera.zoomTo({x: 0, y: 0}, 0.1, 0) findPlayerNames: -> - return {} unless @level.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] + return {} unless @level.isType('ladder', 'hero-ladder', 'course-ladder') playerNames = {} for session in [@session, @otherSession] when session?.get('team') playerNames[session.get('team')] = session.get('creatorName') or 'Anonymous' @@ -369,33 +371,30 @@ module.exports = class PlayLevelView extends RootView # Once Surface is Loaded #################################################### onLevelStarted: -> - return unless @surface? + return unless @surface? or @webSurface? @loadingView.showReady() @trackLevelLoadEnd() if window.currentModal and not window.currentModal.destroyed and window.currentModal.constructor isnt VictoryModal return Backbone.Mediator.subscribeOnce 'modal:closed', @onLevelStarted, @ - @surface.showLevel() + @surface?.showLevel() Backbone.Mediator.publish 'level:set-time', time: 0 if (@isEditorPreview or @observing) and not @getQueryVariable('intro') @loadingView.startUnveiling() @loadingView.unveil true else - @scriptManager.initializeCamera() + @scriptManager?.initializeCamera() onLoadingViewUnveiling: (e) -> @selectHero() onLoadingViewUnveiled: (e) -> - if @level.get('type') in ['course-ladder', 'hero-ladder'] or @observing + if @level.isType('course-ladder', 'hero-ladder') or @observing # We used to autoplay by default, but now we only do it if the level says to in the introduction script. Backbone.Mediator.publish 'level:set-playing', playing: true @loadingView.$el.remove() @removeSubView @loadingView @loadingView = null @playAmbientSound() - if @options.realTimeMultiplayerSessionID? - Backbone.Mediator.publish 'playback:real-time-playback-waiting', {} - @realTimeMultiplayerContinueGame @options.realTimeMultiplayerSessionID # TODO: Is it possible to create a Mongoose ObjectId for 'ls', instead of the string returned from get()? application.tracker?.trackEvent 'Started Level', category:'Play Level', levelID: @levelID, ls: @session?.get('_id') unless @observing $(window).trigger 'resize' @@ -423,7 +422,7 @@ module.exports = class PlayLevelView extends RootView Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true Backbone.Mediator.publish 'tome:select-primary-sprite', {} Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false - @surface.focusOnHero() + @surface?.focusOnHero() perhapsStartSimulating: -> return unless @shouldSimulate() @@ -440,7 +439,7 @@ module.exports = class PlayLevelView extends RootView simulateNextGame: -> return @simulator.fetchAndSimulateOneGame() if @simulator simulatorOptions = background: true, leagueID: @courseInstanceID - simulatorOptions.levelID = @level.get('slug') if @level.get('type', true) in ['course-ladder', 'hero-ladder'] + simulatorOptions.levelID = @level.get('slug') if @level.isType('course-ladder', 'hero-ladder') @simulator = new Simulator simulatorOptions # Crude method of mitigating Simulator memory leak issues fetchAndSimulateOneGameOriginal = @simulator.fetchAndSimulateOneGame @@ -462,31 +461,30 @@ module.exports = class PlayLevelView extends RootView cores = window.navigator.hardwareConcurrency or defaultCores # Available on Chrome/Opera, soon Safari defaultHeapLimit = 793000000 heapLimit = window.performance?.memory?.jsHeapSizeLimit or defaultHeapLimit # Only available on Chrome, basically just says 32- vs. 64-bit - levelType = @level.get 'type', true gamesSimulated = me.get('simulatedBy') console.debug "Should we start simulating? Cores:", window.navigator.hardwareConcurrency, "Heap limit:", window.performance?.memory?.jsHeapSizeLimit, "Load duration:", @loadDuration return false unless $.browser?.desktop return false if $.browser?.msie or $.browser?.msedge return false if $.browser.linux return false if me.level() < 8 - if levelType in ['course', 'game-dev'] + if @level.isType('course', 'game-dev', 'web-dev') return false - else if levelType is 'hero' and gamesSimulated + else if @level.isType('hero') and gamesSimulated return false if stillBuggy return false if cores < 8 return false if heapLimit < defaultHeapLimit return false if @loadDuration > 10000 - else if levelType is 'hero-ladder' and gamesSimulated + else if @level.isType('hero-ladder') and gamesSimulated return false if stillBuggy return false if cores < 4 return false if heapLimit < defaultHeapLimit return false if @loadDuration > 15000 - else if levelType is 'hero-ladder' and not gamesSimulated + else if @level.isType('hero-ladder') and not gamesSimulated return false if stillBuggy return false if cores < 8 return false if heapLimit <= defaultHeapLimit return false if @loadDuration > 20000 - else if levelType is 'course-ladder' + else if @level.isType('course-ladder') return false if cores <= defaultCores return false if heapLimit < defaultHeapLimit return false if @loadDuration > 18000 @@ -542,7 +540,7 @@ module.exports = class PlayLevelView extends RootView onDonePressed: -> @showVictory() onShowVictory: (e) -> - $('#level-done-button').show() unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + $('#level-done-button').show() unless @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') @showVictory() if e.showModal return if @victorySeen @victorySeen = true @@ -560,8 +558,11 @@ module.exports = class PlayLevelView extends RootView return if @level.hasLocalChanges() # Don't award achievements when beating level changed in level editor @endHighlight() options = {level: @level, supermodel: @supermodel, session: @session, hasReceivedMemoryWarning: @hasReceivedMemoryWarning, courseID: @courseID, courseInstanceID: @courseInstanceID, world: @world} - ModalClass = if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] then HeroVictoryModal else VictoryModal + ModalClass = if @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') then HeroVictoryModal else VictoryModal ModalClass = CourseVictoryModal if @isCourseMode() or me.isSessionless() + if @level.isType('course-ladder') + ModalClass = CourseVictoryModal + options.courseInstanceID = @getQueryVariable 'league' ModalClass = PicoCTFVictoryModal if window.serverConfig.picoCTF victoryModal = new ModalClass(options) @openModalView(victoryModal) @@ -585,13 +586,6 @@ module.exports = class PlayLevelView extends RootView onFocusDom: (e) -> $(e.selector).focus() - onMultiplayerChanged: (e) -> - if @session.get('multiplayer') - @bus.connect() - else - @bus.removeFirebaseData => - @bus.disconnect() - onContactClicked: (e) -> Backbone.Mediator.publish 'level:contact-button-pressed', {} @openModalView contactModal = new ContactModal levelID: @level.get('slug') or @level.id, courseID: @courseID, courseInstanceID: @courseInstanceID @@ -630,10 +624,6 @@ module.exports = class PlayLevelView extends RootView AudioPlayer.preloadSoundReference sound # Real-time playback - onRealTimePlaybackWaiting: (e) -> - @$el.addClass('real-time').focus() - @onWindowResize() - onRealTimePlaybackStarted: (e) -> @$el.addClass('real-time').focus() @onWindowResize() @@ -646,14 +636,12 @@ module.exports = class PlayLevelView extends RootView _.delay @onSubmissionComplete, 750 # Wait for transition to end. else @waitingForSubmissionComplete = true - @onRealTimeMultiplayerPlaybackEnded() onSubmissionComplete: => return if @destroyed Backbone.Mediator.publish 'level:set-time', ratio: 1 return if @level.hasLocalChanges() # Don't award achievements when beating level changed in level editor - # TODO: Show a victory dialog specific to hero-ladder level - if @goalManager.checkOverallStatus() is 'success' and not @options.realTimeMultiplayerSessionID? + if @goalManager.checkOverallStatus() is 'success' showModalFn = -> Backbone.Mediator.publish 'level:show-victory', showModal: true @session.recordScores @world.scores, @level if @level.get 'replayable' @@ -678,7 +666,6 @@ module.exports = class PlayLevelView extends RootView #@instance.save() unless @instance.loading delete window.nextURL console.profileEnd?() if PROFILE_ME - @onRealTimeMultiplayerLevelUnloaded() application.tracker?.disableInspectletJS() super() @@ -694,358 +681,3 @@ module.exports = class PlayLevelView extends RootView @setupManager?.destroy() @setupManager = new LevelSetupManager({supermodel: @supermodel, level: @level, levelID: @levelID, parent: @, session: @session, hadEverChosenHero: true}) @setupManager.open() - - # Start Real-time Multiplayer ###################################################### - # - # This view acts as a hub for the real-time multiplayer session for the current level. - # - # It performs these actions: - # Player heartbeat - # Publishes player status - # Updates real-time multiplayer session state - # Updates real-time multiplayer player state - # Cleans up old sessions (sets state to 'finished') - # Real-time multiplayer cast handshake - # Swap teams on game joined, if necessary - # Reload PlayLevelView on real-time submit, automatically continue game and real-time playback - # - # It monitors these: - # Real-time multiplayer sessions - # Current real-time multiplayer session - # Internal multiplayer create/joined/left events - # - # Real-time state variables. - # Each Ref is Firebase reference, and may have a matching Data suffixed variable with the latest data received. - # @realTimePlayerRef - User's real-time multiplayer player for this level - # @realTimePlayerGameRef - User's current real-time multiplayer player game session - # @realTimeSessionRef - Current real-time multiplayer game session - # @realTimeOpponentRef - Current real-time multiplayer opponent - # @realTimePlayersRef - Real-time players for current real-time multiplayer game session - # @options.realTimeMultiplayerSessionID - Need to continue an existing real-time multiplayer session - # - # TODO: Move this code to it's own file, or possibly the LevelBus - # TODO: Save settings somewhere reasonable - multiplayerFireHost: 'https://codecombat.firebaseio.com/test/db/' - - onRealTimeMultiplayerLevelLoaded: (session) -> - # console.log 'PlayLevelView onRealTimeMultiplayerLevelLoaded' - return if @realTimePlayerRef? - return if me.get('anonymous') - @realTimePlayerRef = new Firebase "#{@multiplayerFireHost}multiplayer_players/#{@levelID}/#{me.id}" - unless @options.realTimeMultiplayerSessionID? - # TODO: Wait for name instead of using 'Anon', or try and update it later? - name = me.get('name') ? session.get('creatorName') ? 'Anon' - @realTimePlayerRef.set - id: me.id # TODO: is this redundant info necessary? - name: name - state: 'playing' - created: new Date().toISOString() - heartbeat: new Date().toISOString() - @timerMultiplayerHeartbeatID = setInterval @onRealTimeMultiplayerHeartbeat, 60 * 1000 - @cleanupRealTimeSessions() - - cleanupRealTimeSessions: -> - # console.log 'PlayLevelView cleanupRealTimeSessions' - # TODO: Reduce this call, possibly by username and dates - realTimeSessionCollection = new Firebase "#{@multiplayerFireHost}multiplayer_level_sessions/#{@levelID}" - realTimeSessionCollection.once 'value', (collectionSnapshot) => - for multiplayerSessionID, multiplayerSession of collectionSnapshot.val() - continue if @options.realTimeMultiplayerSessionID? and @options.realTimeMultiplayerSessionID is multiplayerSessionID - continue unless multiplayerSession.state isnt 'finished' - player = realTimeSessionCollection.child "#{multiplayerSession.id}/players/#{me.id}" - player.once 'value', (playerSnapshot) => - if playerSnapshot.val() - console.info 'Cleaning up previous real-time multiplayer session', multiplayerSessionID - player.update 'state': 'left' - multiplayerSessionRef = realTimeSessionCollection.child "#{multiplayerSessionID}" - multiplayerSessionRef.update 'state': 'finished' - - onRealTimeMultiplayerLevelUnloaded: -> - # console.log 'PlayLevelView onRealTimeMultiplayerLevelUnloaded' - if @timerMultiplayerHeartbeatID? - clearInterval @timerMultiplayerHeartbeatID - @timerMultiplayerHeartbeatID = null - - # TODO: similar to game ending cleanup - if @realTimeOpponentRef? - @realTimeOpponentRef.off 'value', @onRealTimeOpponentChanged - @realTimeOpponentRef = null - if @realTimePlayersRef? - @realTimePlayersRef.off 'child_added', @onRealTimePlayerAdded - @realTimePlayersRef = null - if @realTimeSessionRef? - @realTimeSessionRef.off 'value', @onRealTimeSessionChanged - @realTimeSessionRef = null - if @realTimePlayerGameRef? - @realTimePlayerGameRef = null - if @realTimePlayerRef? - @realTimePlayerRef = null - - onRealTimeMultiplayerHeartbeat: => - # console.log 'PlayLevelView onRealTimeMultiplayerHeartbeat', @realTimePlayerRef - @realTimePlayerRef.update 'heartbeat': new Date().toISOString() if @realTimePlayerRef? - - onRealTimeMultiplayerCreatedGame: (e) -> - # console.log 'PlayLevelView onRealTimeMultiplayerCreatedGame' - @joinRealTimeMultiplayerGame e - @realTimePlayerGameRef.update 'state': 'coding' - @realTimePlayerRef.update 'state': 'available' - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Waiting for opponent..' - - onRealTimeSessionChanged: (snapshot) => - # console.log 'PlayLevelView onRealTimeSessionChanged', snapshot.val() - @realTimeSessionData = snapshot.val() - if @realTimeSessionData?.state is 'finished' - @realTimeGameEnded() - Backbone.Mediator.publish 'real-time-multiplayer:left-game', {} - - onRealTimePlayerAdded: (snapshot) => - # console.log 'PlayLevelView onRealTimePlayerAdded', snapshot.val() - # Assume game is full, game on - data = snapshot.val() - if data? and data.id isnt me.id - @realTimeOpponentData = data - # console.log 'PlayLevelView onRealTimePlayerAdded opponent', @realTimeOpponentData, @realTimePlayersData - @realTimePlayersData[@realTimeOpponentData.id] = @realTimeOpponentData - if @realTimeSessionData?.state is 'creating' - @realTimeSessionRef.update 'state': 'coding' - @realTimePlayerRef.update 'state': 'unavailable' - @realTimeOpponentRef = @realTimeSessionRef.child "players/#{@realTimeOpponentData.id}" - @realTimeOpponentRef.on 'value', @onRealTimeOpponentChanged - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: "Playing against #{@realTimeOpponentData.name}" - - onRealTimeOpponentChanged: (snapshot) => - # console.log 'PlayLevelView onRealTimeOpponentChanged', snapshot.val() - @realTimeOpponentData = snapshot.val() - switch @realTimeOpponentData?.state - when 'left' - console.info 'Real-time multiplayer opponent left the game' - opponentID = @realTimeOpponentData.id - @realTimeGameEnded() - Backbone.Mediator.publish 'real-time-multiplayer:left-game', userID: opponentID - when 'submitted' - # TODO: What should this message say? - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: "#{@realTimeOpponentData.name} waiting for your code" - - joinRealTimeMultiplayerGame: (e) -> - # console.log 'PlayLevelView joinRealTimeMultiplayerGame', e - unless @realTimeSessionRef? - @session.set('submittedCodeLanguage', @session.get('codeLanguage')) - @session.save() - - @realTimeSessionRef = new Firebase "#{@multiplayerFireHost}multiplayer_level_sessions/#{@levelID}/#{e.realTimeSessionID}" - @realTimePlayersRef = @realTimeSessionRef.child 'players' - - # Look for opponent - @realTimeSessionRef.once 'value', (multiplayerSessionSnapshot) => - if @realTimeSessionData = multiplayerSessionSnapshot.val() - @realTimePlayersRef.once 'value', (playsSnapshot) => - if @realTimePlayersData = playsSnapshot.val() - for id, player of @realTimePlayersData - if id isnt me.id - @realTimeOpponentRef = @realTimeSessionRef.child "players/#{id}" - @realTimeOpponentRef.once 'value', (opponentSnapshot) => - if @realTimeOpponentData = opponentSnapshot.val() - @updateTeam() - else - console.error 'Could not lookup multiplayer opponent data.' - @realTimeOpponentRef.on 'value', @onRealTimeOpponentChanged - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Playing against ' + player.name - else - console.error 'Could not lookup multiplayer session players data.' - # TODO: need child_removed too? - @realTimePlayersRef.on 'child_added', @onRealTimePlayerAdded - else - console.error 'Could not lookup multiplayer session data.' - @realTimeSessionRef.on 'value', @onRealTimeSessionChanged - - @realTimePlayerGameRef = @realTimeSessionRef.child "players/#{me.id}" - - # TODO: Follow up in MultiplayerView to see if double joins can be avoided - # else - # console.error 'Joining real-time multiplayer game with an existing @realTimeSessionRef.' - - onRealTimeMultiplayerJoinedGame: (e) -> - # console.log 'PlayLevelView onRealTimeMultiplayerJoinedGame', e - @joinRealTimeMultiplayerGame e - @realTimePlayerGameRef.update 'state': 'coding' - @realTimePlayerRef.update 'state': 'unavailable' - - onRealTimeMultiplayerLeftGame: (e) -> - # console.log 'PlayLevelView onRealTimeMultiplayerLeftGame', e - if e.userID? and e.userID is me.id - @realTimePlayerGameRef.update 'state': 'left' - @realTimeGameEnded() - - realTimeMultiplayerContinueGame: (realTimeSessionID) -> - # console.log 'PlayLevelView realTimeMultiplayerContinueGame', realTimeSessionID, me.id - Backbone.Mediator.publish 'real-time-multiplayer:joined-game', realTimeSessionID: realTimeSessionID - - console.info 'Setting my game status to ready' - @realTimePlayerGameRef.update 'state': 'ready' - - if @realTimeOpponentData.state is 'ready' - @realTimeOpponentIsReady() - else - console.info 'Waiting for opponent to be ready' - @realTimeOpponentRef.on 'value', @realTimeOpponentMaybeReady - - realTimeOpponentMaybeReady: (snapshot) => - # console.log 'PlayLevelView realTimeOpponentMaybeReady' - if @realTimeOpponentData = snapshot.val() - if @realTimeOpponentData.state is 'ready' - @realTimeOpponentRef.off 'value', @realTimeOpponentMaybeReady - @realTimeOpponentIsReady() - - realTimeOpponentIsReady: => - console.info 'All real-time multiplayer players are ready!' - @realTimeSessionRef.update 'state': 'running' - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: 'Battling ' + @realTimeOpponentData.name - Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} - - realTimeGameEnded: -> - if @realTimeOpponentRef? - @realTimeOpponentRef.off 'value', @onRealTimeOpponentChanged - @realTimeOpponentRef = null - if @realTimePlayersRef? - @realTimePlayersRef.off 'child_added', @onRealTimePlayerAdded - @realTimePlayersRef = null - if @realTimeSessionRef? - @realTimeSessionRef.off 'value', @onRealTimeSessionChanged - @realTimeSessionRef.update 'state': 'finished' - @realTimeSessionRef = null - if @realTimePlayerGameRef? - @realTimePlayerGameRef = null - if @realTimePlayerRef? - @realTimePlayerRef.update 'state': 'playing' - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: '' - - onRealTimeMultiplayerCast: (e) -> - # console.log 'PlayLevelView onRealTimeMultiplayerCast', @realTimeSessionData, @realTimePlayersData - unless @realTimeSessionRef? - console.error 'Real-time multiplayer cast without multiplayer session.' - return - unless @realTimeSessionData? - console.error 'Real-time multiplayer cast without multiplayer data.' - return - unless @realTimePlayersData? - console.error 'Real-time multiplayer cast without multiplayer players data.' - return - - # Set submissionCount for created real-time multiplayer session - if me.id is @realTimeSessionData.creator - sessionState = @session.get('state') - if sessionState? - submissionCount = sessionState.submissionCount ? 0 - console.info 'Setting multiplayer submissionCount to', submissionCount - @realTimeSessionRef.update 'submissionCount': submissionCount - else - console.error 'Failed to read sessionState in onRealTimeMultiplayerCast' - - console.info 'Submitting my code' - permissions = @session.get 'permissions' ? [] - unless _.find(permissions, (p) -> p.target is 'public' and p.access is 'read') - permissions.push target:'public', access:'read' - @session.set 'permissions', permissions - @session.patch() - @realTimePlayerGameRef.update 'state': 'submitted' - - console.info 'Other player is', @realTimeOpponentData.state - if @realTimeOpponentData.state in ['submitted', 'ready'] - @realTimeOpponentSubmittedCode @realTimeOpponentData, @realTimePlayerGameData - else - # Wait for opponent to submit their code - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: "Waiting for code from #{@realTimeOpponentData.name}" - @realTimeOpponentRef.on 'value', @realTimeOpponentMaybeSubmitted - - realTimeOpponentMaybeSubmitted: (snapshot) => - if @realTimeOpponentData = snapshot.val() - if @realTimeOpponentData.state in ['submitted', 'ready'] - @realTimeOpponentRef.off 'value', @realTimeOpponentMaybeSubmitted - @realTimeOpponentSubmittedCode @realTimeOpponentData, @realTimePlayerGameData - - onRealTimeMultiplayerPlaybackEnded: -> - # console.log 'PlayLevelView onRealTimeMultiplayerPlaybackEnded' - if @realTimeSessionRef? - @realTimeSessionRef.update 'state': 'coding' - @realTimePlayerGameRef.update 'state': 'coding' - if @realTimeOpponentData? - Backbone.Mediator.publish 'real-time-multiplayer:player-status', status: "Playing against #{@realTimeOpponentData.name}" - - realTimeOpponentSubmittedCode: (opponentPlayer, myPlayer) => - # console.log 'PlayLevelView realTimeOpponentSubmittedCode', @realTimeSessionData.id, opponentPlayer.level_session - # Read submissionCount for joined real-time multiplayer session - if me.id isnt @realTimeSessionData.creator - sessionState = @session.get('state') ? {} - newSubmissionCount = @realTimeSessionData.submissionCount - if newSubmissionCount? - # TODO: This isn't always getting updated where the random seed generation uses it. - sessionState.submissionCount = parseInt newSubmissionCount - console.info 'Got multiplayer submissionCount', sessionState.submissionCount - @session.set 'state', sessionState - @session.patch() - - # Reload this level so the opponent session can easily be wired up - Backbone.Mediator.publish 'router:navigate', - route: "/play/level/#{@levelID}" - viewClass: PlayLevelView - viewArgs: [{supermodel: @supermodel, autoUnveil: true, realTimeMultiplayerSessionID: @realTimeSessionData.id, opponent: opponentPlayer.level_session, team: @team}, @levelID] - - updateTeam: -> - # If not creator, and same team as creator, then switch teams - # TODO: Assumes there are only 'humans' and 'ogres' - - unless @realTimeOpponentData? - console.error 'Tried to switch teams without real-time multiplayer opponent data.' - return - unless @realTimeSessionData? - console.error 'Tried to switch teams without real-time multiplayer session data.' - return - return if me.id is @realTimeSessionData.creator - - oldTeam = @realTimeOpponentData.team - return unless oldTeam is @session.get('team') - - # Need to switch to other team - newTeam = if oldTeam is 'humans' then 'ogres' else 'humans' - console.info "Switching from team #{oldTeam} to #{newTeam}" - - # Move code from old team to new team - # Assumes teamSpells has matching spells for each team - # TODO: Similar to code in loadOpponentTeam, consolidate? - code = @session.get 'code' - teamSpells = @session.get 'teamSpells' - for oldSpellKey in teamSpells[oldTeam] - [oldThang, oldSpell] = oldSpellKey.split '/' - oldCode = code[oldThang]?[oldSpell] - continue unless oldCode? - # Move oldCode to new team under same spell - for newSpellKey in teamSpells[newTeam] - [newThang, newSpell] = newSpellKey.split '/' - if newSpell is oldSpell - # Found spell location under new team - # console.log "Swapping spell=#{oldSpell} from #{oldThang} to #{newThang}" - if code[newThang]?[oldSpell]? - # Option 1: have a new spell to swap - code[oldThang][oldSpell] = code[newThang][oldSpell] - else - # Option 2: no new spell to swap - delete code[oldThang][oldSpell] - code[newThang] = {} unless code[newThang]? - code[newThang][oldSpell] = oldCode - break - - @setTeam newTeam # Sets @session 'team' - sessionState = @session.get('state') - if sessionState? - # TODO: Don't hard code thangID - sessionState.selected = if newTeam is 'humans' then 'Hero Placeholder' else 'Hero Placeholder 1' - @session.set 'state', sessionState - @session.set 'code', code - @session.patch() - - if sessionState? - # TODO: Don't hardcode spellName - Backbone.Mediator.publish 'level:select-sprite', thangID: sessionState.selected, spellName: 'plan' - -# End Real-time Multiplayer ###################################################### diff --git a/app/views/play/level/PlayWebDevLevelView.coffee b/app/views/play/level/PlayWebDevLevelView.coffee new file mode 100644 index 000000000..40d99e3f7 --- /dev/null +++ b/app/views/play/level/PlayWebDevLevelView.coffee @@ -0,0 +1,35 @@ +RootView = require 'views/core/RootView' + +Level = require 'models/Level' +LevelSession = require 'models/LevelSession' +WebSurfaceView = require './WebSurfaceView' + +module.exports = class PlayWebDevLevelView extends RootView + id: 'play-web-dev-level-view' + template: require 'templates/play/level/play-web-dev-level-view' + + initialize: (@options, @levelID, @sessionID) -> + @courseID = @getQueryVariable 'course' + @level = @supermodel.loadModel(new Level _id: @levelID).model + @session = @supermodel.loadModel(new LevelSession _id: @sessionID).model + + onLoaded: -> + super() + @insertSubView @webSurface = new WebSurfaceView {level: @level} + Backbone.Mediator.publish 'tome:html-updated', html: @getHTML() ? '

    Player has no HTML

    ', create: true + @$el.find('#info-bar').delay(4000).fadeOut(2000) + $('body').css('overflow', 'hidden') # Don't show tiny scroll bar from our minimal additions to the iframe + + showError: (jqxhr) -> + $('h1').text jqxhr.statusText + + getHTML: -> + playerHTML = @session.get('code')?['hero-placeholder']?.plan + return playerHTML unless hero = _.find @level.get('thangs'), id: 'Hero Placeholder' + return playerHTML unless programmableConfig = _.find(hero.components, (component) -> component.config?.programmableMethods).config + return programmableConfig.programmableMethods.plan.languages.html.replace /[\s\S]*<\/playercode>/, playerHTML + + destroy: -> + @webSurface?.destroy() + $('body').css('overflow', 'initial') # Recover from our modifications to body overflow before we leave + super() diff --git a/app/views/play/level/ThangAvatarView.coffee b/app/views/play/level/ThangAvatarView.coffee index f16fcea96..1d6c05fb0 100644 --- a/app/views/play/level/ThangAvatarView.coffee +++ b/app/views/play/level/ThangAvatarView.coffee @@ -57,12 +57,9 @@ module.exports = class ThangAvatarView extends CocoView @$el.toggleClass 'selected', Boolean(selected) onProblemsUpdated: (e) -> - return unless @thang?.id of e.spell.thangs - myProblems = [] - for thangID, spellThang of e.spell.thangs when thangID is @thang.id - #aether = if e.isCast and spellThang.castAether then spellThang.castAether else spellThang.aether - aether = spellThang.castAether # try only paying attention to the actually cast ones - myProblems = myProblems.concat aether.getAllProblems() if aether + return unless @thang?.id is e.spell.thang?.thang.id + aether = e.spell.thang.castAether + myProblems = aether?.getAllProblems() ? [] worstLevel = null for level in ['error', 'warning', 'info'] when _.some myProblems, {level: level} worstLevel = level diff --git a/app/views/play/level/WebSurfaceView.coffee b/app/views/play/level/WebSurfaceView.coffee new file mode 100644 index 000000000..7ee8c95c9 --- /dev/null +++ b/app/views/play/level/WebSurfaceView.coffee @@ -0,0 +1,98 @@ +CocoView = require 'views/core/CocoView' +template = require 'templates/play/level/web-surface-view' + +module.exports = class WebSurfaceView extends CocoView + id: 'web-surface-view' + template: template + + subscriptions: + 'tome:html-updated': 'onHTMLUpdated' + + initialize: (options) -> + @goals = (goal for goal in options.goalManager?.goals ? [] when goal.html) + # Consider https://www.npmjs.com/package/css-select to do this on virtualDom instead of in iframe on concreteDOM + super(options) + + afterRender: -> + super() + @iframe = @$('iframe')[0] + $(@iframe).on 'load', (e) => + window.addEventListener 'message', @onIframeMessage + @iframeLoaded = true + @onIframeLoaded?() + @onIframeLoaded = null + + # TODO: make clicking Run actually trigger a 'create' update here (for resetting scripts) + + onHTMLUpdated: (e) -> + unless @iframeLoaded + return @onIframeLoaded = => @onHTMLUpdated e unless @destroyed + dom = htmlparser2.parseDOM e.html, {} + body = _.find(dom, name: 'body') ? {name: 'body', attribs: null, children: dom} + html = _.find(dom, name: 'html') ? {name: 'html', attribs: null, children: [body]} + # TODO: pull out the actual scripts, styles, and body/elements they are doing so we can merge them with our initial structure on the other side + { virtualDom, styles, scripts } = @extractStylesAndScripts(@dekuify html) + messageType = if e.create or not @virtualDom then 'create' else 'update' + @iframe.contentWindow.postMessage {type: messageType, dom: virtualDom, styles, scripts, goals: @goals}, '*' + @virtualDom = virtualDom + + dekuify: (elem) -> + return elem.data if elem.type is 'text' + return null if elem.type is 'comment' # TODO: figure out how to make a comment in virtual dom + elem.attribs = _.omit elem.attribs, (val, attr) -> attr.indexOf('<') > -1 # Deku chokes on `

    ` + unless elem.name + console.log("Failed to dekuify", elem) + return elem.type + deku.element(elem.name, elem.attribs, (@dekuify(c) for c in elem.children ? [])) + + extractStylesAndScripts: (dekuTree) -> + recurse = (dekuTree) -> + #base case + if dekuTree.type is '#text' + return { virtualDom: dekuTree, styles: [], scripts: [] } + if dekuTree.type is 'style' + console.log 'Found a style: ', dekuTree + return { styles: [dekuTree], scripts: [] } + if dekuTree.type is 'script' + console.log 'Found a script: ', dekuTree + return { styles: [], scripts: [dekuTree] } + # recurse over children + childStyles = [] + childScripts = [] + dekuTree.children?.forEach (dekuChild, index) => + { virtualDom, styles, scripts } = recurse(dekuChild) + dekuTree.children[index] = virtualDom + childStyles = childStyles.concat(styles) + childScripts = childScripts.concat(scripts) + dekuTree.children = _.filter dekuTree.children # Remove the nodes we extracted + return { virtualDom: dekuTree, scripts: childScripts, styles: childStyles } + + { virtualDom, scripts, styles } = recurse(dekuTree) + combinedScripts = @combineNodes('script', scripts) + combinedStyles = @combineNodes('style', styles) + return { virtualDom, scripts: combinedScripts, styles: combinedStyles } + + combineNodes: (type, nodes) -> + if _.any(nodes, (node) -> node.type isnt type) + throw new Error("Can't combine nodes of different types. (Got #{nodes.map (n) -> n.type})") + children = nodes.map((n) -> n.children).reduce(((a,b) -> a.concat(b)), []) + if _.isEmpty(children) + deku.element(type, {}) + else + deku.element(type, {}, children) + + onIframeMessage: (event) => + origin = event.origin or event.originalEvent.origin + unless origin is window.location.origin + return console.log 'Ignoring message from bad origin:', origin + unless event.source is @iframe.contentWindow + return console.log 'Ignoring message from somewhere other than our iframe:', event.source + switch event.data.type + when 'goals-updated' + Backbone.Mediator.publish 'god:new-html-goal-states', goalStates: event.data.goalStates, overallStatus: event.data.overallStatus + else + console.warn 'Unknown message type', event.data.type, 'for message', e, 'from origin', origin + + destroy: -> + window.removeEventListener 'message', @onIframeMessage + super() diff --git a/app/views/play/level/modal/CourseVictoryModal.coffee b/app/views/play/level/modal/CourseVictoryModal.coffee index 8df142bd1..0e34d2744 100644 --- a/app/views/play/level/modal/CourseVictoryModal.coffee +++ b/app/views/play/level/modal/CourseVictoryModal.coffee @@ -44,6 +44,11 @@ module.exports = class CourseVictoryModal extends ModalView @levelSessions = @supermodel.loadCollection(@levelSessions, 'sessions', { data: { project: 'state.complete level.original playtime changed' } }).model + + if not @course + @course = new Course() + @supermodel.trackRequest @course.fetchForCourseInstance(@courseInstanceID) + window.tracker?.trackEvent 'Play Level Victory Modal Loaded', category: 'Students', levelSlug: @level.get('slug'), ['Mixpanel'] onResourceLoadFailed: (e) -> @@ -53,6 +58,7 @@ module.exports = class CourseVictoryModal extends ModalView onLoaded: -> super() + @courseID ?= @course.id @views = [] @levelSessions?.remove(@session) @@ -63,10 +69,12 @@ module.exports = class CourseVictoryModal extends ModalView course: @course classroom: @classroom levelSessions: @levelSessions + session: @session }) progressView.once 'done', @onDone, @ progressView.once 'next-level', @onNextLevel, @ + progressView.once 'ladder', @onLadder, @ for view in @views view.on 'continue', @onViewContinue, @ @views.push(progressView) @@ -104,3 +112,15 @@ module.exports = class CourseVictoryModal extends ModalView else link = "/courses/#{@courseID}/#{@courseInstanceID}" application.router.navigate(link, {trigger: true}) + + onLadder: -> + # Preserve the supermodel as we navigate back to the ladder. + viewArgs = [{supermodel: if @options.hasReceivedMemoryWarning then null else @supermodel}, @level.get('slug')] + ladderURL = "/play/ladder/#{@level.get('slug') || @level.id}" + if leagueID = (@courseInstanceID or @getQueryVariable 'league') + leagueType = if @level.get('type') is 'course-ladder' then 'course' else 'clan' + viewArgs.push leagueType + viewArgs.push leagueID + ladderURL += "/#{leagueType}/#{leagueID}" + ladderURL += '#my-matches' + Backbone.Mediator.publish 'router:navigate', route: ladderURL, viewClass: 'views/ladder/LadderView', viewArgs: viewArgs diff --git a/app/views/play/level/modal/HeroVictoryModal.coffee b/app/views/play/level/modal/HeroVictoryModal.coffee index 1f7a46672..3e7ce32a6 100644 --- a/app/views/play/level/modal/HeroVictoryModal.coffee +++ b/app/views/play/level/modal/HeroVictoryModal.coffee @@ -32,6 +32,7 @@ module.exports = class HeroVictoryModal extends ModalView 'click .sign-up-button': 'onClickSignupButton' 'click .continue-from-offer-button': 'onClickContinueFromOffer' 'click .skip-offer-button': 'onClickSkipOffer' + 'click #share-level-btn': 'onClickShareLevelButton' # Feedback events 'mouseover .rating i': (e) -> @showStars(@starNum($(e.target))) @@ -49,7 +50,7 @@ module.exports = class HeroVictoryModal extends ModalView @session = options.session @level = options.level @thangTypes = {} - if @level.get('type', true) in ['hero', 'hero-ladder', 'course', 'course-ladder', 'game-dev'] + if @level.isType('hero', 'hero-ladder', 'course', 'course-ladder', 'game-dev', 'web-dev') achievements = new CocoCollection([], { url: "/db/achievement?related=#{@session.get('level').original}" model: Achievement @@ -63,17 +64,19 @@ module.exports = class HeroVictoryModal extends ModalView else @readyToContinue = true @playSound 'victory' - if @level.get('type', true) is 'course' + if @level.isType('course', 'game-dev', 'web-dev') if nextLevel = @level.get('nextLevel') @nextLevel = new Level().setURL "/db/level/#{nextLevel.original}/version/#{nextLevel.majorVersion}" @nextLevel = @supermodel.loadModel(@nextLevel).model if @courseID @course = new Course().setURL "/db/course/#{@courseID}" @course = @supermodel.loadModel(@course).model - if @level.get('type', true) in ['course', 'course-ladder'] + if @level.isType('course', 'course-ladder') @saveReviewEventually = _.debounce(@saveReviewEventually, 2000) @loadExistingFeedback() - # TODO: support game-dev + + if @level.get('shareable') is 'project' + @shareURL = "#{window.location.origin}/play/#{@level.get('type')}-level/#{@level.get('slug')}/#{@session.id}" destroy: -> clearInterval @sequentialAnimationInterval @@ -154,8 +157,7 @@ module.exports = class HeroVictoryModal extends ModalView getRenderData: -> c = super() c.levelName = utils.i18n @level.attributes, 'name' - # TODO: support 'game-dev' - if @level.get('type', true) not in ['hero', 'game-dev'] + if @level.isType('hero', 'game-dev', 'web-dev') c.victoryText = utils.i18n @level.get('victory') ? {}, 'body' earnedAchievementMap = _.indexBy(@newEarnedAchievements or [], (ea) -> ea.get('achievement')) for achievement in (@achievements?.models or []) @@ -192,7 +194,7 @@ module.exports = class HeroVictoryModal extends ModalView c.thangTypes = @thangTypes c.me = me - c.readyToRank = @level.get('type', true) in ['hero-ladder', 'course-ladder'] and @session.readyToRank() + c.readyToRank = @level.isType('hero-ladder', 'course-ladder') and @session.readyToRank() c.level = @level c.i18n = utils.i18n @@ -211,10 +213,10 @@ module.exports = class HeroVictoryModal extends ModalView # Show the "I'm done" button between 30 - 120 minutes if they definitely came from Hour of Code c.showHourOfCodeDoneButton = showDone - c.showLeaderboard = @level.get('scoreTypes')?.length > 0 and @level.get('type', true) isnt 'course' + c.showLeaderboard = @level.get('scoreTypes')?.length > 0 and not @level.isType('course') - c.showReturnToCourse = not c.showLeaderboard and not me.get('anonymous') and @level.get('type', true) in ['course', 'course-ladder'] - c.isCourseLevel = @level.get('type', true) in ['course'] + c.showReturnToCourse = not c.showLeaderboard and not me.get('anonymous') and @level.isType('course', 'course-ladder') + c.isCourseLevel = @level.isType('course') c.currentCourseName = @course?.get('name') c.currentLevelName = @level?.get('name') c.nextLevelName = @nextLevel?.get('name') @@ -223,17 +225,17 @@ module.exports = class HeroVictoryModal extends ModalView afterRender: -> super() - @$el.toggleClass 'with-achievements', @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev'] # TODO: support game-dev + @$el.toggleClass 'with-achievements', @level.isType('hero', 'hero-ladder', 'game-dev', 'web-dev') return unless @supermodel.finished() @playSelectionSound hero, true for original, hero of @thangTypes # Preload them @updateSavingProgressStatus() @initializeAnimations() - if @level.get('type', true) in ['hero-ladder', 'course-ladder'] + if @level.isType('hero-ladder', 'course-ladder') @ladderSubmissionView = new LadderSubmissionView session: @session, level: @level @insertSubView @ladderSubmissionView, @$el.find('.ladder-submission-view') initializeAnimations: -> - return @endSequentialAnimations() unless @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev'] # TODO: support game-dev + return @endSequentialAnimations() unless @level.isType('hero', 'hero-ladder', 'game-dev', 'web-dev') @updateXPBars 0 #playVictorySound = => @playSound 'victory-title-appear' # TODO: actually add this @$el.find('#victory-header').delay(250).queue(-> @@ -264,7 +266,7 @@ module.exports = class HeroVictoryModal extends ModalView beginSequentialAnimations: -> return if @destroyed - return unless @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev'] # TODO: support game-dev + return unless @level.isType('hero', 'hero-ladder', 'game-dev', 'web-dev') @sequentialAnimatedPanels = _.map(@animatedPanels.find('.reward-panel'), (panel) -> { number: $(panel).data('number') previousNumber: $(panel).data('previous-number') @@ -379,7 +381,7 @@ module.exports = class HeroVictoryModal extends ModalView clearInterval @sequentialAnimationInterval @animationComplete = true @updateSavingProgressStatus() - Backbone.Mediator.publish 'music-player:enter-menu', terrain: @level.get('terrain', true) + Backbone.Mediator.publish 'music-player:enter-menu', terrain: @level.get('terrain', true) or 'forest' updateSavingProgressStatus: -> @$el.find('#saving-progress-label').toggleClass('hide', @readyToContinue) @@ -394,7 +396,7 @@ module.exports = class HeroVictoryModal extends ModalView viewArgs = [{supermodel: if @options.hasReceivedMemoryWarning then null else @supermodel}, @level.get('slug')] ladderURL = "/play/ladder/#{@level.get('slug') || @level.id}" if leagueID = (@courseInstanceID or @getQueryVariable 'league') - leagueType = if @level.get('type') is 'course-ladder' then 'course' else 'clan' + leagueType = if @level.isType('course-ladder') then 'course' else 'clan' viewArgs.push leagueType viewArgs.push leagueID ladderURL += "/#{leagueType}/#{leagueID}" @@ -414,14 +416,14 @@ module.exports = class HeroVictoryModal extends ModalView {'kithgard-gates': 'forest', 'kithgard-mastery': 'forest', 'siege-of-stonehold': 'desert', 'clash-of-clones': 'mountain', 'summits-gate': 'glacier'}[@level.get('slug')] or @level.get 'campaign' # Much easier to just keep this updated than to dynamically figure it out. getNextLevelLink: (returnToCourse=false) -> - if @level.get('type', true) is 'course' and nextLevel = @level.get('nextLevel') and not returnToCourse + if @level.isType('course', 'game-dev', 'web-dev') and nextLevel = @level.get('nextLevel') and not returnToCourse # need to do something more complicated to load its slug console.log 'have @nextLevel', @nextLevel, 'from nextLevel', nextLevel link = "/play/level/#{@nextLevel.get('slug')}" if @courseID link += "?course=#{@courseID}" link += "&course-instance=#{@courseInstanceID}" if @courseInstanceID - else if @level.get('type', true) is 'course' + else if @level.isType('course') link = "/courses" if @courseID link += "/#{@courseID}" @@ -440,12 +442,12 @@ module.exports = class HeroVictoryModal extends ModalView justBeatLevel: @level supermodel: if @options.hasReceivedMemoryWarning then null else @supermodel _.merge options, extraOptions if extraOptions - if @level.get('type', true) is 'course' and @nextLevel and not options.returnToCourse + if @level.isType('course') and @nextLevel and not options.returnToCourse viewClass = require 'views/play/level/PlayLevelView' options.courseID = @courseID options.courseInstanceID = @courseInstanceID viewArgs = [options, @nextLevel.get('slug')] - else if @level.get('type', true) is 'course' + else if @level.isType('course') # TODO: shouldn't set viewClass and route in different places viewClass = require 'views/courses/CoursesView' viewArgs = [options] @@ -453,7 +455,7 @@ module.exports = class HeroVictoryModal extends ModalView viewClass = require 'views/courses/CourseDetailsView' viewArgs.push @courseID viewArgs.push @courseInstanceID if @courseInstanceID - else if @level.get('type', true) is 'course-ladder' + else if @level.isType('course-ladder') leagueID = @courseInstanceID or @getQueryVariable 'league' nextLevelLink = "/play/ladder/#{@level.get('slug')}" nextLevelLink += "/course/#{leagueID}" if leagueID @@ -499,6 +501,10 @@ module.exports = class HeroVictoryModal extends ModalView onClickSkipOffer: (e) -> Backbone.Mediator.publish 'router:navigate', @navigationEventUponCompletion + onClickShareLevelButton: -> + @$('#share-level-input').val(@shareURL).select() + @tryCopy() + # Ratings and reviews starNum: (starEl) -> starEl.prevAll('i').length + 1 diff --git a/app/views/play/level/modal/ImageGalleryModal.coffee b/app/views/play/level/modal/ImageGalleryModal.coffee new file mode 100644 index 000000000..50a300b59 --- /dev/null +++ b/app/views/play/level/modal/ImageGalleryModal.coffee @@ -0,0 +1,876 @@ +ModalView = require 'views/core/ModalView' + +module.exports = class ImageGalleryModal extends ModalView + id: 'image-gallery-modal' + template: require 'templates/play/level/modal/image-gallery-modal' + # Top most useful Thang portraits + images: [ + {slug: 'archer-f', name: 'Archer F', original: '529ab1a24b67a988ad000002', portraitURL: '/file/db/thang.type/529ab1a24b67a988ad000002/portrait.png', kind: 'Unit'} + {slug: 'archer-m', name: 'Archer M', original: '52cee45a76ebd5196b00003a', portraitURL: '/file/db/thang.type/52cee45a76ebd5196b00003a/portrait.png', kind: 'Unit'} + {slug: 'artist', name: 'Artist', original: '56d0c4f601476e2100de76c0', portraitURL: '/file/db/thang.type/56d0c4f601476e2100de76c0/portrait.png', kind: 'Unit'} + {slug: 'assassin', name: 'Assassin', original: '566a2202e132c81f00f38c81', portraitURL: '/file/db/thang.type/566a2202e132c81f00f38c81/portrait.png', kind: 'Hero'} + {slug: 'baby-griffin', name: 'baby griffin', original: '57586f0a22179b2800efda37', portraitURL: '/file/db/thang.type/57586f0a22179b2800efda37/portrait.png', kind: 'Item'} + {slug: 'basic-flags', name: 'Basic Flags', original: '545bacb41e649a4495f887da', portraitURL: '/file/db/thang.type/545bacb41e649a4495f887da/portrait.png', kind: 'Item'} + {slug: 'boom-ball', name: 'Boom Ball', original: '54eb540b49fa2d5c905ddf1a', portraitURL: '/file/db/thang.type/54eb540b49fa2d5c905ddf1a/portrait.png', kind: 'Item'} + {slug: 'breaker', name: 'Breaker', original: '56d0dd5b441ddd2f002ba3d8', portraitURL: '/file/db/thang.type/56d0dd5b441ddd2f002ba3d8/portrait.png', kind: 'Unit'} + {slug: 'burl', name: 'Burl', original: '530e5926c06854403ba68693', portraitURL: '/file/db/thang.type/530e5926c06854403ba68693/portrait.png', kind: 'Unit'} + {slug: 'captain', name: 'Captain', original: '529ec584c423d4e83b000014', portraitURL: '/file/db/thang.type/529ec584c423d4e83b000014/portrait.png', kind: 'Hero'} + {slug: 'champion', name: 'Champion', original: '575848b522179b2800efbfbf', portraitURL: '/file/db/thang.type/575848b522179b2800efbfbf/portrait.png', kind: 'Hero'} + {slug: 'chest-of-gems', name: 'Chest of Gems', original: '5432f9d18364d30000d1f943', portraitURL: '/file/db/thang.type/5432f9d18364d30000d1f943/portrait.png', kind: 'Misc'} + {slug: 'confuse', name: 'Confuse', original: '53024b76a6efdd32359c5340', portraitURL: '/file/db/thang.type/53024b76a6efdd32359c5340/portrait.png', kind: 'Mark'} + {slug: 'control', name: 'Control', original: '53024c7b27471514685d5397', portraitURL: '/file/db/thang.type/53024c7b27471514685d5397/portrait.png', kind: 'Mark'} + {slug: 'cougar', name: 'Cougar', original: '5744e3683af6bf590cd27371', portraitURL: '/file/db/thang.type/5744e3683af6bf590cd27371/portrait.png', kind: 'Item'} + {slug: 'cow', name: 'Cow', original: '52e95a5022efc8e709001743', portraitURL: '/file/db/thang.type/52e95a5022efc8e709001743/portrait.png', kind: 'Doodad'} + {slug: 'dantdm', name: 'DanTDM', original: '578674c3a6c641350091b645', portraitURL: '/file/db/thang.type/578674c3a6c641350091b645/portrait.png', kind: 'Unit'} + {slug: 'desert-bones-2', name: 'Desert Bones 2', original: '548cf11b0f559d0000be7e2b', portraitURL: '/file/db/thang.type/548cf11b0f559d0000be7e2b/portrait.png', kind: 'Doodad'} + {slug: 'duelist', name: 'Duelist', original: '57588f09046caf2e0012ed41', portraitURL: '/file/db/thang.type/57588f09046caf2e0012ed41/portrait.png', kind: 'Hero'} + {slug: 'equestrian', name: 'Equestrian', original: '52e95b4222efc8e70900175d', portraitURL: '/file/db/thang.type/52e95b4222efc8e70900175d/portrait.png', kind: 'Unit'} + {slug: 'flower-1', name: 'Flower 1', original: '54e951c8f54ef5794f354ed1', portraitURL: '/file/db/thang.type/54e951c8f54ef5794f354ed1/portrait.png', kind: 'Doodad'} + {slug: 'flower-2', name: 'Flower 2', original: '54e9525ff54ef5794f354ed5', portraitURL: '/file/db/thang.type/54e9525ff54ef5794f354ed5/portrait.png', kind: 'Doodad'} + {slug: 'flower-3', name: 'Flower 3', original: '54e95293f54ef5794f354ed9', portraitURL: '/file/db/thang.type/54e95293f54ef5794f354ed9/portrait.png', kind: 'Doodad'} + {slug: 'flower-4', name: 'Flower 4', original: '54e952b7f54ef5794f354edd', portraitURL: '/file/db/thang.type/54e952b7f54ef5794f354edd/portrait.png', kind: 'Doodad'} + {slug: 'flower-5', name: 'Flower 5', original: '54e952daf54ef5794f354ee1', portraitURL: '/file/db/thang.type/54e952daf54ef5794f354ee1/portrait.png', kind: 'Doodad'} + {slug: 'flower-6', name: 'Flower 6', original: '54e95308f54ef5794f354ee5', portraitURL: '/file/db/thang.type/54e95308f54ef5794f354ee5/portrait.png', kind: 'Doodad'} + {slug: 'flower-7', name: 'Flower 7', original: '54e9532ff54ef5794f354ee9', portraitURL: '/file/db/thang.type/54e9532ff54ef5794f354ee9/portrait.png', kind: 'Doodad'} + {slug: 'flower-8', name: 'Flower 8', original: '54e9534ef54ef5794f354eed', portraitURL: '/file/db/thang.type/54e9534ef54ef5794f354eed/portrait.png', kind: 'Doodad'} + {slug: 'forest-archer', name: 'Forest Archer', original: '5466d4f2417c8b48a9811e87', portraitURL: '/file/db/thang.type/5466d4f2417c8b48a9811e87/portrait.png', kind: 'Hero'} + {slug: 'frozen-munchkin', name: 'Frozen Munchkin', original: '5576686e1e82182d9e6889bb', portraitURL: '/file/db/thang.type/5576686e1e82182d9e6889bb/portrait.png', kind: 'Doodad'} + {slug: 'frozen-soldier-f', name: 'Frozen Soldier F', original: '5576683e1e82182d9e6889b7', portraitURL: '/file/db/thang.type/5576683e1e82182d9e6889b7/portrait.png', kind: 'Doodad'} + {slug: 'frozen-soldier-m', name: 'Frozen Soldier M', original: '557662bf1e82182d9e6889af', portraitURL: '/file/db/thang.type/557662bf1e82182d9e6889af/portrait.png', kind: 'Doodad'} + {slug: 'gem', name: 'Gem', original: '52aa3b9eccbd588d4d000003', portraitURL: '/file/db/thang.type/52aa3b9eccbd588d4d000003/portrait.png', kind: 'Misc'} + {slug: 'gold-coin', name: 'Gold Coin', original: '535ef031c519160709f2f63a', portraitURL: '/file/db/thang.type/535ef031c519160709f2f63a/portrait.png', kind: 'Misc'} + {slug: 'goliath', name: 'Goliath', original: '55e1a6e876cb0948c96af9f8', portraitURL: '/file/db/thang.type/55e1a6e876cb0948c96af9f8/portrait.png', kind: 'Hero'} + {slug: 'guardian', name: 'Guardian', original: '566a058620de41290036a745', portraitURL: '/file/db/thang.type/566a058620de41290036a745/portrait.png', kind: 'Hero'} + {slug: 'horse', name: 'Horse', original: '52e989a4427172ae56001f04', portraitURL: '/file/db/thang.type/52e989a4427172ae56001f04/portrait.png', kind: 'Doodad'} + {slug: 'knight', name: 'Knight', original: '529ffbf1cf1818f2be000001', portraitURL: '/file/db/thang.type/529ffbf1cf1818f2be000001/portrait.png', kind: 'Hero'} + {slug: 'librarian', name: 'Librarian', original: '52fbf74b7e01835453bd8d8e', portraitURL: '/file/db/thang.type/52fbf74b7e01835453bd8d8e/portrait.png', kind: 'Hero'} + {slug: 'necromancer', name: 'Necromancer', original: '55652fb3b9effa46a1f775fd', portraitURL: '/file/db/thang.type/55652fb3b9effa46a1f775fd/portrait.png', kind: 'Hero'} + {slug: 'ninja', name: 'Ninja', original: '52fc0ed77e01835453bd8f6c', portraitURL: '/file/db/thang.type/52fc0ed77e01835453bd8f6c/portrait.png', kind: 'Hero'} + {slug: 'ogre-brawler', name: 'Ogre Brawler', original: '529e5ee76febb9ca7e00000b', portraitURL: '/file/db/thang.type/529e5ee76febb9ca7e00000b/portrait.png', kind: 'Unit'} + {slug: 'ogre-chieftain', name: 'Ogre Chieftain', original: '55370661428ddac5686fd026', portraitURL: '/file/db/thang.type/55370661428ddac5686fd026/portrait.png', kind: 'Unit'} + {slug: 'ogre-f', name: 'Ogre F', original: '52cedd3e0b0d5c1b4c003ec6', portraitURL: '/file/db/thang.type/52cedd3e0b0d5c1b4c003ec6/portrait.png', kind: 'Unit'} + {slug: 'ogre-fangrider', name: 'Ogre Fangrider', original: '529e5f0c6febb9ca7e00000c', portraitURL: '/file/db/thang.type/529e5f0c6febb9ca7e00000c/portrait.png', kind: 'Unit'} + {slug: 'ogre-headhunter', name: 'Ogre Headhunter', original: '54c96c3cdef3ad363ff998a1', portraitURL: '/file/db/thang.type/54c96c3cdef3ad363ff998a1/portrait.png', kind: 'Unit'} + {slug: 'ogre-m', name: 'Ogre M', original: '529e40856febb9ca7e000004', portraitURL: '/file/db/thang.type/529e40856febb9ca7e000004/portrait.png', kind: 'Unit'} + {slug: 'ogre-munchkin-f', name: 'Ogre Munchkin F', original: '52cee1d976ebd5196b000038', portraitURL: '/file/db/thang.type/52cee1d976ebd5196b000038/portrait.png', kind: 'Unit'} + {slug: 'ogre-munchkin-m', name: 'Ogre Munchkin M', original: '529e5d756febb9ca7e00000a', portraitURL: '/file/db/thang.type/529e5d756febb9ca7e00000a/portrait.png', kind: 'Unit'} + {slug: 'ogre-shaman', name: 'Ogre Shaman', original: '529f92f9dacd325127000008', portraitURL: '/file/db/thang.type/529f92f9dacd325127000008/portrait.png', kind: 'Unit'} + {slug: 'ogre-thrower', name: 'Ogre Thrower', original: '529fff23cf1818f2be000003', portraitURL: '/file/db/thang.type/529fff23cf1818f2be000003/portrait.png', kind: 'Unit'} + {slug: 'ogre-warlock', name: 'Ogre Warlock', original: '5536f88c428ddac5686fd00c', portraitURL: '/file/db/thang.type/5536f88c428ddac5686fd00c/portrait.png', kind: 'Unit'} + {slug: 'ogre-witch', name: 'Ogre Witch', original: '5536ce98428ddac5686fcfd3', portraitURL: '/file/db/thang.type/5536ce98428ddac5686fcfd3/portrait.png', kind: 'Unit'} + {slug: 'oracle', name: 'Oracle', original: '56d0cfa063103d2a00af5449', portraitURL: '/file/db/thang.type/56d0cfa063103d2a00af5449/portrait.png', kind: 'Unit'} + {slug: 'paladin', name: 'Paladin', original: '552be965c54551e79b57b766', portraitURL: '/file/db/thang.type/552be965c54551e79b57b766/portrait.png', kind: 'Unit'} + {slug: 'peasant-f', name: 'Peasant F', original: '52d48f02d0ce9936e2000005', portraitURL: '/file/db/thang.type/52d48f02d0ce9936e2000005/portrait.png', kind: 'Unit'} + {slug: 'peasant-m', name: 'Peasant M', original: '529f9026dacd325127000005', portraitURL: '/file/db/thang.type/529f9026dacd325127000005/portrait.png', kind: 'Unit'} + {slug: 'polar-bear-cub', name: 'Polar Bear Cub', original: '578691f9bd31c1440083251d', portraitURL: '/file/db/thang.type/578691f9bd31c1440083251d/portrait.png', kind: 'Item'} + {slug: 'potion-master', name: 'Potion Master', original: '52e9adf7427172ae56002172', portraitURL: '/file/db/thang.type/52e9adf7427172ae56002172/portrait.png', kind: 'Hero'} + {slug: 'pugicorn', name: 'Pugicorn', original: '577d5d4dab818b210046b3bf', portraitURL: '/file/db/thang.type/577d5d4dab818b210046b3bf/portrait.png', kind: 'Item'} + {slug: 'raider', name: 'Raider', original: '55527eb0b8abf4ba1fe9a107', portraitURL: '/file/db/thang.type/55527eb0b8abf4ba1fe9a107/portrait.png', kind: 'Hero'} + {slug: 'raven', name: 'Raven', original: '5786a472a6c64135009238d3', portraitURL: '/file/db/thang.type/5786a472a6c64135009238d3/portrait.png', kind: 'Item'} + {slug: 'raven-pet', name: 'Raven Pet', original: '540f389a821af8000097dc5a', portraitURL: '/file/db/thang.type/540f389a821af8000097dc5a/portrait.png', kind: 'Unit'} + {slug: 'razordisc', name: 'Razordisc', original: '54eb4d5949fa2d5c905ddf06', portraitURL: '/file/db/thang.type/54eb4d5949fa2d5c905ddf06/portrait.png', kind: 'Item'} + {slug: 'samurai', name: 'Samurai', original: '53e12be0d042f23505c3023b', portraitURL: '/file/db/thang.type/53e12be0d042f23505c3023b/portrait.png', kind: 'Hero'} + {slug: 'skeleton', name: 'Skeleton', original: '54c83b8ae2829db30d0310e0', portraitURL: '/file/db/thang.type/54c83b8ae2829db30d0310e0/portrait.png', kind: 'Unit'} + {slug: 'soldier-f', name: 'Soldier F', original: '52d49552d0ce9936e2000007', portraitURL: '/file/db/thang.type/52d49552d0ce9936e2000007/portrait.png', kind: 'Unit'} + {slug: 'soldier-m', name: 'Soldier M', original: '529e680ac423d4e83b000001', portraitURL: '/file/db/thang.type/529e680ac423d4e83b000001/portrait.png', kind: 'Unit'} + {slug: 'sorcerer', name: 'Sorcerer', original: '52fd1524c7e6cf99160e7bc9', portraitURL: '/file/db/thang.type/52fd1524c7e6cf99160e7bc9/portrait.png', kind: 'Hero'} + {slug: 'target', name: 'Target', original: '52b32ad97385ec3d03000001', portraitURL: '/file/db/thang.type/52b32ad97385ec3d03000001/portrait.png', kind: 'Mark'} + {slug: 'thoktar', name: 'Thoktar', original: '52a00542cf1818f2be000006', portraitURL: '/file/db/thang.type/52a00542cf1818f2be000006/portrait.png', kind: 'Unit'} + {slug: 'tinker', name: 'Tinker', original: '56cdd89be906e72400f13451', portraitURL: '/file/db/thang.type/56cdd89be906e72400f13451/portrait.png', kind: 'Unit'} + {slug: 'trapper', name: 'Trapper', original: '5466d449417c8b48a9811e83', portraitURL: '/file/db/thang.type/5466d449417c8b48a9811e83/portrait.png', kind: 'Hero'} + {slug: 'wizard', name: 'Wizard', original: '52a00d55cf1818f2be00000b', portraitURL: '/file/db/thang.type/52a00d55cf1818f2be00000b/portrait.png', kind: 'Unit'} + {slug: 'wyrm', name: 'Wyrm', original: '56ba2b34e942de2600c792ed', portraitURL: '/file/db/thang.type/56ba2b34e942de2600c792ed/portrait.png', kind: 'Unit'} + ] + + # Ones we didn't decide to use + otherImages: [ + {slug: 'hero-placeholder', name: 'Hero Placeholder', original: '53ed1d9c2b65b0e32b9c96a9', portraitURL: '/file/db/thang.type/53ed1d9c2b65b0e32b9c96a9/portrait.png', kind: 'Unit'} + {slug: 'flag', name: 'Flag', original: '53fa25f25bc220000052c2be', portraitURL: '/file/db/thang.type/53fa25f25bc220000052c2be/portrait.png', kind: 'Misc'} + {slug: 'ace-of-coders-background', name: 'Ace of Coders Background', original: '55ef24a10e11a95a0d0ab103', portraitURL: '/file/db/thang.type/55ef24a10e11a95a0d0ab103/portrait.png', kind: 'Floor'} + {slug: 'advanced-flags', name: 'Advanced Flags', original: '5478b97e8707a2c3a2493b2f', portraitURL: '/file/db/thang.type/5478b97e8707a2c3a2493b2f/portrait.png', kind: 'Item'} + {slug: 'aerial-spear', name: 'Aerial Spear', original: '5400da521130f1881ca255e4', portraitURL: '/file/db/thang.type/5400da521130f1881ca255e4/portrait.png', kind: 'Misc'} + {slug: 'altar', name: 'Altar', original: '54ef8eb683b08b7d054b7f04', portraitURL: '/file/db/thang.type/54ef8eb683b08b7d054b7f04/portrait.png', kind: 'Doodad'} + {slug: 'amber-sense-stone', name: 'Amber Sense Stone', original: '54693413a2b1f53ce79443dd', portraitURL: '/file/db/thang.type/54693413a2b1f53ce79443dd/portrait.png', kind: 'Item'} + {slug: 'angel-fountain', name: 'Angel Fountain', original: '54f11438021968810565376b', portraitURL: '/file/db/thang.type/54f11438021968810565376b/portrait.png', kind: 'Doodad'} + {slug: 'angel-statue', name: 'Angel Statue', original: '54f1152a021968810565378a', portraitURL: '/file/db/thang.type/54f1152a021968810565378a/portrait.png', kind: 'Doodad'} + {slug: 'archway', name: 'Archway', original: '534dd3531a52ddd804f34efc', portraitURL: '/file/db/thang.type/534dd3531a52ddd804f34efc/portrait.png', kind: 'Misc'} + {slug: 'arrow', name: 'Arrow', original: '529ce66b0bf0bccdc6000005', portraitURL: '/file/db/thang.type/529ce66b0bf0bccdc6000005/portrait.png', kind: 'Missile'} + {slug: 'arrow-tower', name: 'Arrow Tower', original: '529f93cfdacd32512700000a', portraitURL: '/file/db/thang.type/529f93cfdacd32512700000a/portrait.png', kind: 'Unit'} + {slug: 'artillery', name: 'Artillery', original: '529e7a16c423d4e83b000003', portraitURL: '/file/db/thang.type/529e7a16c423d4e83b000003/portrait.png', kind: 'Unit'} + {slug: 'baby-griffin-pet', name: 'Baby Griffin Pet', original: '5750ef2f9f734c20005f1f57', portraitURL: '/file/db/thang.type/5750ef2f9f734c20005f1f57/portrait.png', kind: 'Unit'} + {slug: 'ball', name: 'Ball', original: '5580af39b43ce0b15a91b299', portraitURL: '/file/db/thang.type/5580af39b43ce0b15a91b299/portrait.png', kind: 'Doodad'} + {slug: 'balsa-staff', name: 'Balsa Staff', original: '544d88478494308424f56505', portraitURL: '/file/db/thang.type/544d88478494308424f56505/portrait.png', kind: 'Item'} + {slug: 'banded-redwood-wand', name: 'Banded Redwood Wand', original: '544d887c8494308424f56509', portraitURL: '/file/db/thang.type/544d887c8494308424f56509/portrait.png', kind: 'Item'} + {slug: 'barn', name: 'Barn', original: '54f1136f25be5e88058374b3', portraitURL: '/file/db/thang.type/54f1136f25be5e88058374b3/portrait.png', kind: 'Doodad'} + {slug: 'barrel', name: 'Barrel', original: '52aa5ff120fccb0000000003', portraitURL: '/file/db/thang.type/52aa5ff120fccb0000000003/portrait.png', kind: 'Doodad'} + {slug: 'barrel-animated', name: 'Barrel Animated', original: '54d2b28e7e1b915605556c37', portraitURL: '/file/db/thang.type/54d2b28e7e1b915605556c37/portrait.png', kind: 'Doodad'} + {slug: 'barrel-animated-2', name: 'Barrel Animated 2', original: '54d2b4fdae912a520569cff1', portraitURL: '/file/db/thang.type/54d2b4fdae912a520569cff1/portrait.png', kind: 'Doodad'} + {slug: 'bat', name: 'Bat', original: '55c13175c87e47c60604f987', portraitURL: '/file/db/thang.type/55c13175c87e47c60604f987/portrait.png', kind: 'Doodad'} + {slug: 'beam', name: 'Beam', original: '529ec2cec423d4e83b000011', portraitURL: '/file/db/thang.type/529ec2cec423d4e83b000011/portrait.png', kind: 'Missile'} + {slug: 'beam-tower', name: 'Beam Tower', original: '529ec0c1c423d4e83b00000d', portraitURL: '/file/db/thang.type/529ec0c1c423d4e83b00000d/portrait.png', kind: 'Unit'} + {slug: 'bear', name: 'Bear', original: '54e95b22f54ef5794f354f41', portraitURL: '/file/db/thang.type/54e95b22f54ef5794f354f41/portrait.png', kind: 'Doodad'} + {slug: 'bear-trap', name: 'Bear Trap', original: '54d2b8ef3e16915505f0bfeb', portraitURL: '/file/db/thang.type/54d2b8ef3e16915505f0bfeb/portrait.png', kind: 'Doodad'} + {slug: 'big-rocks-1', name: 'Big Rocks 1', original: '557f950db43ce0b15a91b1d9', portraitURL: '/file/db/thang.type/557f950db43ce0b15a91b1d9/portrait.png', kind: 'Doodad'} + {slug: 'big-rocks-2', name: 'Big Rocks 2', original: '557f959ab43ce0b15a91b1dd', portraitURL: '/file/db/thang.type/557f959ab43ce0b15a91b1dd/portrait.png', kind: 'Doodad'} + {slug: 'big-rocks-3', name: 'Big Rocks 3', original: '557f95e7b43ce0b15a91b1e1', portraitURL: '/file/db/thang.type/557f95e7b43ce0b15a91b1e1/portrait.png', kind: 'Doodad'} + {slug: 'big-rocks-4', name: 'Big Rocks 4', original: '557f9627b43ce0b15a91b1e5', portraitURL: '/file/db/thang.type/557f9627b43ce0b15a91b1e5/portrait.png', kind: 'Doodad'} + {slug: 'big-rocks-5', name: 'Big Rocks 5', original: '557f9661b43ce0b15a91b1e9', portraitURL: '/file/db/thang.type/557f9661b43ce0b15a91b1e9/portrait.png', kind: 'Doodad'} + {slug: 'bird', name: 'Bird', original: '53e2e31f6f406a3505b3eab0', portraitURL: '/file/db/thang.type/53e2e31f6f406a3505b3eab0/portrait.png', kind: 'Doodad'} + {slug: 'bloodhenge', name: 'Bloodhenge', original: '54f1168802196881056537df', portraitURL: '/file/db/thang.type/54f1168802196881056537df/portrait.png', kind: 'Doodad'} + {slug: 'blue-cart', name: 'Blue Cart', original: '5435d3207b554def1f99c49c', portraitURL: '/file/db/thang.type/5435d3207b554def1f99c49c/portrait.png', kind: 'Doodad'} + {slug: 'bluff-1', name: 'Bluff 1', original: '52afce51c5b1813ec200001a', portraitURL: '/file/db/thang.type/52afce51c5b1813ec200001a/portrait.png', kind: 'Doodad'} + {slug: 'bluff-2', name: 'Bluff 2', original: '52afcecbc5b1813ec200001c', portraitURL: '/file/db/thang.type/52afcecbc5b1813ec200001c/portrait.png', kind: 'Doodad'} + {slug: 'bolt', name: 'Bolt', original: '55c658a8a03e2014d693990a', portraitURL: '/file/db/thang.type/55c658a8a03e2014d693990a/portrait.png', kind: 'Missile'} + {slug: 'bolt-spitter', name: 'Bolt Spitter', original: '544d85d88494308424f564e4', portraitURL: '/file/db/thang.type/544d85d88494308424f564e4/portrait.png', kind: 'Item'} + {slug: 'boltsaw', name: 'Boltsaw', original: '544d6f5e8494308424f56476', portraitURL: '/file/db/thang.type/544d6f5e8494308424f56476/portrait.png', kind: 'Item'} + {slug: 'bone-dagger', name: 'Bone Dagger', original: '54eb4b2249fa2d5c905ddefe', portraitURL: '/file/db/thang.type/54eb4b2249fa2d5c905ddefe/portrait.png', kind: 'Item'} + {slug: 'book-of-life-i', name: 'Book of Life I', original: '546375653839c6e02811d30b', portraitURL: '/file/db/thang.type/546375653839c6e02811d30b/portrait.png', kind: 'Item'} + {slug: 'book-of-life-ii', name: 'Book of Life II', original: '546375813839c6e02811d30e', portraitURL: '/file/db/thang.type/546375813839c6e02811d30e/portrait.png', kind: 'Item'} + {slug: 'book-of-life-iii', name: 'Book of Life III', original: '546375a43839c6e02811d311', portraitURL: '/file/db/thang.type/546375a43839c6e02811d311/portrait.png', kind: 'Item'} + {slug: 'book-of-life-iv', name: 'Book of Life IV', original: '546376ca3839c6e02811d31d', portraitURL: '/file/db/thang.type/546376ca3839c6e02811d31d/portrait.png', kind: 'Item'} + {slug: 'book-of-life-v', name: 'Book of Life V', original: '546376ea3839c6e02811d320', portraitURL: '/file/db/thang.type/546376ea3839c6e02811d320/portrait.png', kind: 'Item'} + {slug: 'bookshelf', name: 'Bookshelf', original: '52e994ea427172ae56001fc9', portraitURL: '/file/db/thang.type/52e994ea427172ae56001fc9/portrait.png', kind: 'Doodad'} + {slug: 'bookshelf-2', name: 'Bookshelf 2', original: '54ef925a64112781056c18b5', portraitURL: '/file/db/thang.type/54ef925a64112781056c18b5/portrait.png', kind: 'Doodad'} + {slug: 'boom-ball-missile', name: 'Boom Ball Missile', original: '5535b5d4428ddac5686fcf82', portraitURL: '/file/db/thang.type/5535b5d4428ddac5686fcf82/portrait.png', kind: 'Missile'} + {slug: 'boomrod', name: 'Boomrod', original: '544d85898494308424f564df', portraitURL: '/file/db/thang.type/544d85898494308424f564df/portrait.png', kind: 'Item'} + {slug: 'boots-of-jumping', name: 'Boots of Jumping', original: '546d4e289df4a17d0d449ad5', portraitURL: '/file/db/thang.type/546d4e289df4a17d0d449ad5/portrait.png', kind: 'Item'} + {slug: 'boots-of-leaping', name: 'Boots of Leaping', original: '53e214f153457600003e3eab', portraitURL: '/file/db/thang.type/53e214f153457600003e3eab/portrait.png', kind: 'Item'} + {slug: 'boss-star-i', name: 'Boss Star I', original: '54eb58e449fa2d5c905ddf46', portraitURL: '/file/db/thang.type/54eb58e449fa2d5c905ddf46/portrait.png', kind: 'Item'} + {slug: 'boss-star-ii', name: 'Boss Star II', original: '54eb5bf649fa2d5c905ddf4a', portraitURL: '/file/db/thang.type/54eb5bf649fa2d5c905ddf4a/portrait.png', kind: 'Item'} + {slug: 'boss-star-iii', name: 'Boss Star III', original: '54eb5c8f49fa2d5c905ddf4e', portraitURL: '/file/db/thang.type/54eb5c8f49fa2d5c905ddf4e/portrait.png', kind: 'Item'} + {slug: 'boss-star-iv', name: 'Boss Star IV', original: '54eb5d1649fa2d5c905ddf52', portraitURL: '/file/db/thang.type/54eb5d1649fa2d5c905ddf52/portrait.png', kind: 'Item'} + {slug: 'boss-star-v', name: 'Boss Star V', original: '54eb5dbc49fa2d5c905ddf56', portraitURL: '/file/db/thang.type/54eb5dbc49fa2d5c905ddf56/portrait.png', kind: 'Item'} + {slug: 'boulder', name: 'Boulder', original: '544d86828494308424f564ec', portraitURL: '/file/db/thang.type/544d86828494308424f564ec/portrait.png', kind: 'Missile'} + {slug: 'boulder-trap', name: 'Boulder Trap', original: '55c246b1dfc8d0b576e60a23', portraitURL: '/file/db/thang.type/55c246b1dfc8d0b576e60a23/portrait.png', kind: 'Doodad'} + {slug: 'box', name: 'Box', original: '54d2b68a3e16915505f0bc8a', portraitURL: '/file/db/thang.type/54d2b68a3e16915505f0bc8a/portrait.png', kind: 'Doodad'} + {slug: 'box-2', name: 'Box 2', original: '54d2b797051a3a5305424c62', portraitURL: '/file/db/thang.type/54d2b797051a3a5305424c62/portrait.png', kind: 'Doodad'} + {slug: 'brawlwood', name: 'Brawlwood', original: '533b1f1642aef2202fdcc487', portraitURL: '/file/db/thang.type/533b1f1642aef2202fdcc487/portrait.png', kind: 'Floor'} + {slug: 'breakout-background', name: 'Breakout Background', original: '56c65f8b79735337006047df', portraitURL: '/file/db/thang.type/56c65f8b79735337006047df/portrait.png', kind: 'Floor'} + {slug: 'broken-tower', name: 'Broken Tower', original: '5376b2caff7b2d3805a396a9', portraitURL: '/file/db/thang.type/5376b2caff7b2d3805a396a9/portrait.png', kind: 'Doodad'} + {slug: 'bronze-coin', name: 'Bronze Coin', original: '535ef2d54f10444d08486ba8', portraitURL: '/file/db/thang.type/535ef2d54f10444d08486ba8/portrait.png', kind: 'Misc'} + {slug: 'bronze-shield', name: 'Bronze Shield', original: '544c310ae0017993fce214bf', portraitURL: '/file/db/thang.type/544c310ae0017993fce214bf/portrait.png', kind: 'Item'} + {slug: 'bullet', name: 'Bullet', original: '544d82bd8494308424f564d0', portraitURL: '/file/db/thang.type/544d82bd8494308424f564d0/portrait.png', kind: 'Missile'} + {slug: 'cabin-1', name: 'Cabin 1', original: '54e93b41970f0b0a263c0400', portraitURL: '/file/db/thang.type/54e93b41970f0b0a263c0400/portrait.png', kind: 'Doodad'} + {slug: 'cabin-2', name: 'Cabin 2', original: '54e93cb4970f0b0a263c0406', portraitURL: '/file/db/thang.type/54e93cb4970f0b0a263c0406/portrait.png', kind: 'Doodad'} + {slug: 'cabin-3', name: 'Cabin 3', original: '54e93d1cf54ef5794f354e7d', portraitURL: '/file/db/thang.type/54e93d1cf54ef5794f354e7d/portrait.png', kind: 'Doodad'} + {slug: 'cabin-4', name: 'Cabin 4', original: '54e93db7f54ef5794f354e83', portraitURL: '/file/db/thang.type/54e93db7f54ef5794f354e83/portrait.png', kind: 'Doodad'} + {slug: 'cabinet', name: 'Cabinet', original: '54ef9101c1f3bd7c0593f232', portraitURL: '/file/db/thang.type/54ef9101c1f3bd7c0593f232/portrait.png', kind: 'Doodad'} + {slug: 'cactus-1', name: 'Cactus 1', original: '546e24949df4a17d0d449bc5', portraitURL: '/file/db/thang.type/546e24949df4a17d0d449bc5/portrait.png', kind: 'Doodad'} + {slug: 'cactus-2', name: 'Cactus 2', original: '546e24039df4a17d0d449bb9', portraitURL: '/file/db/thang.type/546e24039df4a17d0d449bb9/portrait.png', kind: 'Doodad'} + {slug: 'caltrop-belt', name: 'Caltrop Belt', original: '54694af7a2b1f53ce7944441', portraitURL: '/file/db/thang.type/54694af7a2b1f53ce7944441/portrait.png', kind: 'Item'} + {slug: 'caltrops', name: 'Caltrops', original: '557f9700b43ce0b15a91b1ed', portraitURL: '/file/db/thang.type/557f9700b43ce0b15a91b1ed/portrait.png', kind: 'Doodad'} + {slug: 'camel', name: 'Camel', original: '548cf4cd0f559d0000be7e57', portraitURL: '/file/db/thang.type/548cf4cd0f559d0000be7e57/portrait.png', kind: 'Doodad'} + {slug: 'camp-fire', name: 'Camp Fire', original: '52e097c110012a5b250000b2', portraitURL: '/file/db/thang.type/52e097c110012a5b250000b2/portrait.png', kind: 'Doodad'} + {slug: 'campfire-stone', name: 'Campfire Stone', original: '54f118e125be5e880583759a', portraitURL: '/file/db/thang.type/54f118e125be5e880583759a/portrait.png', kind: 'Doodad'} + {slug: 'candle', name: 'Candle', original: '52e95fb222efc8e7090017d7', portraitURL: '/file/db/thang.type/52e95fb222efc8e7090017d7/portrait.png', kind: 'Doodad'} + {slug: 'carved-steel-ring', name: 'Carved Steel Ring', original: '54692dfaa2b1f53ce794439f', portraitURL: '/file/db/thang.type/54692dfaa2b1f53ce794439f/portrait.png', kind: 'Item'} + {slug: 'catapult', name: 'Catapult', original: '553e7ba29bdea5d00f1fd905', portraitURL: '/file/db/thang.type/553e7ba29bdea5d00f1fd905/portrait.png', kind: 'Unit'} + {slug: 'cave', name: 'Cave', original: '52e95983427172ae560018ce', portraitURL: '/file/db/thang.type/52e95983427172ae560018ce/portrait.png', kind: 'Doodad'} + {slug: 'chainmail-tunic', name: 'Chainmail Tunic', original: '5441c4dd4e9aeb727cc9713b', portraitURL: '/file/db/thang.type/5441c4dd4e9aeb727cc9713b/portrait.png', kind: 'Item'} + {slug: 'chains', name: 'Chains', original: '52aa602020fccb0000000004', portraitURL: '/file/db/thang.type/52aa602020fccb0000000004/portrait.png', kind: 'Doodad'} + {slug: 'chair', name: 'Chair', original: '52e9960e427172ae56001fdf', portraitURL: '/file/db/thang.type/52e9960e427172ae56001fdf/portrait.png', kind: 'Doodad'} + {slug: 'charge-belt', name: 'Charge Belt', original: '54694b27a2b1f53ce7944445', portraitURL: '/file/db/thang.type/54694b27a2b1f53ce7944445/portrait.png', kind: 'Item'} + {slug: 'choppable-tree-1', name: 'Choppable Tree 1', original: '52fbd1d67e01835453bd8a26', portraitURL: '/file/db/thang.type/52fbd1d67e01835453bd8a26/portrait.png', kind: 'Doodad'} + {slug: 'choppable-tree-2', name: 'Choppable Tree 2', original: '52fbd7e07e01835453bd8afc', portraitURL: '/file/db/thang.type/52fbd7e07e01835453bd8afc/portrait.png', kind: 'Doodad'} + {slug: 'choppable-tree-3', name: 'Choppable Tree 3', original: '52fbd9beab6e45c813bc79c6', portraitURL: '/file/db/thang.type/52fbd9beab6e45c813bc79c6/portrait.png', kind: 'Doodad'} + {slug: 'choppable-tree-4', name: 'Choppable Tree 4', original: '52fbdb747e01835453bd8b4a', portraitURL: '/file/db/thang.type/52fbdb747e01835453bd8b4a/portrait.png', kind: 'Doodad'} + {slug: 'circle-tree-stand-1', name: 'Circle Tree Stand 1', original: '541cb842c6362edfb0f3447d', portraitURL: '/file/db/thang.type/541cb842c6362edfb0f3447d/portrait.png', kind: 'Doodad'} + {slug: 'circle-tree-stand-2', name: 'Circle Tree Stand 2', original: '541cc5708e78524aad94de69', portraitURL: '/file/db/thang.type/541cc5708e78524aad94de69/portrait.png', kind: 'Doodad'} + {slug: 'circle-tree-stand-3', name: 'Circle Tree Stand 3', original: '541cc6898e78524aad94de6f', portraitURL: '/file/db/thang.type/541cc6898e78524aad94de6f/portrait.png', kind: 'Doodad'} + {slug: 'circlet-of-the-magi', name: 'Circlet of the Magi', original: '54ea39342b7506e891ca70f2', portraitURL: '/file/db/thang.type/54ea39342b7506e891ca70f2/portrait.png', kind: 'Item'} + {slug: 'classroom-bench', name: 'classroom bench', original: '56eb09520c6e9f1f00990e81', portraitURL: '/file/db/thang.type/56eb09520c6e9f1f00990e81/portrait.png', kind: 'Doodad'} + {slug: 'classroom-floor', name: 'Classroom Floor', original: '56a139f9d987c52900d4de5a', portraitURL: '/file/db/thang.type/56a139f9d987c52900d4de5a/portrait.png', kind: 'Floor'} + {slug: 'classroom-sculpture', name: 'Classroom Sculpture', original: '56a16510088f002400720564', portraitURL: '/file/db/thang.type/56a16510088f002400720564/portrait.png', kind: 'Doodad'} + {slug: 'classroom-students-desk', name: 'Classroom Students Desk', original: '56a15d88d987c52900d4ecdb', portraitURL: '/file/db/thang.type/56a15d88d987c52900d4ecdb/portrait.png', kind: 'Doodad'} + {slug: 'classroom-students-seat', name: 'Classroom Students Seat', original: '56a162348431922e0042fae3', portraitURL: '/file/db/thang.type/56a162348431922e0042fae3/portrait.png', kind: 'Doodad'} + {slug: 'classroom-viewscreen', name: 'Classroom Viewscreen', original: '569fdf3c6ff9591f000050bf', portraitURL: '/file/db/thang.type/569fdf3c6ff9591f000050bf/portrait.png', kind: 'Doodad'} + {slug: 'classroom-wall', name: 'Classroom Wall', original: '56a0150cf363ed1f0029e11c', portraitURL: '/file/db/thang.type/56a0150cf363ed1f0029e11c/portrait.png', kind: 'Wall'} + {slug: 'claymore', name: 'Claymore', original: '544d6d4a8494308424f56471', portraitURL: '/file/db/thang.type/544d6d4a8494308424f56471/portrait.png', kind: 'Item'} + {slug: 'cloud-1', name: 'Cloud 1', original: '550b42b7343675176d05a919', portraitURL: '/file/db/thang.type/550b42b7343675176d05a919/portrait.png', kind: 'Doodad'} + {slug: 'cloud-2', name: 'Cloud 2', original: '550b43fc343675176d05a923', portraitURL: '/file/db/thang.type/550b43fc343675176d05a923/portrait.png', kind: 'Doodad'} + {slug: 'cloud-3', name: 'Cloud 3', original: '550b4506343675176d05a933', portraitURL: '/file/db/thang.type/550b4506343675176d05a933/portrait.png', kind: 'Doodad'} + {slug: 'coin', name: 'Coin', original: '52aa3a8fccbd588d4d000001', portraitURL: '/file/db/thang.type/52aa3a8fccbd588d4d000001/portrait.png', kind: 'Misc'} + {slug: 'compound-boots', name: 'Compound Boots', original: '546d4d8e9df4a17d0d449acd', portraitURL: '/file/db/thang.type/546d4d8e9df4a17d0d449acd/portrait.png', kind: 'Item'} + {slug: 'cougar-pet', name: 'Cougar Pet', original: '540f3a33821af8000097dc62', portraitURL: '/file/db/thang.type/540f3a33821af8000097dc62/portrait.png', kind: 'Unit'} + {slug: 'crevasse-1', name: 'Crevasse 1', original: '5576080a1e82182d9e6888cd', portraitURL: '/file/db/thang.type/5576080a1e82182d9e6888cd/portrait.png', kind: 'Doodad'} + {slug: 'crevasse-2', name: 'Crevasse 2', original: '557630c31e82182d9e688921', portraitURL: '/file/db/thang.type/557630c31e82182d9e688921/portrait.png', kind: 'Doodad'} + {slug: 'crevasse-3', name: 'Crevasse 3', original: '557631321e82182d9e688925', portraitURL: '/file/db/thang.type/557631321e82182d9e688925/portrait.png', kind: 'Doodad'} + {slug: 'crisscross-back', name: 'Crisscross Back', original: '53b495e37e17883a05754216', portraitURL: '/file/db/thang.type/53b495e37e17883a05754216/portrait.png', kind: 'Floor'} + {slug: 'crisscross-front', name: 'Crisscross Front', original: '53b495b02082f23505b844e5', portraitURL: '/file/db/thang.type/53b495b02082f23505b844e5/portrait.png', kind: 'Floor'} + {slug: 'cross-bones-background', name: 'Cross Bones Background', original: '572e51175366918e018060e5', portraitURL: '/file/db/thang.type/572e51175366918e018060e5/portrait.png', kind: 'Floor'} + {slug: 'crossbeam-support', name: 'crossbeam support', original: '5786828a0d397a2e0026f274', portraitURL: '/file/db/thang.type/5786828a0d397a2e0026f274/portrait.png', kind: 'Doodad'} + {slug: 'crossbow', name: 'Crossbow', original: '53e21ae653457600003e3ec2', portraitURL: '/file/db/thang.type/53e21ae653457600003e3ec2/portrait.png', kind: 'Item'} + {slug: 'crude-builders-hammer', name: 'Crude Builder\'s Hammer', original: '53f4e6e3d822c23505b74f42', portraitURL: '/file/db/thang.type/53f4e6e3d822c23505b74f42/portrait.png', kind: 'Item'} + {slug: 'crude-crossbow', name: 'Crude Crossbow', original: '544d7ffd8494308424f564c3', portraitURL: '/file/db/thang.type/544d7ffd8494308424f564c3/portrait.png', kind: 'Item'} + {slug: 'crude-dagger', name: 'Crude Dagger', original: '544d952b8494308424f56517', portraitURL: '/file/db/thang.type/544d952b8494308424f56517/portrait.png', kind: 'Item'} + {slug: 'crude-dagger-missile', name: 'Crude Dagger Missile', original: '546e292d9df4a17d0d449c0c', portraitURL: '/file/db/thang.type/546e292d9df4a17d0d449c0c/portrait.png', kind: 'Missile'} + {slug: 'crude-glasses', name: 'Crude Glasses', original: '53e238df53457600003e3f0b', portraitURL: '/file/db/thang.type/53e238df53457600003e3f0b/portrait.png', kind: 'Item'} + {slug: 'crude-spike', name: 'Crude Spike', original: '544d79e28494308424f56482', portraitURL: '/file/db/thang.type/544d79e28494308424f56482/portrait.png', kind: 'Item'} + {slug: 'crude-telephoto-glasses', name: 'Crude Telephoto Glasses', original: '5469415aa2b1f53ce7944411', portraitURL: '/file/db/thang.type/5469415aa2b1f53ce7944411/portrait.png', kind: 'Item'} + {slug: 'crypt-key', name: 'Crypt Key', original: '54eb573549fa2d5c905ddf36', portraitURL: '/file/db/thang.type/54eb573549fa2d5c905ddf36/portrait.png', kind: 'Item'} + {slug: 'crystal-wand', name: 'Crystal Wand', original: '54eab63b2b7506e891ca71f2', portraitURL: '/file/db/thang.type/54eab63b2b7506e891ca71f2/portrait.png', kind: 'Item'} + {slug: 'cupboards-of-kgard-background', name: 'Cupboards of Kgard background', original: '56994ec3d32e4c1f0075460d', portraitURL: '/file/db/thang.type/56994ec3d32e4c1f0075460d/portrait.png', kind: 'Floor'} + {slug: 'curse', name: 'Curse', original: '53024d18a6efdd32359c5365', portraitURL: '/file/db/thang.type/53024d18a6efdd32359c5365/portrait.png', kind: 'Mark'} + {slug: 'cut-garnet-sense-stone', name: 'Cut Garnet Sense Stone', original: '546933a5a2b1f53ce79443d5', portraitURL: '/file/db/thang.type/546933a5a2b1f53ce79443d5/portrait.png', kind: 'Item'} + {slug: 'cut-stone-builders-hammer', name: 'Cut Stone Builder\'s Hammer', original: '54694c0ba2b1f53ce7944456', portraitURL: '/file/db/thang.type/54694c0ba2b1f53ce7944456/portrait.png', kind: 'Item'} + {slug: 'darksteel-blade', name: 'Darksteel Blade', original: '544d7f558494308424f564bb', portraitURL: '/file/db/thang.type/544d7f558494308424f564bb/portrait.png', kind: 'Item'} + {slug: 'deadeye-crossbow', name: 'Deadeye Crossbow', original: '54eaad752b7506e891ca71d1', portraitURL: '/file/db/thang.type/54eaad752b7506e891ca71d1/portrait.png', kind: 'Item'} + {slug: 'decoy', name: 'Decoy', original: '5498bb758e52573b10d3bce6', portraitURL: '/file/db/thang.type/5498bb758e52573b10d3bce6/portrait.png', kind: 'Unit'} + {slug: 'defensive-boots', name: 'Defensive Boots', original: '546d4e019df4a17d0d449ad1', portraitURL: '/file/db/thang.type/546d4e019df4a17d0d449ad1/portrait.png', kind: 'Item'} + {slug: 'defensive-infantry-shield', name: 'Defensive Infantry Shield', original: '544d7b408494308424f5648f', portraitURL: '/file/db/thang.type/544d7b408494308424f5648f/portrait.png', kind: 'Item'} + {slug: 'deflector', name: 'Deflector', original: '54eabff349fa2d5c905ddeee', portraitURL: '/file/db/thang.type/54eabff349fa2d5c905ddeee/portrait.png', kind: 'Item'} + {slug: 'derrick', name: 'Derrick', original: '546e24339df4a17d0d449bbd', portraitURL: '/file/db/thang.type/546e24339df4a17d0d449bbd/portrait.png', kind: 'Doodad'} + {slug: 'desert-bones-1', name: 'Desert Bones 1', original: '548cf0cc0f559d0000be7e27', portraitURL: '/file/db/thang.type/548cf0cc0f559d0000be7e27/portrait.png', kind: 'Doodad'} + {slug: 'desert-bones-3', name: 'Desert Bones 3', original: '548cf1630f559d0000be7e2f', portraitURL: '/file/db/thang.type/548cf1630f559d0000be7e2f/portrait.png', kind: 'Doodad'} + {slug: 'desert-green-1', name: 'Desert Green 1', original: '548cef670f559d0000be7e17', portraitURL: '/file/db/thang.type/548cef670f559d0000be7e17/portrait.png', kind: 'Doodad'} + {slug: 'desert-green-2', name: 'Desert Green 2', original: '548cefc50f559d0000be7e1b', portraitURL: '/file/db/thang.type/548cefc50f559d0000be7e1b/portrait.png', kind: 'Doodad'} + {slug: 'desert-house-1', name: 'Desert House 1', original: '548cf35a0f559d0000be7e43', portraitURL: '/file/db/thang.type/548cf35a0f559d0000be7e43/portrait.png', kind: 'Doodad'} + {slug: 'desert-house-2', name: 'Desert House 2', original: '548cf3ae0f559d0000be7e47', portraitURL: '/file/db/thang.type/548cf3ae0f559d0000be7e47/portrait.png', kind: 'Doodad'} + {slug: 'desert-house-3', name: 'Desert House 3', original: '548cf4000f559d0000be7e4b', portraitURL: '/file/db/thang.type/548cf4000f559d0000be7e4b/portrait.png', kind: 'Doodad'} + {slug: 'desert-house-4', name: 'Desert House 4', original: '548cf44c0f559d0000be7e4f', portraitURL: '/file/db/thang.type/548cf44c0f559d0000be7e4f/portrait.png', kind: 'Doodad'} + {slug: 'desert-palm-1', name: 'Desert Palm 1', original: '548cf0110f559d0000be7e1f', portraitURL: '/file/db/thang.type/548cf0110f559d0000be7e1f/portrait.png', kind: 'Doodad'} + {slug: 'desert-palm-2', name: 'Desert Palm 2', original: '548cf06f0f559d0000be7e23', portraitURL: '/file/db/thang.type/548cf06f0f559d0000be7e23/portrait.png', kind: 'Doodad'} + {slug: 'desert-pillar', name: 'Desert Pillar', original: '541c5ff487338f570851ad83', portraitURL: '/file/db/thang.type/541c5ff487338f570851ad83/portrait.png', kind: 'Doodad'} + {slug: 'desert-pyramid', name: 'Desert Pyramid', original: '53e239c253457600003e3f11', portraitURL: '/file/db/thang.type/53e239c253457600003e3f11/portrait.png', kind: 'Doodad'} + {slug: 'desert-rubble-1', name: 'Desert Rubble 1', original: '53126c48f5a594b00fbfcc42', portraitURL: '/file/db/thang.type/53126c48f5a594b00fbfcc42/portrait.png', kind: 'Doodad'} + {slug: 'desert-rubble-2', name: 'Desert Rubble 2', original: '52f01b0b5071878f7650e11a', portraitURL: '/file/db/thang.type/52f01b0b5071878f7650e11a/portrait.png', kind: 'Doodad'} + {slug: 'desert-rubble-3', name: 'Desert Rubble 3', original: '546e23a89df4a17d0d449bb1', portraitURL: '/file/db/thang.type/546e23a89df4a17d0d449bb1/portrait.png', kind: 'Doodad'} + {slug: 'desert-sand-rock', name: 'Desert Sand Rock', original: '55c64774ef141c65665beb84', portraitURL: '/file/db/thang.type/55c64774ef141c65665beb84/portrait.png', kind: 'Doodad'} + {slug: 'desert-shrub-big-1', name: 'Desert Shrub Big 1', original: '546e237d9df4a17d0d449bad', portraitURL: '/file/db/thang.type/546e237d9df4a17d0d449bad/portrait.png', kind: 'Doodad'} + {slug: 'desert-shrub-big-2', name: 'Desert Shrub Big 2', original: '546e22c59df4a17d0d449ba1', portraitURL: '/file/db/thang.type/546e22c59df4a17d0d449ba1/portrait.png', kind: 'Doodad'} + {slug: 'desert-shrub-big-3', name: 'Desert Shrub Big 3', original: '53f4c776d822c23505b7091c', portraitURL: '/file/db/thang.type/53f4c776d822c23505b7091c/portrait.png', kind: 'Doodad'} + {slug: 'desert-shrub-small-1', name: 'Desert Shrub Small 1', original: '548ceec80f559d0000be7e0f', portraitURL: '/file/db/thang.type/548ceec80f559d0000be7e0f/portrait.png', kind: 'Doodad'} + {slug: 'desert-shrub-small-2', name: 'Desert Shrub Small 2', original: '548cef1f0f559d0000be7e13', portraitURL: '/file/db/thang.type/548cef1f0f559d0000be7e13/portrait.png', kind: 'Doodad'} + {slug: 'desert-skullcave', name: 'Desert Skullcave', original: '546e231c9df4a17d0d449ba5', portraitURL: '/file/db/thang.type/546e231c9df4a17d0d449ba5/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-1', name: 'Desert Wall 1', original: '5404fe5f1d10b2f170618ae9', portraitURL: '/file/db/thang.type/5404fe5f1d10b2f170618ae9/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-2', name: 'Desert Wall 2', original: '540100ba794c1a8b4d328437', portraitURL: '/file/db/thang.type/540100ba794c1a8b4d328437/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-3', name: 'Desert Wall 3', original: '53f4e7fff7bc7336054dcf64', portraitURL: '/file/db/thang.type/53f4e7fff7bc7336054dcf64/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-4', name: 'Desert Wall 4', original: '53f3ef04e7a7643005c0f4a1', portraitURL: '/file/db/thang.type/53f3ef04e7a7643005c0f4a1/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-5', name: 'Desert Wall 5', original: '53ebafdd1a100989a40ce479', portraitURL: '/file/db/thang.type/53ebafdd1a100989a40ce479/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-6', name: 'Desert Wall 6', original: '53eb989b1a100989a40ce46a', portraitURL: '/file/db/thang.type/53eb989b1a100989a40ce46a/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-7', name: 'Desert Wall 7', original: '53eaa7de786ccc3405a9f2a4', portraitURL: '/file/db/thang.type/53eaa7de786ccc3405a9f2a4/portrait.png', kind: 'Doodad'} + {slug: 'desert-wall-8', name: 'Desert Wall 8', original: '53eaa6f6ef27b33605514a64', portraitURL: '/file/db/thang.type/53eaa6f6ef27b33605514a64/portrait.png', kind: 'Doodad'} + {slug: 'desert-well', name: 'Desert Well', original: '548cf4880f559d0000be7e53', portraitURL: '/file/db/thang.type/548cf4880f559d0000be7e53/portrait.png', kind: 'Doodad'} + {slug: 'destroyed-human-tower', name: 'destroyed human tower', original: '57867e5acca8994b002702a9', portraitURL: '/file/db/thang.type/57867e5acca8994b002702a9/portrait.png', kind: 'Doodad'} + {slug: 'destroyed-human-tower-with-trees', name: 'destroyed human tower with trees', original: '572d5abed7787fc300d85964', portraitURL: '/file/db/thang.type/572d5abed7787fc300d85964/portrait.png', kind: 'Doodad'} + {slug: 'destroyed-human-tower-with-trees-2', name: 'destroyed human tower with trees 2', original: '572d5b42d7787fc300d8596f', portraitURL: '/file/db/thang.type/572d5b42d7787fc300d8596f/portrait.png', kind: 'Doodad'} + {slug: 'destroyed-ogre-tower-footing', name: 'destroyed ogre tower footing', original: '578680980d397a2e0026eff9', portraitURL: '/file/db/thang.type/578680980d397a2e0026eff9/portrait.png', kind: 'Doodad'} + {slug: 'diamond-sense-stone', name: 'Diamond Sense Stone', original: '546934b7a2b1f53ce79443e1', portraitURL: '/file/db/thang.type/546934b7a2b1f53ce79443e1/portrait.png', kind: 'Item'} + {slug: 'dirt-path-1', name: 'Dirt Path 1', original: '5302acfd27471514685d5fd4', portraitURL: '/file/db/thang.type/5302acfd27471514685d5fd4/portrait.png', kind: 'Floor'} + {slug: 'disintegrate', name: 'Disintegrate', original: '54d2bb1abb157252059b1d29', portraitURL: '/file/db/thang.type/54d2bb1abb157252059b1d29/portrait.png', kind: 'Mark'} + {slug: 'dispel', name: 'Dispel', original: '55c2807d3767fd3435eb4465', portraitURL: '/file/db/thang.type/55c2807d3767fd3435eb4465/portrait.png', kind: 'Mark'} + {slug: 'dragonscale-chainmail-coif', name: 'Dragonscale Chainmail Coif', original: '546d477d9df4a17d0d449a6b', portraitURL: '/file/db/thang.type/546d477d9df4a17d0d449a6b/portrait.png', kind: 'Item'} + {slug: 'dragonscale-chainmail-tunic', name: 'Dragonscale Chainmail Tunic', original: '546d3d149df4a17d0d449a43', portraitURL: '/file/db/thang.type/546d3d149df4a17d0d449a43/portrait.png', kind: 'Item'} + {slug: 'dragontooth', name: 'Dragontooth', original: '54eb51d349fa2d5c905ddf0e', portraitURL: '/file/db/thang.type/54eb51d349fa2d5c905ddf0e/portrait.png', kind: 'Item'} + {slug: 'drain-life', name: 'Drain Life', original: '54d2bc5b4e4a08550556da55', portraitURL: '/file/db/thang.type/54d2bc5b4e4a08550556da55/portrait.png', kind: 'Mark'} + {slug: 'dread-door-background', name: 'Dread Door Background', original: '572e46a3f8c4f9b601ede6c0', portraitURL: '/file/db/thang.type/572e46a3f8c4f9b601ede6c0/portrait.png', kind: 'Floor'} + {slug: 'dueling-grounds-background', name: 'Dueling Grounds Background', original: '572e5163e8db5195014848b3', portraitURL: '/file/db/thang.type/572e5163e8db5195014848b3/portrait.png', kind: 'Floor'} + {slug: 'dunes', name: 'Dunes', original: '546e251d9df4a17d0d449bd1', portraitURL: '/file/db/thang.type/546e251d9df4a17d0d449bd1/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-door', name: 'Dungeon Door', original: '52a0e5123abf480000000001', portraitURL: '/file/db/thang.type/52a0e5123abf480000000001/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-entrance', name: 'Dungeon Entrance', original: '544d850e8494308424f564dd', portraitURL: '/file/db/thang.type/544d850e8494308424f564dd/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-floor', name: 'Dungeon Floor', original: '52af688f6320a8049d000001', portraitURL: '/file/db/thang.type/52af688f6320a8049d000001/portrait.png', kind: 'Floor'} + {slug: 'dungeon-pillar', name: 'Dungeon Pillar', original: '543ea0ff9692aa00006208e7', portraitURL: '/file/db/thang.type/543ea0ff9692aa00006208e7/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-pit', name: 'Dungeon Pit', original: '52b09408ccbc671372000002', portraitURL: '/file/db/thang.type/52b09408ccbc671372000002/portrait.png', kind: 'Floor'} + {slug: 'dungeon-rock-1', name: 'Dungeon Rock 1', original: '54ef944764112781056c1f96', portraitURL: '/file/db/thang.type/54ef944764112781056c1f96/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-rock-2', name: 'Dungeon Rock 2', original: '54ef99bf223edd8105b00eaa', portraitURL: '/file/db/thang.type/54ef99bf223edd8105b00eaa/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-rock-3', name: 'Dungeon Rock 3', original: '54ef9af5b4740779058448c6', portraitURL: '/file/db/thang.type/54ef9af5b4740779058448c6/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-rock-4', name: 'Dungeon Rock 4', original: '54ef9c26933e1e7b0584663e', portraitURL: '/file/db/thang.type/54ef9c26933e1e7b0584663e/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-rock-5', name: 'Dungeon Rock 5', original: '54ef9d376aea7d7805535cc8', portraitURL: '/file/db/thang.type/54ef9d376aea7d7805535cc8/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-rock-group', name: 'Dungeon Rock Group', original: '54ef9e0583b08b7d054ba331', portraitURL: '/file/db/thang.type/54ef9e0583b08b7d054ba331/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-stairs-horizontal', name: 'Dungeon Stairs Horizontal', original: '5463dc27c295cc4fb9c06257', portraitURL: '/file/db/thang.type/5463dc27c295cc4fb9c06257/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-stairs-vertical', name: 'Dungeon Stairs Vertical', original: '5463d8a0c295cc4fb9c06255', portraitURL: '/file/db/thang.type/5463d8a0c295cc4fb9c06255/portrait.png', kind: 'Doodad'} + {slug: 'dungeon-wall', name: 'Dungeon Wall', original: '529e7aecc423d4e83b000004', portraitURL: '/file/db/thang.type/529e7aecc423d4e83b000004/portrait.png', kind: 'Wall'} + {slug: 'dungeons-of-kgard-background', name: 'Dungeons of Kgard Background', original: '563d3c02f5b71e8405fabff8', portraitURL: '/file/db/thang.type/563d3c02f5b71e8405fabff8/portrait.png', kind: 'Floor'} + {slug: 'dynamic-flags', name: 'Dynamic Flags', original: '5478b9068707a2c3a2493b2b', portraitURL: '/file/db/thang.type/5478b9068707a2c3a2493b2b/portrait.png', kind: 'Item'} + {slug: 'earthskin', name: 'Earthskin', original: '54d2bcf66ec7cf53051e7855', portraitURL: '/file/db/thang.type/54d2bcf66ec7cf53051e7855/portrait.png', kind: 'Mark'} + {slug: 'east-mounted-camera-facing-east-west', name: 'east mounted camera facing east west', original: '56f183091e1daf0a016c670b', portraitURL: '/file/db/thang.type/56f183091e1daf0a016c670b/portrait.png', kind: 'Doodad'} + {slug: 'east-mounted-camera-facing-north', name: 'east mounted camera facing north', original: '56f1782541c1a0cb00f8d66c', portraitURL: '/file/db/thang.type/56f1782541c1a0cb00f8d66c/portrait.png', kind: 'Doodad'} + {slug: 'east-mounted-camera-facing-south', name: 'east mounted camera facing south', original: '56f1811841c1a0cb00f8ddb1', portraitURL: '/file/db/thang.type/56f1811841c1a0cb00f8ddb1/portrait.png', kind: 'Doodad'} + {slug: 'edge-of-darkness', name: 'Edge of Darkness', original: '54eaa8762b7506e891ca71a9', portraitURL: '/file/db/thang.type/54eaa8762b7506e891ca71a9/portrait.png', kind: 'Item'} + {slug: 'eldritch-icicle', name: 'Eldritch Icicle', original: '54ea311e2b7506e891ca70b0', portraitURL: '/file/db/thang.type/54ea311e2b7506e891ca70b0/portrait.png', kind: 'Item'} + {slug: 'electrocute', name: 'Electrocute', original: '55c281263767fd3435eb4469', portraitURL: '/file/db/thang.type/55c281263767fd3435eb4469/portrait.png', kind: 'Mark'} + {slug: 'electrowall', name: 'Electrowall', original: '54177e26571f116c0b1f00c0', portraitURL: '/file/db/thang.type/54177e26571f116c0b1f00c0/portrait.png', kind: 'Doodad'} + {slug: 'elemental-codex-i', name: 'Elemental Codex I', original: '5463755a3839c6e02811d30a', portraitURL: '/file/db/thang.type/5463755a3839c6e02811d30a/portrait.png', kind: 'Item'} + {slug: 'elemental-codex-ii', name: 'Elemental Codex II', original: '546375783839c6e02811d30d', portraitURL: '/file/db/thang.type/546375783839c6e02811d30d/portrait.png', kind: 'Item'} + {slug: 'elemental-codex-iii', name: 'Elemental Codex III', original: '5463759c3839c6e02811d310', portraitURL: '/file/db/thang.type/5463759c3839c6e02811d310/portrait.png', kind: 'Item'} + {slug: 'elemental-codex-iv', name: 'Elemental Codex IV', original: '546376bf3839c6e02811d31c', portraitURL: '/file/db/thang.type/546376bf3839c6e02811d31c/portrait.png', kind: 'Item'} + {slug: 'elemental-codex-v', name: 'Elemental Codex V', original: '546376e23839c6e02811d31f', portraitURL: '/file/db/thang.type/546376e23839c6e02811d31f/portrait.png', kind: 'Item'} + {slug: 'embroidered-griffin-wool-hat', name: 'Embroidered Griffin Wool Hat', original: '546d4ca19df4a17d0d449abf', portraitURL: '/file/db/thang.type/546d4ca19df4a17d0d449abf/portrait.png', kind: 'Item'} + {slug: 'embroidered-griffin-wool-robe', name: 'Embroidered Griffin Wool Robe', original: '546d4a549df4a17d0d449a97', portraitURL: '/file/db/thang.type/546d4a549df4a17d0d449a97/portrait.png', kind: 'Item'} + {slug: 'emerald-chainmail-coif', name: 'Emerald Chainmail Coif', original: '546d46cf9df4a17d0d449a63', portraitURL: '/file/db/thang.type/546d46cf9df4a17d0d449a63/portrait.png', kind: 'Item'} + {slug: 'emerald-chainmail-tunic', name: 'Emerald Chainmail Tunic', original: '546d3c8d9df4a17d0d449a3b', portraitURL: '/file/db/thang.type/546d3c8d9df4a17d0d449a3b/portrait.png', kind: 'Item'} + {slug: 'emperors-gloves', name: 'Emperor\'s Gloves', original: '546949aca2b1f53ce7944431', portraitURL: '/file/db/thang.type/546949aca2b1f53ce7944431/portrait.png', kind: 'Item'} + {slug: 'enameled-dragonplate', name: 'Enameled Dragonplate', original: '546ab1e53777d61863292876', portraitURL: '/file/db/thang.type/546ab1e53777d61863292876/portrait.png', kind: 'Item'} + {slug: 'enameled-dragonplate-helmet', name: 'Enameled Dragonplate Helmet', original: '546d3a539df4a17d0d449a1f', portraitURL: '/file/db/thang.type/546d3a539df4a17d0d449a1f/portrait.png', kind: 'Item'} + {slug: 'enameled-dragonshield', name: 'Enameled Dragonshield', original: '54eabf022b7506e891ca7236', portraitURL: '/file/db/thang.type/54eabf022b7506e891ca7236/portrait.png', kind: 'Item'} + {slug: 'enchanted-lambswool-cloak', name: 'Enchanted Lambswool Cloak', original: '546d49109df4a17d0d449a7f', portraitURL: '/file/db/thang.type/546d49109df4a17d0d449a7f/portrait.png', kind: 'Item'} + {slug: 'enchanted-lenses', name: 'Enchanted Lenses', original: '546941cba2b1f53ce7944419', portraitURL: '/file/db/thang.type/546941cba2b1f53ce7944419/portrait.png', kind: 'Item'} + {slug: 'enchanted-stick', name: 'Enchanted Stick', original: '544d87188494308424f564f1', portraitURL: '/file/db/thang.type/544d87188494308424f564f1/portrait.png', kind: 'Item'} + {slug: 'enemy-mine-background', name: 'Enemy Mine Background', original: '563cdd340b2c7c87054e102b', portraitURL: '/file/db/thang.type/563cdd340b2c7c87054e102b/portrait.png', kind: 'Floor'} + {slug: 'energy-ball', name: 'Energy Ball', original: '53025d83222f73867774d8ed', portraitURL: '/file/db/thang.type/53025d83222f73867774d8ed/portrait.png', kind: 'Missile'} + {slug: 'energy-ball-diet', name: 'Energy Ball Diet', original: '531a6ddf1ddc910545d5e96d', portraitURL: '/file/db/thang.type/531a6ddf1ddc910545d5e96d/portrait.png', kind: 'Missile'} + {slug: 'enforcer', name: 'Enforcer', original: '56d0ca1263103d2a00af5331', portraitURL: '/file/db/thang.type/56d0ca1263103d2a00af5331/portrait.png', kind: 'Unit'} + {slug: 'engraved-builders-hammer', name: 'Engraved Builder\'s Hammer', original: '54694ca7a2b1f53ce7944462', portraitURL: '/file/db/thang.type/54694ca7a2b1f53ce7944462/portrait.png', kind: 'Item'} + {slug: 'engraved-obsidian-breastplate', name: 'Engraved Obsidian Breastplate', original: '546ab15e3777d6186329286e', portraitURL: '/file/db/thang.type/546ab15e3777d6186329286e/portrait.png', kind: 'Item'} + {slug: 'engraved-obsidian-helmet', name: 'Engraved Obsidian Helmet', original: '546d39d89df4a17d0d449a17', portraitURL: '/file/db/thang.type/546d39d89df4a17d0d449a17/portrait.png', kind: 'Item'} + {slug: 'engraved-obsidian-shield', name: 'Engraved Obsidian Shield', original: '54eabbd22b7506e891ca721e', portraitURL: '/file/db/thang.type/54eabbd22b7506e891ca721e/portrait.png', kind: 'Item'} + {slug: 'engraved-wristwatch', name: 'Engraved Wristwatch', original: '546937dea2b1f53ce79443ed', portraitURL: '/file/db/thang.type/546937dea2b1f53ce79443ed/portrait.png', kind: 'Item'} + {slug: 'explosive-potion', name: 'Explosive Potion', original: '5466d9a5417c8b48a9811e8e', portraitURL: '/file/db/thang.type/5466d9a5417c8b48a9811e8e/portrait.png', kind: 'Missile'} + {slug: 'ezeroths-timepiece', name: 'Ezeroth\'s Timepiece', original: '546938cea2b1f53ce79443f5', portraitURL: '/file/db/thang.type/546938cea2b1f53ce79443f5/portrait.png', kind: 'Item'} + {slug: 'farm', name: 'Farm', original: '52ea853d427172ae56003494', portraitURL: '/file/db/thang.type/52ea853d427172ae56003494/portrait.png', kind: 'Doodad'} + {slug: 'faux-fur-hat', name: 'Faux Fur Hat', original: '5441c2be4e9aeb727cc97105', portraitURL: '/file/db/thang.type/5441c2be4e9aeb727cc97105/portrait.png', kind: 'Item'} + {slug: 'fear', name: 'Fear', original: '53024db827471514685d53b2', portraitURL: '/file/db/thang.type/53024db827471514685d53b2/portrait.png', kind: 'Mark'} + {slug: 'fence', name: 'Fence', original: '5421bc5218adb78d98d265e8', portraitURL: '/file/db/thang.type/5421bc5218adb78d98d265e8/portrait.png', kind: 'Doodad'} + {slug: 'fence-wall', name: 'Fence Wall', original: '54349179a4cc5c900efa4814', portraitURL: '/file/db/thang.type/54349179a4cc5c900efa4814/portrait.png', kind: 'Doodad'} + {slug: 'filing-cabinet', name: 'Filing Cabinet', original: '52e9fa73427172ae56002593', portraitURL: '/file/db/thang.type/52e9fa73427172ae56002593/portrait.png', kind: 'Doodad'} + {slug: 'fine-boots', name: 'Fine Boots', original: '53e2388e53457600003e3f09', portraitURL: '/file/db/thang.type/53e2388e53457600003e3f09/portrait.png', kind: 'Item'} + {slug: 'fine-leather-chainmail-coif', name: 'Fine Leather Chainmail Coif', original: '546d455f9df4a17d0d449a4f', portraitURL: '/file/db/thang.type/546d455f9df4a17d0d449a4f/portrait.png', kind: 'Item'} + {slug: 'fine-leather-chainmail-tunic', name: 'Fine Leather Chainmail Tunic', original: '546d3b129df4a17d0d449a27', portraitURL: '/file/db/thang.type/546d3b129df4a17d0d449a27/portrait.png', kind: 'Item'} + {slug: 'fine-stone-builders-hammer', name: 'Fine Stone Builder\'s Hammer', original: '54694c44a2b1f53ce794445a', portraitURL: '/file/db/thang.type/54694c44a2b1f53ce794445a/portrait.png', kind: 'Item'} + {slug: 'fine-telephoto-glasses', name: 'Fine Telephoto Glasses', original: '54694194a2b1f53ce7944415', portraitURL: '/file/db/thang.type/54694194a2b1f53ce7944415/portrait.png', kind: 'Item'} + {slug: 'fine-wooden-glasses', name: 'Fine Wooden Glasses', original: '5469405ba2b1f53ce7944404', portraitURL: '/file/db/thang.type/5469405ba2b1f53ce7944404/portrait.png', kind: 'Item'} + {slug: 'fir-tree-1', name: 'Fir Tree 1', original: '54e9503df54ef5794f354ec1', portraitURL: '/file/db/thang.type/54e9503df54ef5794f354ec1/portrait.png', kind: 'Doodad'} + {slug: 'fir-tree-2', name: 'Fir Tree 2', original: '54e95107f54ef5794f354ec5', portraitURL: '/file/db/thang.type/54e95107f54ef5794f354ec5/portrait.png', kind: 'Doodad'} + {slug: 'fir-tree-3', name: 'Fir Tree 3', original: '54e9513ff54ef5794f354ec9', portraitURL: '/file/db/thang.type/54e9513ff54ef5794f354ec9/portrait.png', kind: 'Doodad'} + {slug: 'fir-tree-4', name: 'Fir Tree 4', original: '54e9518df54ef5794f354ecd', portraitURL: '/file/db/thang.type/54e9518df54ef5794f354ecd/portrait.png', kind: 'Doodad'} + {slug: 'fire', name: 'Fire', original: '54d2bdea4e4a08550556dbfe', portraitURL: '/file/db/thang.type/54d2bdea4e4a08550556dbfe/portrait.png', kind: 'Mark'} + {slug: 'fire-dancing-background', name: 'Fire Dancing Background', original: '576ad6ab7e64f325002df2e4', portraitURL: '/file/db/thang.type/576ad6ab7e64f325002df2e4/portrait.png', kind: 'Floor'} + {slug: 'fire-opal-sense-stone', name: 'Fire Opal Sense Stone', original: '546932e4a2b1f53ce79443cd', portraitURL: '/file/db/thang.type/546932e4a2b1f53ce79443cd/portrait.png', kind: 'Item'} + {slug: 'fire-trap', name: 'Fire Trap', original: '5449536afb56d566e86972ba', portraitURL: '/file/db/thang.type/5449536afb56d566e86972ba/portrait.png', kind: 'Misc'} + {slug: 'fireball', name: 'Fireball', original: '531a6a2f1ddc910545d5e944', portraitURL: '/file/db/thang.type/531a6a2f1ddc910545d5e944/portrait.png', kind: 'Missile'} + {slug: 'firewall', name: 'firewall', original: '56f56687db0216900f086ac1', portraitURL: '/file/db/thang.type/56f56687db0216900f086ac1/portrait.png', kind: 'Doodad'} + {slug: 'firewood-1', name: 'Firewood 1', original: '52e953d0427172ae5600181d', portraitURL: '/file/db/thang.type/52e953d0427172ae5600181d/portrait.png', kind: 'Doodad'} + {slug: 'firewood-2', name: 'Firewood 2', original: '52e9575d22efc8e7090016ed', portraitURL: '/file/db/thang.type/52e9575d22efc8e7090016ed/portrait.png', kind: 'Doodad'} + {slug: 'firewood-3', name: 'Firewood 3', original: '52e957ec22efc8e7090016fd', portraitURL: '/file/db/thang.type/52e957ec22efc8e7090016fd/portrait.png', kind: 'Doodad'} + {slug: 'firn-1', name: 'Firn 1', original: '557639fa1e82182d9e68894d', portraitURL: '/file/db/thang.type/557639fa1e82182d9e68894d/portrait.png', kind: 'Floor'} + {slug: 'firn-2', name: 'Firn 2', original: '55763a971e82182d9e688951', portraitURL: '/file/db/thang.type/55763a971e82182d9e688951/portrait.png', kind: 'Floor'} + {slug: 'firn-3', name: 'Firn 3', original: '55763ab51e82182d9e688955', portraitURL: '/file/db/thang.type/55763ab51e82182d9e688955/portrait.png', kind: 'Floor'} + {slug: 'firn-4', name: 'Firn 4', original: '55763ad11e82182d9e688959', portraitURL: '/file/db/thang.type/55763ad11e82182d9e688959/portrait.png', kind: 'Floor'} + {slug: 'firn-5', name: 'Firn 5', original: '55763aea1e82182d9e68895d', portraitURL: '/file/db/thang.type/55763aea1e82182d9e68895d/portrait.png', kind: 'Floor'} + {slug: 'firn-6', name: 'Firn 6', original: '55763b111e82182d9e688961', portraitURL: '/file/db/thang.type/55763b111e82182d9e688961/portrait.png', kind: 'Floor'} + {slug: 'firn-cliff', name: 'Firn Cliff', original: '55c277983767fd3435eb444e', portraitURL: '/file/db/thang.type/55c277983767fd3435eb444e/portrait.png', kind: 'Floor'} + {slug: 'flame-armor', name: 'Flame Armor', original: '55c27b5c3767fd3435eb445a', portraitURL: '/file/db/thang.type/55c27b5c3767fd3435eb445a/portrait.png', kind: 'Mark'} + {slug: 'flaming-shell', name: 'Flaming Shell', original: '553e80669bdea5d00f1fd90e', portraitURL: '/file/db/thang.type/553e80669bdea5d00f1fd90e/portrait.png', kind: 'Missile'} + {slug: 'flippable-land', name: 'Flippable Land', original: '53a20126610a6b3505568163', portraitURL: '/file/db/thang.type/53a20126610a6b3505568163/portrait.png', kind: 'Floor'} + {slug: 'floppy-lambswool-hat', name: 'Floppy Lambswool Hat', original: '546d4b069df4a17d0d449aa3', portraitURL: '/file/db/thang.type/546d4b069df4a17d0d449aa3/portrait.png', kind: 'Item'} + {slug: 'force-bolt', name: 'Force Bolt', original: '5467807c417c8b48a9811efd', portraitURL: '/file/db/thang.type/5467807c417c8b48a9811efd/portrait.png', kind: 'Missile'} + {slug: 'forest-river-tile-deadend', name: 'Forest River tile deadend', original: '577d5b367e0491260074b95b', portraitURL: '/file/db/thang.type/577d5b367e0491260074b95b/portrait.png', kind: 'undefined'} + {slug: 'forest-river-tile-full-intersection', name: 'forest river tile full intersection', original: '5786badaa6c6413500926209', portraitURL: '/file/db/thang.type/5786badaa6c6413500926209/portrait.png', kind: 'undefined'} + {slug: 'forest-river-tile-straight', name: 'forest river tile straight', original: '577d58d5dbf35b24001b91cb', portraitURL: '/file/db/thang.type/577d58d5dbf35b24001b91cb/portrait.png', kind: 'undefined'} + {slug: 'forest-river-tile-t-intersection', name: 'Forest River tile t intersection', original: '577d5b927e0491260074ba3a', portraitURL: '/file/db/thang.type/577d5b927e0491260074ba3a/portrait.png', kind: 'undefined'} + {slug: 'forest-river-tile-turn', name: 'Forest River tile turn', original: '577d59e37e0491260074b5bd', portraitURL: '/file/db/thang.type/577d59e37e0491260074b5bd/portrait.png', kind: 'undefined'} + {slug: 'forgetful-gemsmith-background', name: 'Forgetful Gemsmith Background', original: '562a9b9ea4cdd48805fb98ca', portraitURL: '/file/db/thang.type/562a9b9ea4cdd48805fb98ca/portrait.png', kind: 'Floor'} + {slug: 'forgotten-bronze-ring', name: 'Forgotten Bronze Ring', original: '54692d8aa2b1f53ce7944397', portraitURL: '/file/db/thang.type/54692d8aa2b1f53ce7944397/portrait.png', kind: 'Item'} + {slug: 'frog', name: 'Frog', original: '57869cf7bd31c14400834028', portraitURL: '/file/db/thang.type/57869cf7bd31c14400834028/portrait.png', kind: 'Item'} + {slug: 'frog-pet', name: 'Frog Pet', original: '540f3678821af8000097dc56', portraitURL: '/file/db/thang.type/540f3678821af8000097dc56/portrait.png', kind: 'Unit'} + {slug: 'gargoyle', name: 'Gargoyle', original: '52afc8f0c5b1813ec2000008', portraitURL: '/file/db/thang.type/52afc8f0c5b1813ec2000008/portrait.png', kind: 'Doodad'} + {slug: 'gargoyle-side', name: 'Gargoyle Side', original: '54efa07f4bb4788505d2339e', portraitURL: '/file/db/thang.type/54efa07f4bb4788505d2339e/portrait.png', kind: 'Doodad'} + {slug: 'gauntlets-of-strength', name: 'Gauntlets of Strength', original: '53e2202953457600003e3ed9', portraitURL: '/file/db/thang.type/53e2202953457600003e3ed9/portrait.png', kind: 'Item'} + {slug: 'gem-pile-medium', name: 'Gem Pile Medium', original: '543306638364d30000d1f951', portraitURL: '/file/db/thang.type/543306638364d30000d1f951/portrait.png', kind: 'Misc'} + {slug: 'gem-pile-small', name: 'Gem Pile Small', original: '543305f78364d30000d1f94a', portraitURL: '/file/db/thang.type/543305f78364d30000d1f94a/portrait.png', kind: 'Misc'} + {slug: 'gems-of-the-deep-background', name: 'Gems of the Deep Background', original: '563aa55276289f86054a7c02', portraitURL: '/file/db/thang.type/563aa55276289f86054a7c02/portrait.png', kind: 'Floor'} + {slug: 'generic-armor-mark-1', name: 'Generic Armor Mark 1', original: '54d2be25bb157252059b2202', portraitURL: '/file/db/thang.type/54d2be25bb157252059b2202/portrait.png', kind: 'Mark'} + {slug: 'generic-armor-mark-2', name: 'Generic Armor Mark 2', original: '54d2be9e3e16915505f0c7a4', portraitURL: '/file/db/thang.type/54d2be9e3e16915505f0c7a4/portrait.png', kind: 'Mark'} + {slug: 'generic-item', name: 'Generic Item', original: '545d3eb52d03e700001b5a5b', portraitURL: '/file/db/thang.type/545d3eb52d03e700001b5a5b/portrait.png', kind: 'Item'} + {slug: 'gift-of-the-trees', name: 'Gift of the Trees', original: '54eab0a32b7506e891ca71dd', portraitURL: '/file/db/thang.type/54eab0a32b7506e891ca71dd/portrait.png', kind: 'Item'} + {slug: 'gilt-wristwatch', name: 'Gilt Wristwatch', original: '54693830a2b1f53ce79443f1', portraitURL: '/file/db/thang.type/54693830a2b1f53ce79443f1/portrait.png', kind: 'Item'} + {slug: 'glasses-doodad', name: 'Glasses Doodad', original: '5420c4c5a0feb36ad21d45e2', portraitURL: '/file/db/thang.type/5420c4c5a0feb36ad21d45e2/portrait.png', kind: 'Item'} + {slug: 'glitterbomb', name: 'Glitterbomb', original: '54eb50f649fa2d5c905ddf0a', portraitURL: '/file/db/thang.type/54eb50f649fa2d5c905ddf0a/portrait.png', kind: 'Item'} + {slug: 'goal-trigger', name: 'Goal Trigger', original: '52bcbf0dce43b70000000006', portraitURL: '/file/db/thang.type/52bcbf0dce43b70000000006/portrait.png', kind: 'Misc'} + {slug: 'gold-ball', name: 'Gold Ball', original: '550b742b8a7d3c197a824dad', portraitURL: '/file/db/thang.type/550b742b8a7d3c197a824dad/portrait.png', kind: 'Missile'} + {slug: 'gold-cloud', name: 'Gold Cloud', original: '550b4b9d8a7d3c197a824d5e', portraitURL: '/file/db/thang.type/550b4b9d8a7d3c197a824d5e/portrait.png', kind: 'Doodad'} + {slug: 'golden-wand', name: 'Golden Wand', original: '54eab7f52b7506e891ca7202', portraitURL: '/file/db/thang.type/54eab7f52b7506e891ca7202/portrait.png', kind: 'Item'} + {slug: 'goldspun-silk-cloak', name: 'Goldspun Silk Cloak', original: '546d49da9df4a17d0d449a8f', portraitURL: '/file/db/thang.type/546d49da9df4a17d0d449a8f/portrait.png', kind: 'Item'} + {slug: 'goldspun-silk-hat', name: 'Goldspun Silk Hat', original: '546d4c249df4a17d0d449ab7', portraitURL: '/file/db/thang.type/546d4c249df4a17d0d449ab7/portrait.png', kind: 'Item'} + {slug: 'grass-cliffs', name: 'Grass Cliffs', original: '52bcb96ece43b70000000003', portraitURL: '/file/db/thang.type/52bcb96ece43b70000000003/portrait.png', kind: 'Floor'} + {slug: 'grass01', name: 'Grass01', original: '53016dddd82649ec2c0c9b29', portraitURL: '/file/db/thang.type/53016dddd82649ec2c0c9b29/portrait.png', kind: 'Floor'} + {slug: 'grass02', name: 'Grass02', original: '53016fc098f2ca1f6e82eebd', portraitURL: '/file/db/thang.type/53016fc098f2ca1f6e82eebd/portrait.png', kind: 'Floor'} + {slug: 'grass03', name: 'Grass03', original: '5301702d98f2ca1f6e82eec4', portraitURL: '/file/db/thang.type/5301702d98f2ca1f6e82eec4/portrait.png', kind: 'Floor'} + {slug: 'grass04', name: 'Grass04', original: '530170a198f2ca1f6e82eecf', portraitURL: '/file/db/thang.type/530170a198f2ca1f6e82eecf/portrait.png', kind: 'Floor'} + {slug: 'grass05', name: 'Grass05', original: '5301716398f2ca1f6e82eedc', portraitURL: '/file/db/thang.type/5301716398f2ca1f6e82eedc/portrait.png', kind: 'Floor'} + {slug: 'gravestone-cross', name: 'Gravestone Cross', original: '54f1100e8d380d7f05acc975', portraitURL: '/file/db/thang.type/54f1100e8d380d7f05acc975/portrait.png', kind: 'Doodad'} + {slug: 'gravestone-rounded', name: 'Gravestone Rounded', original: '54f110e3f854c97a05551616', portraitURL: '/file/db/thang.type/54f110e3f854c97a05551616/portrait.png', kind: 'Doodad'} + {slug: 'gravestone-square', name: 'Gravestone Square', original: '54f10f08d2969f8405ef51fd', portraitURL: '/file/db/thang.type/54f10f08d2969f8405ef51fd/portrait.png', kind: 'Doodad'} + {slug: 'graveyard-fence', name: 'Graveyard Fence', original: '54f111b379054c8705757747', portraitURL: '/file/db/thang.type/54f111b379054c8705757747/portrait.png', kind: 'Doodad'} + {slug: 'great-sword', name: 'Great Sword', original: '544d7f8d8494308424f564bf', portraitURL: '/file/db/thang.type/544d7f8d8494308424f564bf/portrait.png', kind: 'Item'} + {slug: 'greed-background', name: 'Greed Background', original: '53764e4ea7b5ab3805f153a4', portraitURL: '/file/db/thang.type/53764e4ea7b5ab3805f153a4/portrait.png', kind: 'Floor'} + {slug: 'green-bubble-missile', name: 'Green Bubble Missile', original: '540e35a34f21cd879ba4f140', portraitURL: '/file/db/thang.type/540e35a34f21cd879ba4f140/portrait.png', kind: 'Missile'} + {slug: 'griffin-rider', name: 'Griffin Rider', original: '52d45d1ab10ae4b024000002', portraitURL: '/file/db/thang.type/52d45d1ab10ae4b024000002/portrait.png', kind: 'Unit'} + {slug: 'griffin-wool-hat', name: 'Griffin Wool Hat', original: '546d4c699df4a17d0d449abb', portraitURL: '/file/db/thang.type/546d4c699df4a17d0d449abb/portrait.png', kind: 'Item'} + {slug: 'griffin-wool-robe', name: 'Griffin Wool Robe', original: '546d4a159df4a17d0d449a93', portraitURL: '/file/db/thang.type/546d4a159df4a17d0d449a93/portrait.png', kind: 'Item'} + {slug: 'hand-sewn-linen-wizards-hat', name: 'Hand-sewn Linen Wizard\'s Hat', original: '546d4bec9df4a17d0d449ab3', portraitURL: '/file/db/thang.type/546d4bec9df4a17d0d449ab3/portrait.png', kind: 'Item'} + {slug: 'hardened-emerald-chainmail-coif', name: 'Hardened Emerald Chainmail Coif', original: '546d47159df4a17d0d449a67', portraitURL: '/file/db/thang.type/546d47159df4a17d0d449a67/portrait.png', kind: 'Item'} + {slug: 'hardened-emerald-chainmail-tunic', name: 'Hardened Emerald Chainmail Tunic', original: '546d3cce9df4a17d0d449a3f', portraitURL: '/file/db/thang.type/546d3cce9df4a17d0d449a3f/portrait.png', kind: 'Item'} + {slug: 'hardened-steel-glasses', name: 'Hardened Steel Glasses', original: '546940d8a2b1f53ce794440d', portraitURL: '/file/db/thang.type/546940d8a2b1f53ce794440d/portrait.png', kind: 'Item'} + {slug: 'harrowland-background', name: 'Harrowland Background', original: '572e51e3f8c4f9b601ede885', portraitURL: '/file/db/thang.type/572e51e3f8c4f9b601ede885/portrait.png', kind: 'Floor'} + {slug: 'haste', name: 'Haste', original: '530251cfa6efdd32359c53d5', portraitURL: '/file/db/thang.type/530251cfa6efdd32359c53d5/portrait.png', kind: 'Mark'} + {slug: 'haunted-kithmaze-background', name: 'Haunted Kithmaze Background', original: '569dd4f2b55fd82e0011b79b', portraitURL: '/file/db/thang.type/569dd4f2b55fd82e0011b79b/portrait.png', kind: 'Floor'} + {slug: 'heal', name: 'Heal', original: '55c63ebcef141c65665beb59', portraitURL: '/file/db/thang.type/55c63ebcef141c65665beb59/portrait.png', kind: 'Mark'} + {slug: 'health-potion-large', name: 'Health Potion Large', original: '52afc634c5b1813ec2000002', portraitURL: '/file/db/thang.type/52afc634c5b1813ec2000002/portrait.png', kind: 'Misc'} + {slug: 'health-potion-medium', name: 'Health Potion Medium', original: '52afc742c5b1813ec2000004', portraitURL: '/file/db/thang.type/52afc742c5b1813ec2000004/portrait.png', kind: 'Misc'} + {slug: 'health-potion-small', name: 'Health Potion Small', original: '52afc7b6c5b1813ec2000006', portraitURL: '/file/db/thang.type/52afc7b6c5b1813ec2000006/portrait.png', kind: 'Misc'} + {slug: 'heavy-iron-breastplate', name: 'Heavy Iron Breastplate', original: '546aaf1b3777d6186329285e', portraitURL: '/file/db/thang.type/546aaf1b3777d6186329285e/portrait.png', kind: 'Item'} + {slug: 'heavy-iron-helmet', name: 'Heavy Iron Helmet', original: '546d390b9df4a17d0d449a0b', portraitURL: '/file/db/thang.type/546d390b9df4a17d0d449a0b/portrait.png', kind: 'Item'} + {slug: 'helmet-fall-1', name: 'Helmet Fall 1', original: '53e2e3e66c59f5340504108f', portraitURL: '/file/db/thang.type/53e2e3e66c59f5340504108f/portrait.png', kind: 'Doodad'} + {slug: 'hide', name: 'Hide', original: '55c281e83767fd3435eb446d', portraitURL: '/file/db/thang.type/55c281e83767fd3435eb446d/portrait.png', kind: 'Mark'} + {slug: 'highlight', name: 'Highlight', original: '529f8fdbdacd325127000003', portraitURL: '/file/db/thang.type/529f8fdbdacd325127000003/portrait.png', kind: 'Mark'} + {slug: 'holoball', name: 'Holoball', original: '56d0fa8a087ee32400764bb8', portraitURL: '/file/db/thang.type/56d0fa8a087ee32400764bb8/portrait.png', kind: 'Doodad'} + {slug: 'holy-sword', name: 'Holy Sword', original: '53e21249b82921000051ce11', portraitURL: '/file/db/thang.type/53e21249b82921000051ce11/portrait.png', kind: 'Item'} + {slug: 'house-1', name: 'House 1', original: '52b095bbccbc671372000006', portraitURL: '/file/db/thang.type/52b095bbccbc671372000006/portrait.png', kind: 'Doodad'} + {slug: 'house-2', name: 'House 2', original: '52b09d35ccbc671372000009', portraitURL: '/file/db/thang.type/52b09d35ccbc671372000009/portrait.png', kind: 'Doodad'} + {slug: 'house-3', name: 'House 3', original: '52b09dd0ccbc67137200000b', portraitURL: '/file/db/thang.type/52b09dd0ccbc67137200000b/portrait.png', kind: 'Doodad'} + {slug: 'house-4', name: 'House 4', original: '52b09e2fccbc67137200000d', portraitURL: '/file/db/thang.type/52b09e2fccbc67137200000d/portrait.png', kind: 'Doodad'} + {slug: 'hoverboard-stand', name: 'Hoverboard Stand', original: '56c630aeed946a44004ff139', portraitURL: '/file/db/thang.type/56c630aeed946a44004ff139/portrait.png', kind: 'Doodad'} + {slug: 'human-barracks', name: 'Human Barracks', original: '530ce329ec5bdaba2a72a99c', portraitURL: '/file/db/thang.type/530ce329ec5bdaba2a72a99c/portrait.png', kind: 'Unit'} + {slug: 'hunting-rifle', name: 'Hunting Rifle', original: '544d82aa8494308424f564cf', portraitURL: '/file/db/thang.type/544d82aa8494308424f564cf/portrait.png', kind: 'Item'} + {slug: 'ice-crystals-1', name: 'Ice Crystals 1', original: '557639501e82182d9e688945', portraitURL: '/file/db/thang.type/557639501e82182d9e688945/portrait.png', kind: 'Doodad'} + {slug: 'ice-crystals-2', name: 'Ice Crystals 2', original: '557639b91e82182d9e688949', portraitURL: '/file/db/thang.type/557639b91e82182d9e688949/portrait.png', kind: 'Doodad'} + {slug: 'ice-door', name: 'Ice Door', original: '557f32b0b43ce0b15a91b16d', portraitURL: '/file/db/thang.type/557f32b0b43ce0b15a91b16d/portrait.png', kind: 'Doodad'} + {slug: 'ice-gargoyle', name: 'Ice Gargoyle', original: '55760d3f1e82182d9e6888f6', portraitURL: '/file/db/thang.type/55760d3f1e82182d9e6888f6/portrait.png', kind: 'Doodad'} + {slug: 'ice-gargoyle-fore', name: 'Ice Gargoyle Fore', original: '55760e311e82182d9e688902', portraitURL: '/file/db/thang.type/55760e311e82182d9e688902/portrait.png', kind: 'Doodad'} + {slug: 'ice-gargoyle-ruin', name: 'Ice Gargoyle Ruin', original: '55760dc31e82182d9e6888fa', portraitURL: '/file/db/thang.type/55760dc31e82182d9e6888fa/portrait.png', kind: 'Doodad'} + {slug: 'ice-gargoyle-ruin-fore', name: 'Ice Gargoyle Ruin Fore', original: '55760dfa1e82182d9e6888fe', portraitURL: '/file/db/thang.type/55760dfa1e82182d9e6888fe/portrait.png', kind: 'Doodad'} + {slug: 'ice-rink-1', name: 'Ice Rink 1', original: '557f321bb43ce0b15a91b161', portraitURL: '/file/db/thang.type/557f321bb43ce0b15a91b161/portrait.png', kind: 'Floor'} + {slug: 'ice-rink-2', name: 'Ice Rink 2', original: '557f325cb43ce0b15a91b165', portraitURL: '/file/db/thang.type/557f325cb43ce0b15a91b165/portrait.png', kind: 'Floor'} + {slug: 'ice-rink-3', name: 'Ice Rink 3', original: '557f3275b43ce0b15a91b169', portraitURL: '/file/db/thang.type/557f3275b43ce0b15a91b169/portrait.png', kind: 'Floor'} + {slug: 'ice-tree-1', name: 'Ice Tree 1', original: '557635641e82182d9e688929', portraitURL: '/file/db/thang.type/557635641e82182d9e688929/portrait.png', kind: 'Doodad'} + {slug: 'ice-tree-2', name: 'Ice Tree 2', original: '557636401e82182d9e68892d', portraitURL: '/file/db/thang.type/557636401e82182d9e68892d/portrait.png', kind: 'Doodad'} + {slug: 'ice-tree-3', name: 'Ice Tree 3', original: '557636e11e82182d9e688931', portraitURL: '/file/db/thang.type/557636e11e82182d9e688931/portrait.png', kind: 'Doodad'} + {slug: 'ice-wall', name: 'Ice Wall', original: '5575f002f3f8d13b4ee1e7fc', portraitURL: '/file/db/thang.type/5575f002f3f8d13b4ee1e7fc/portrait.png', kind: 'Wall'} + {slug: 'ice-yak', name: 'Ice Yak', original: '557f3917b43ce0b15a91b175', portraitURL: '/file/db/thang.type/557f3917b43ce0b15a91b175/portrait.png', kind: 'Unit'} + {slug: 'igloo-1', name: 'Igloo 1', original: '557608f61e82182d9e6888cf', portraitURL: '/file/db/thang.type/557608f61e82182d9e6888cf/portrait.png', kind: 'Doodad'} + {slug: 'igloo-2', name: 'Igloo 2', original: '557609b01e82182d9e6888d3', portraitURL: '/file/db/thang.type/557609b01e82182d9e6888d3/portrait.png', kind: 'Doodad'} + {slug: 'igloo-3', name: 'Igloo 3', original: '557609dd1e82182d9e6888d7', portraitURL: '/file/db/thang.type/557609dd1e82182d9e6888d7/portrait.png', kind: 'Doodad'} + {slug: 'igloo-4', name: 'Igloo 4', original: '55760a2c1e82182d9e6888db', portraitURL: '/file/db/thang.type/55760a2c1e82182d9e6888db/portrait.png', kind: 'Doodad'} + {slug: 'impaling-firebolt', name: 'Impaling Firebolt', original: '54f767c4b3e4927805021022', portraitURL: '/file/db/thang.type/54f767c4b3e4927805021022/portrait.png', kind: 'Missile'} + {slug: 'importer-of-great-justice', name: 'Importer of Great Justice', original: '54938575e9850ae3e8fbdd74', portraitURL: '/file/db/thang.type/54938575e9850ae3e8fbdd74/portrait.png', kind: 'undefined'} + {slug: 'indoor-floor', name: 'Indoor Floor', original: '52ead2b2207133f35c000833', portraitURL: '/file/db/thang.type/52ead2b2207133f35c000833/portrait.png', kind: 'Floor'} + {slug: 'indoor-wall', name: 'Indoor Wall', original: '52ea9a13d23f140d100000b2', portraitURL: '/file/db/thang.type/52ea9a13d23f140d100000b2/portrait.png', kind: 'Wall'} + {slug: 'infantry-shield', name: 'Infantry Shield', original: '544d7bb88494308424f56493', portraitURL: '/file/db/thang.type/544d7bb88494308424f56493/portrait.png', kind: 'Item'} + {slug: 'invisible', name: 'Invisible', original: '52b0f9c75c5c4af6bd000004', portraitURL: '/file/db/thang.type/52b0f9c75c5c4af6bd000004/portrait.png', kind: 'Misc'} + {slug: 'iron-chainmail-coif', name: 'Iron Chainmail Coif', original: '546d45c59df4a17d0d449a53', portraitURL: '/file/db/thang.type/546d45c59df4a17d0d449a53/portrait.png', kind: 'Item'} + {slug: 'iron-chainmail-tunic', name: 'Iron Chainmail Tunic', original: '546d3b7c9df4a17d0d449a2b', portraitURL: '/file/db/thang.type/546d3b7c9df4a17d0d449a2b/portrait.png', kind: 'Item'} + {slug: 'iron-defender', name: 'Iron Defender', original: '54eaabe62b7506e891ca71c9', portraitURL: '/file/db/thang.type/54eaabe62b7506e891ca71c9/portrait.png', kind: 'Item'} + {slug: 'iron-link', name: 'Iron Link', original: '54692d5ca2b1f53ce7944393', portraitURL: '/file/db/thang.type/54692d5ca2b1f53ce7944393/portrait.png', kind: 'Item'} + {slug: 'iron-maiden', name: 'Iron Maiden', original: '54ef9f0a83b08b7d054ba50d', portraitURL: '/file/db/thang.type/54ef9f0a83b08b7d054ba50d/portrait.png', kind: 'Doodad'} + {slug: 'iron-shield', name: 'Iron Shield', original: '5441c3f44e9aeb727cc97129', portraitURL: '/file/db/thang.type/5441c3f44e9aeb727cc97129/portrait.png', kind: 'Item'} + {slug: 'kings-ring', name: 'King\'s Ring', original: '54eb56df49fa2d5c905ddf2e', portraitURL: '/file/db/thang.type/54eb56df49fa2d5c905ddf2e/portrait.png', kind: 'Item'} + {slug: 'kithgard-gates-background', name: 'Kithgard Gates Background', original: '572e52b17a9c3e8101b8be0e', portraitURL: '/file/db/thang.type/572e52b17a9c3e8101b8be0e/portrait.png', kind: 'Floor'} + {slug: 'kithgard-workers-glasses', name: 'Kithgard Worker\'s Glasses', original: '53eb99f41a100989a40ce46e', portraitURL: '/file/db/thang.type/53eb99f41a100989a40ce46e/portrait.png', kind: 'Item'} + {slug: 'kithsteel-blade', name: 'Kithsteel Blade', original: '54eaa78a2b7506e891ca719d', portraitURL: '/file/db/thang.type/54eaa78a2b7506e891ca719d/portrait.png', kind: 'Item'} + {slug: 'knightfire-charge', name: 'Knightfire Charge', original: '544d96328494308424f56533', portraitURL: '/file/db/thang.type/544d96328494308424f56533/portrait.png', kind: 'Item'} + {slug: 'knightfire-charge-missile', name: 'Knightfire Charge Missile', original: '546297f1f44055a4b5e735bb', portraitURL: '/file/db/thang.type/546297f1f44055a4b5e735bb/portrait.png', kind: 'Missile'} + {slug: 'known-enemy-background', name: 'Known Enemy Background', original: '572e4e61b2088976012429eb', portraitURL: '/file/db/thang.type/572e4e61b2088976012429eb/portrait.png', kind: 'Floor'} + {slug: 'koraths-promise', name: 'Korath\'s Promise', original: '54eb575749fa2d5c905ddf3a', portraitURL: '/file/db/thang.type/54eb575749fa2d5c905ddf3a/portrait.png', kind: 'Item'} + {slug: 'krummholz-1', name: 'Krummholz 1', original: '54e953adf54ef5794f354ef1', portraitURL: '/file/db/thang.type/54e953adf54ef5794f354ef1/portrait.png', kind: 'Doodad'} + {slug: 'krummholz-2', name: 'Krummholz 2', original: '54e9545bf54ef5794f354ef5', portraitURL: '/file/db/thang.type/54e9545bf54ef5794f354ef5/portrait.png', kind: 'Doodad'} + {slug: 'krummholz-3', name: 'Krummholz 3', original: '54e95492f54ef5794f354ef9', portraitURL: '/file/db/thang.type/54e95492f54ef5794f354ef9/portrait.png', kind: 'Doodad'} + {slug: 'lambswool-cloak', name: 'Lambswool Cloak', original: '546d48ce9df4a17d0d449a7b', portraitURL: '/file/db/thang.type/546d48ce9df4a17d0d449a7b/portrait.png', kind: 'Item'} + {slug: 'large-bolt-crossbow', name: 'Large Bolt Crossbow', original: '544d80598494308424f564c7', portraitURL: '/file/db/thang.type/544d80598494308424f564c7/portrait.png', kind: 'Item'} + {slug: 'large-classroom-viewscreen-off', name: 'Large Classroom Viewscreen Off', original: '56c632c6abf4a61f009040b5', portraitURL: '/file/db/thang.type/56c632c6abf4a61f009040b5/portrait.png', kind: 'Doodad'} + {slug: 'large-classroom-viewscreen-on', name: 'Large Classroom Viewscreen On', original: '56eb28b267a0142000a36358', portraitURL: '/file/db/thang.type/56eb28b267a0142000a36358/portrait.png', kind: 'Doodad'} + {slug: 'lava-grate', name: 'Lava Grate', original: '54ef9fb8c1f3bd7c05941750', portraitURL: '/file/db/thang.type/54ef9fb8c1f3bd7c05941750/portrait.png', kind: 'Doodad'} + {slug: 'leather-belt', name: 'Leather Belt', original: '5437002a7beba4a82024a97d', portraitURL: '/file/db/thang.type/5437002a7beba4a82024a97d/portrait.png', kind: 'Item'} + {slug: 'leather-boots', name: 'Leather Boots', original: '53e2384453457600003e3f07', portraitURL: '/file/db/thang.type/53e2384453457600003e3f07/portrait.png', kind: 'Item'} + {slug: 'leather-chainmail-coif', name: 'Leather Chainmail Coif', original: '546d45089df4a17d0d449a4b', portraitURL: '/file/db/thang.type/546d45089df4a17d0d449a4b/portrait.png', kind: 'Item'} + {slug: 'leather-chainmail-tunic', name: 'Leather Chainmail Tunic', original: '546d3ab69df4a17d0d449a23', portraitURL: '/file/db/thang.type/546d3ab69df4a17d0d449a23/portrait.png', kind: 'Item'} + {slug: 'leather-tunic', name: 'Leather Tunic', original: '545d3cf22d03e700001b5a58', portraitURL: '/file/db/thang.type/545d3cf22d03e700001b5a58/portrait.png', kind: 'Item'} + {slug: 'level-banner', name: 'Level Banner', original: '5432c9688364d30000d1f935', portraitURL: '/file/db/thang.type/5432c9688364d30000d1f935/portrait.png', kind: 'Misc'} + {slug: 'lightning-bolt', name: 'Lightning Bolt', original: '54f3fa515fcc6a3950c7eabd', portraitURL: '/file/db/thang.type/54f3fa515fcc6a3950c7eabd/portrait.png', kind: 'Missile'} + {slug: 'lightning-stick', name: 'Lightning Stick', original: '544d86318494308424f564e8', portraitURL: '/file/db/thang.type/544d86318494308424f564e8/portrait.png', kind: 'Item'} + {slug: 'lightning-twig', name: 'Lightning Twig', original: '54eab1ec2b7506e891ca71e1', portraitURL: '/file/db/thang.type/54eab1ec2b7506e891ca71e1/portrait.png', kind: 'Item'} + {slug: 'lightstone', name: 'Lightstone', original: '54da20b7163110520551ed33', portraitURL: '/file/db/thang.type/54da20b7163110520551ed33/portrait.png', kind: 'Misc'} + {slug: 'log-1', name: 'Log 1', original: '54e954d7f54ef5794f354efd', portraitURL: '/file/db/thang.type/54e954d7f54ef5794f354efd/portrait.png', kind: 'Doodad'} + {slug: 'log-2', name: 'Log 2', original: '54e9553ef54ef5794f354f01', portraitURL: '/file/db/thang.type/54e9553ef54ef5794f354f01/portrait.png', kind: 'Doodad'} + {slug: 'log-3', name: 'Log 3', original: '54e9556af54ef5794f354f05', portraitURL: '/file/db/thang.type/54e9556af54ef5794f354f05/portrait.png', kind: 'Doodad'} + {slug: 'long-sword', name: 'Long Sword', original: '544d7d1f8494308424f564a3', portraitURL: '/file/db/thang.type/544d7d1f8494308424f564a3/portrait.png', kind: 'Item'} + {slug: 'loop-da-loop-background', name: 'Loop Da Loop Background', original: '56c67cba797353370060506d', portraitURL: '/file/db/thang.type/56c67cba797353370060506d/portrait.png', kind: 'Floor'} + {slug: 'magic-missile', name: 'Magic Missile', original: '5467beaf69d1ba0000fb91fb', portraitURL: '/file/db/thang.type/5467beaf69d1ba0000fb91fb/portrait.png', kind: 'Missile'} + {slug: 'magnetize', name: 'Magnetize', original: '55c6403eef141c65665beb5e', portraitURL: '/file/db/thang.type/55c6403eef141c65665beb5e/portrait.png', kind: 'Mark'} + {slug: 'mahogany-glasses', name: 'Mahogany Glasses', original: '54694093a2b1f53ce7944408', portraitURL: '/file/db/thang.type/54694093a2b1f53ce7944408/portrait.png', kind: 'Item'} + {slug: 'mahogany-staff', name: 'Mahogany Staff', original: '544d88158494308424f56501', portraitURL: '/file/db/thang.type/544d88158494308424f56501/portrait.png', kind: 'Item'} + {slug: 'maka-test-wall', name: 'maka-test-wall', original: '56a7d85fb679392600e31138', portraitURL: '/file/db/thang.type/56a7d85fb679392600e31138/portrait.png', kind: 'Wall'} + {slug: 'market-stand', name: 'Market Stand', original: '54f11600f854c97a055516da', portraitURL: '/file/db/thang.type/54f11600f854c97a055516da/portrait.png', kind: 'Doodad'} + {slug: 'master-of-names-background', name: 'Master of Names Background', original: '572e4ec2e8db519501484869', portraitURL: '/file/db/thang.type/572e4ec2e8db519501484869/portrait.png', kind: 'Floor'} + {slug: 'master-sword', name: 'Master Sword', original: '54ea89112b7506e891ca717d', portraitURL: '/file/db/thang.type/54ea89112b7506e891ca717d/portrait.png', kind: 'Item'} + {slug: 'masters-flags', name: 'Master\'s Flags', original: '5478b9be8707a2c3a2493b33', portraitURL: '/file/db/thang.type/5478b9be8707a2c3a2493b33/portrait.png', kind: 'Item'} + {slug: 'mausoleum', name: 'Mausoleum', original: '54f1128a25be5e8805837491', portraitURL: '/file/db/thang.type/54f1128a25be5e8805837491/portrait.png', kind: 'Doodad'} + {slug: 'mayhem-background', name: 'Mayhem Background', original: '572e5071e8db519501484896', portraitURL: '/file/db/thang.type/572e5071e8db519501484896/portrait.png', kind: 'Floor'} + {slug: 'mcp', name: 'mcp', original: '576322da0d81132500afdc8d', portraitURL: '/file/db/thang.type/576322da0d81132500afdc8d/portrait.png', kind: 'Unit'} + {slug: 'megaphone', name: 'Megaphone', original: '53e216ff53457600003e3eb7', portraitURL: '/file/db/thang.type/53e216ff53457600003e3eb7/portrait.png', kind: 'Item'} + {slug: 'metal-builders-hammer', name: 'Metal Builder\'s Hammer', original: '54694c79a2b1f53ce794445e', portraitURL: '/file/db/thang.type/54694c79a2b1f53ce794445e/portrait.png', kind: 'Item'} + {slug: 'moonless-night', name: 'Moonless Night', original: '54692f44a2b1f53ce79443b8', portraitURL: '/file/db/thang.type/54692f44a2b1f53ce79443b8/portrait.png', kind: 'Item'} + {slug: 'moonlit-blade', name: 'Moonlit Blade', original: '544d95a48494308424f56523', portraitURL: '/file/db/thang.type/544d95a48494308424f56523/portrait.png', kind: 'Item'} + {slug: 'moonlit-blade-missile', name: 'Moonlit Blade Missile', original: '544d97bc8494308424f5653c', portraitURL: '/file/db/thang.type/544d97bc8494308424f5653c/portrait.png', kind: 'Missile'} + {slug: 'mornings-edge', name: 'Morning\'s Edge', original: '54eaa69a2b7506e891ca7195', portraitURL: '/file/db/thang.type/54eaa69a2b7506e891ca7195/portrait.png', kind: 'Item'} + {slug: 'mountain-1', name: 'Mountain 1', original: '54e931d7970f0b0a263c03ef', portraitURL: '/file/db/thang.type/54e931d7970f0b0a263c03ef/portrait.png', kind: 'Doodad'} + {slug: 'mountain-2', name: 'Mountain 2', original: '54e9340b970f0b0a263c03f3', portraitURL: '/file/db/thang.type/54e9340b970f0b0a263c03f3/portrait.png', kind: 'Doodad'} + {slug: 'mountain-3', name: 'Mountain 3', original: '54e935d1970f0b0a263c03f7', portraitURL: '/file/db/thang.type/54e935d1970f0b0a263c03f7/portrait.png', kind: 'Doodad'} + {slug: 'mountain-4', name: 'Mountain 4', original: '54e9377e970f0b0a263c03fc', portraitURL: '/file/db/thang.type/54e9377e970f0b0a263c03fc/portrait.png', kind: 'Doodad'} + {slug: 'mountain-lake-1', name: 'Mountain Lake 1', original: '54e93f0ef54ef5794f354e99', portraitURL: '/file/db/thang.type/54e93f0ef54ef5794f354e99/portrait.png', kind: 'Doodad'} + {slug: 'mountain-lake-2', name: 'Mountain Lake 2', original: '54e94106f54ef5794f354ea3', portraitURL: '/file/db/thang.type/54e94106f54ef5794f354ea3/portrait.png', kind: 'Doodad'} + {slug: 'mountain-shrub-1', name: 'Mountain Shrub 1', original: '54e9567ff54ef5794f354f11', portraitURL: '/file/db/thang.type/54e9567ff54ef5794f354f11/portrait.png', kind: 'Doodad'} + {slug: 'mountain-shrub-2', name: 'Mountain Shrub 2', original: '54e956b7f54ef5794f354f15', portraitURL: '/file/db/thang.type/54e956b7f54ef5794f354f15/portrait.png', kind: 'Doodad'} + {slug: 'mountain-shrub-3', name: 'Mountain Shrub 3', original: '54e956def54ef5794f354f19', portraitURL: '/file/db/thang.type/54e956def54ef5794f354f19/portrait.png', kind: 'Doodad'} + {slug: 'mountain-shrub-4', name: 'Mountain Shrub 4', original: '54e95724f54ef5794f354f1d', portraitURL: '/file/db/thang.type/54e95724f54ef5794f354f1d/portrait.png', kind: 'Doodad'} + {slug: 'mountain-tree-stand-1', name: 'Mountain Tree Stand 1', original: '55c24e91dfc8d0b576e60a5e', portraitURL: '/file/db/thang.type/55c24e91dfc8d0b576e60a5e/portrait.png', kind: 'Doodad'} + {slug: 'mountain-tree-stand-2', name: 'Mountain Tree Stand 2', original: '55c25141dfc8d0b576e60a64', portraitURL: '/file/db/thang.type/55c25141dfc8d0b576e60a64/portrait.png', kind: 'Doodad'} + {slug: 'mountain-tree-stand-3', name: 'Mountain Tree Stand 3', original: '55c25173dfc8d0b576e60a6a', portraitURL: '/file/db/thang.type/55c25173dfc8d0b576e60a6a/portrait.png', kind: 'Doodad'} + {slug: 'mountain-tree-stand-4', name: 'Mountain Tree Stand 4', original: '55c25190dfc8d0b576e60a70', portraitURL: '/file/db/thang.type/55c25190dfc8d0b576e60a70/portrait.png', kind: 'Doodad'} + {slug: 'movement-stone', name: 'Movement Stone', original: '546e257a9df4a17d0d449bd9', portraitURL: '/file/db/thang.type/546e257a9df4a17d0d449bd9/portrait.png', kind: 'Doodad'} + {slug: 'movement-stone-loop', name: 'Movement Stone Loop', original: '546e24679df4a17d0d449bc1', portraitURL: '/file/db/thang.type/546e24679df4a17d0d449bc1/portrait.png', kind: 'Doodad'} + {slug: 'multiplayer-treasure-grove-background', name: 'Multiplayer Treasure Grove Background', original: '572e526e7a9c3e8101b8be02', portraitURL: '/file/db/thang.type/572e526e7a9c3e8101b8be02/portrait.png', kind: 'Floor'} + {slug: 'mummy', name: 'Mummy', original: '54ef799c8d75558205e98a8e', portraitURL: '/file/db/thang.type/54ef799c8d75558205e98a8e/portrait.png', kind: 'Doodad'} + {slug: 'mushroom', name: 'Mushroom', original: '52bcc23a8c4289607b00000a', portraitURL: '/file/db/thang.type/52bcc23a8c4289607b00000a/portrait.png', kind: 'Misc'} + {slug: 'mushroom-cluster-1', name: 'Mushroom Cluster 1', original: '5576376f1e82182d9e688935', portraitURL: '/file/db/thang.type/5576376f1e82182d9e688935/portrait.png', kind: 'Doodad'} + {slug: 'mushroom-cluster-2', name: 'Mushroom Cluster 2', original: '557638341e82182d9e688939', portraitURL: '/file/db/thang.type/557638341e82182d9e688939/portrait.png', kind: 'Doodad'} + {slug: 'mushroom-cluster-3', name: 'Mushroom Cluster 3', original: '557638731e82182d9e68893d', portraitURL: '/file/db/thang.type/557638731e82182d9e68893d/portrait.png', kind: 'Doodad'} + {slug: 'mushroom-cluster-4', name: 'Mushroom Cluster 4', original: '5576390e1e82182d9e688941', portraitURL: '/file/db/thang.type/5576390e1e82182d9e688941/portrait.png', kind: 'Doodad'} + {slug: 'musty-linen-robe', name: 'Musty Linen Robe', original: '546d49409df4a17d0d449a83', portraitURL: '/file/db/thang.type/546d49409df4a17d0d449a83/portrait.png', kind: 'Item'} + {slug: 'newmakatesthushbaum', name: 'newmakatesthushbaum', original: '56ce223647c33f2400d98c66', portraitURL: '/file/db/thang.type/56ce223647c33f2400d98c66/portrait.png', kind: 'undefined'} + {slug: 'nightingales-song', name: 'Nightingale\'s Song', original: '54eb570b49fa2d5c905ddf32', portraitURL: '/file/db/thang.type/54eb570b49fa2d5c905ddf32/portrait.png', kind: 'Item'} + {slug: 'north-mounted-camera-facing-east-west', name: 'north mounted camera facing east-west', original: '56f175f2f3ae4cc900a0c5fc', portraitURL: '/file/db/thang.type/56f175f2f3ae4cc900a0c5fc/portrait.png', kind: 'Doodad'} + {slug: 'north-mounted-camera-facing-north', name: 'north mounted camera facing north', original: '56f173384852efd20059948a', portraitURL: '/file/db/thang.type/56f173384852efd20059948a/portrait.png', kind: 'Doodad'} + {slug: 'north-mounted-camera-facing-south', name: 'north mounted camera facing south', original: '56f17562f3ae4cc900a0c57a', portraitURL: '/file/db/thang.type/56f17562f3ae4cc900a0c57a/portrait.png', kind: 'Doodad'} + {slug: 'oak-crossbow', name: 'Oak Crossbow', original: '544d80928494308424f564cb', portraitURL: '/file/db/thang.type/544d80928494308424f564cb/portrait.png', kind: 'Item'} + {slug: 'oak-sphere-staff', name: 'Oak Sphere Staff', original: '544d88b78494308424f5650d', portraitURL: '/file/db/thang.type/544d88b78494308424f5650d/portrait.png', kind: 'Item'} + {slug: 'oak-wand', name: 'Oak Wand', original: '544d87d18494308424f564fd', portraitURL: '/file/db/thang.type/544d87d18494308424f564fd/portrait.png', kind: 'Item'} + {slug: 'oasis-1', name: 'Oasis 1', original: '544d79678494308424f56480', portraitURL: '/file/db/thang.type/544d79678494308424f56480/portrait.png', kind: 'Doodad'} + {slug: 'oasis-2', name: 'Oasis 2', original: '544d71198494308424f5647c', portraitURL: '/file/db/thang.type/544d71198494308424f5647c/portrait.png', kind: 'Doodad'} + {slug: 'oasis-3', name: 'Oasis 3', original: '5435d22f7b554def1f99c49a', portraitURL: '/file/db/thang.type/5435d22f7b554def1f99c49a/portrait.png', kind: 'Doodad'} + {slug: 'obsidian-breastplate', name: 'Obsidian Breastplate', original: '546ab11b3777d6186329286a', portraitURL: '/file/db/thang.type/546ab11b3777d6186329286a/portrait.png', kind: 'Item'} + {slug: 'obsidian-helmet', name: 'Obsidian Helmet', original: '546d39989df4a17d0d449a13', portraitURL: '/file/db/thang.type/546d39989df4a17d0d449a13/portrait.png', kind: 'Item'} + {slug: 'obsidian-shield', name: 'Obsidian Shield', original: '54eaba502b7506e891ca7216', portraitURL: '/file/db/thang.type/54eaba502b7506e891ca7216/portrait.png', kind: 'Item'} + {slug: 'obsidian-staff', name: 'Obsidian Staff', original: '54eab4b92b7506e891ca71ea', portraitURL: '/file/db/thang.type/54eab4b92b7506e891ca71ea/portrait.png', kind: 'Item'} + {slug: 'obstacle', name: 'Obstacle', original: '52bcc10d1f766a891c000001', portraitURL: '/file/db/thang.type/52bcc10d1f766a891c000001/portrait.png', kind: 'Misc'} + {slug: 'office-chair', name: 'office-chair', original: '56b25d8cc9d8ed21008354b8', portraitURL: '/file/db/thang.type/56b25d8cc9d8ed21008354b8/portrait.png', kind: 'Doodad'} + {slug: 'office-desk', name: 'Office Desk', original: '56b26c487168802600d26218', portraitURL: '/file/db/thang.type/56b26c487168802600d26218/portrait.png', kind: 'Doodad'} + {slug: 'office-door', name: 'Office Door', original: '56ba3366131fde2a000b84db', portraitURL: '/file/db/thang.type/56ba3366131fde2a000b84db/portrait.png', kind: 'Doodad'} + {slug: 'office-filing-cabinet', name: 'Office Filing Cabinet', original: '56b267eec2958a26005fbb58', portraitURL: '/file/db/thang.type/56b267eec2958a26005fbb58/portrait.png', kind: 'Doodad'} + {slug: 'office-filing-cabinet-2', name: 'Office Filing Cabinet 2', original: '56b268dd7168802600d25f3d', portraitURL: '/file/db/thang.type/56b268dd7168802600d25f3d/portrait.png', kind: 'Doodad'} + {slug: 'office-floor', name: 'Office Floor', original: '56b26e39bb550b26003adef0', portraitURL: '/file/db/thang.type/56b26e39bb550b26003adef0/portrait.png', kind: 'Floor'} + {slug: 'office-wall', name: 'office-wall', original: '56abc26c26c92a26005b3745', portraitURL: '/file/db/thang.type/56abc26c26c92a26005b3745/portrait.png', kind: 'Wall'} + {slug: 'ogre-barracks', name: 'Ogre Barracks', original: '530d11faa8583eb90a2fc76f', portraitURL: '/file/db/thang.type/530d11faa8583eb90a2fc76f/portrait.png', kind: 'Unit'} + {slug: 'ogre-fence', name: 'Ogre Fence', original: '5456b5c5d5ada30000525609', portraitURL: '/file/db/thang.type/5456b5c5d5ada30000525609/portrait.png', kind: 'Doodad'} + {slug: 'ogre-fence-2', name: 'Ogre Fence 2', original: '5456b631d5ada3000052560b', portraitURL: '/file/db/thang.type/5456b631d5ada3000052560b/portrait.png', kind: 'Doodad'} + {slug: 'ogre-headhunter-hero', name: 'Ogre Headhunter Hero', original: '5670779dfb9b702400cf6987', portraitURL: '/file/db/thang.type/5670779dfb9b702400cf6987/portrait.png', kind: 'Unit'} + {slug: 'ogre-peon-f', name: 'Ogre Peon F', original: '53765709a7b5ab3805f15512', portraitURL: '/file/db/thang.type/53765709a7b5ab3805f15512/portrait.png', kind: 'Unit'} + {slug: 'ogre-peon-m', name: 'Ogre Peon M', original: '53793734f883583805e356e2', portraitURL: '/file/db/thang.type/53793734f883583805e356e2/portrait.png', kind: 'Unit'} + {slug: 'ogre-scout-f', name: 'Ogre Scout F', original: '54909436b30e9eb7027fe21c', portraitURL: '/file/db/thang.type/54909436b30e9eb7027fe21c/portrait.png', kind: 'Unit'} + {slug: 'ogre-scout-m', name: 'Ogre Scout M', original: '54908ce5b30e9eb7027fe201', portraitURL: '/file/db/thang.type/54908ce5b30e9eb7027fe201/portrait.png', kind: 'Unit'} + {slug: 'ogre-tent', name: 'Ogre Tent', original: '5456b49dd5ada30000525607', portraitURL: '/file/db/thang.type/5456b49dd5ada30000525607/portrait.png', kind: 'Doodad'} + {slug: 'ogre-tower', name: 'ogre tower', original: '578686459fabcb1f0087d064', portraitURL: '/file/db/thang.type/578686459fabcb1f0087d064/portrait.png', kind: 'Doodad'} + {slug: 'ogre-tower-with-desert-rocks', name: 'ogre tower with desert rocks', original: '572d465dab2d38ad00a1c918', portraitURL: '/file/db/thang.type/572d465dab2d38ad00a1c918/portrait.png', kind: 'Doodad'} + {slug: 'ogre-towers-with-trees', name: 'ogre towers with trees', original: '572d47eee24ce2fb0025c6f3', portraitURL: '/file/db/thang.type/572d47eee24ce2fb0025c6f3/portrait.png', kind: 'Doodad'} + {slug: 'ogre-treasure-chest', name: 'Ogre Treasure Chest', original: '540e16d6821af8000097dc55', portraitURL: '/file/db/thang.type/540e16d6821af8000097dc55/portrait.png', kind: 'Doodad'} + {slug: 'ogre-wall', name: 'ogre wall', original: '5786834a2437842400f4009c', portraitURL: '/file/db/thang.type/5786834a2437842400f4009c/portrait.png', kind: 'Doodad'} + {slug: 'ogre-witch-hero', name: 'Ogre Witch Hero', original: '5638f6c4ef9d6464094a559d', portraitURL: '/file/db/thang.type/5638f6c4ef9d6464094a559d/portrait.png', kind: 'Unit'} + {slug: 'old-selection', name: 'Old Selection', original: '52aa5f7520fccb0000000002', portraitURL: '/file/db/thang.type/52aa5f7520fccb0000000002/portrait.png', kind: 'Mark'} + {slug: 'order-of-the-paladin', name: 'Order of the Paladin', original: '54eb55af49fa2d5c905ddf22', portraitURL: '/file/db/thang.type/54eb55af49fa2d5c905ddf22/portrait.png', kind: 'Item'} + {slug: 'overseer', name: 'Overseer', original: '56e75e0b67a0142000a12699', portraitURL: '/file/db/thang.type/56e75e0b67a0142000a12699/portrait.png', kind: 'Unit'} + {slug: 'painted-steel-breastplate', name: 'Painted Steel Breastplate', original: '546ab0dd3777d61863292866', portraitURL: '/file/db/thang.type/546ab0dd3777d61863292866/portrait.png', kind: 'Item'} + {slug: 'painted-steel-helmet', name: 'Painted Steel Helmet', original: '546d39589df4a17d0d449a0f', portraitURL: '/file/db/thang.type/546d39589df4a17d0d449a0f/portrait.png', kind: 'Item'} + {slug: 'painted-steel-shield', name: 'Painted Steel Shield', original: '544d7c5b8494308424f5649b', portraitURL: '/file/db/thang.type/544d7c5b8494308424f5649b/portrait.png', kind: 'Item'} + {slug: 'palisade', name: 'Palisade', original: '546e24bd9df4a17d0d449bc9', portraitURL: '/file/db/thang.type/546e24bd9df4a17d0d449bc9/portrait.png', kind: 'Doodad'} + {slug: 'paralyze', name: 'Paralyze', original: '53024e6b222f73867774d773', portraitURL: '/file/db/thang.type/53024e6b222f73867774d773/portrait.png', kind: 'Mark'} + {slug: 'pedestal', name: 'Pedestal', original: '542ae4750048dcb95727a1e6', portraitURL: '/file/db/thang.type/542ae4750048dcb95727a1e6/portrait.png', kind: 'Doodad'} + {slug: 'phoenixfire', name: 'Phoenixfire', original: '54ea8b602b7506e891ca718d', portraitURL: '/file/db/thang.type/54ea8b602b7506e891ca718d/portrait.png', kind: 'Item'} + {slug: 'plasma-ball', name: 'Plasma Ball', original: '5589fe594bed1b6c2a2cab6b', portraitURL: '/file/db/thang.type/5589fe594bed1b6c2a2cab6b/portrait.png', kind: 'Missile'} + {slug: 'poison', name: 'Poison', original: '53024020222f73867774d619', portraitURL: '/file/db/thang.type/53024020222f73867774d619/portrait.png', kind: 'Mark'} + {slug: 'poisoned-throwing-shard-missile', name: 'Poisoned Throwing Shard Missile', original: '544d97088494308424f56539', portraitURL: '/file/db/thang.type/544d97088494308424f56539/portrait.png', kind: 'Missile'} + {slug: 'polar-bear-cub-pet', name: 'Polar Bear Cub pet', original: '57588d4b87b06e1f00ded849', portraitURL: '/file/db/thang.type/57588d4b87b06e1f00ded849/portrait.png', kind: 'Unit'} + {slug: 'polished-agate-sense-stone', name: 'Polished Agate Sense Stone', original: '54693274a2b1f53ce79443c9', portraitURL: '/file/db/thang.type/54693274a2b1f53ce79443c9/portrait.png', kind: 'Item'} + {slug: 'polished-bronze-breastplate', name: 'Polished Bronze Breastplate', original: '545d3f0b2d03e700001b5a5d', portraitURL: '/file/db/thang.type/545d3f0b2d03e700001b5a5d/portrait.png', kind: 'Item'} + {slug: 'polished-bronze-helmet', name: 'Polished Bronze Helmet', original: '546d38779df4a17d0d449a03', portraitURL: '/file/db/thang.type/546d38779df4a17d0d449a03/portrait.png', kind: 'Item'} + {slug: 'polished-bronze-shield', name: 'Polished Bronze Shield', original: '544d7a888494308424f56487', portraitURL: '/file/db/thang.type/544d7a888494308424f56487/portrait.png', kind: 'Item'} + {slug: 'polished-emerald-sense-stone', name: 'Polished Emerald Sense Stone', original: '546933dda2b1f53ce79443d9', portraitURL: '/file/db/thang.type/546933dda2b1f53ce79443d9/portrait.png', kind: 'Item'} + {slug: 'polished-sense-stone', name: 'Polished Sense Stone', original: '53e215a253457600003e3eaf', portraitURL: '/file/db/thang.type/53e215a253457600003e3eaf/portrait.png', kind: 'Item'} + {slug: 'polished-steel-scale-chainmail-coif', name: 'Polished Steel Scale Chainmail Coif', original: '546d46889df4a17d0d449a5f', portraitURL: '/file/db/thang.type/546d46889df4a17d0d449a5f/portrait.png', kind: 'Item'} + {slug: 'polished-steel-scale-chainmail-tunic', name: 'Polished Steel Scale Chainmail Tunic', original: '546d3c3f9df4a17d0d449a37', portraitURL: '/file/db/thang.type/546d3c3f9df4a17d0d449a37/portrait.png', kind: 'Item'} + {slug: 'pot-1', name: 'Pot 1', original: '54ef882f83b08b7d054b6d49', portraitURL: '/file/db/thang.type/54ef882f83b08b7d054b6d49/portrait.png', kind: 'Doodad'} + {slug: 'pot-2', name: 'Pot 2', original: '54ef89dc4bb4788505d21234', portraitURL: '/file/db/thang.type/54ef89dc4bb4788505d21234/portrait.png', kind: 'Doodad'} + {slug: 'pot-3', name: 'Pot 3', original: '54ef8b1f305d7e790557d5d5', portraitURL: '/file/db/thang.type/54ef8b1f305d7e790557d5d5/portrait.png', kind: 'Doodad'} + {slug: 'pot-4', name: 'Pot 4', original: '54ef8becace2147e05868483', portraitURL: '/file/db/thang.type/54ef8becace2147e05868483/portrait.png', kind: 'Doodad'} + {slug: 'potion-belt', name: 'Potion Belt', original: '54694ac4a2b1f53ce794443d', portraitURL: '/file/db/thang.type/54694ac4a2b1f53ce794443d/portrait.png', kind: 'Item'} + {slug: 'potted-tree', name: 'Potted Tree', original: '56b2c8baf2ea182100d8ce78', portraitURL: '/file/db/thang.type/56b2c8baf2ea182100d8ce78/portrait.png', kind: 'Doodad'} + {slug: 'powder-charge', name: 'Powder Charge', original: '5462952cf44055a4b5e73599', portraitURL: '/file/db/thang.type/5462952cf44055a4b5e73599/portrait.png', kind: 'Item'} + {slug: 'powder-charge-missile', name: 'Powder Charge Missile', original: '544d99328494308424f56540', portraitURL: '/file/db/thang.type/544d99328494308424f56540/portrait.png', kind: 'Missile'} + {slug: 'power-up', name: 'Power Up', original: '55c64140ef141c65665beb6b', portraitURL: '/file/db/thang.type/55c64140ef141c65665beb6b/portrait.png', kind: 'Mark'} + {slug: 'power-up-2', name: 'Power Up 2', original: '55c6419fef141c65665beb6f', portraitURL: '/file/db/thang.type/55c6419fef141c65665beb6f/portrait.png', kind: 'Mark'} + {slug: 'precision-rifle', name: 'Precision Rifle', original: '54eaaecc2b7506e891ca71d9', portraitURL: '/file/db/thang.type/54eaaecc2b7506e891ca71d9/portrait.png', kind: 'Item'} + {slug: 'programmaticon-i', name: 'Programmaticon I', original: '53e4108204c00d4607a89f78', portraitURL: '/file/db/thang.type/53e4108204c00d4607a89f78/portrait.png', kind: 'Item'} + {slug: 'programmaticon-ii', name: 'Programmaticon II', original: '546e25d99df4a17d0d449be1', portraitURL: '/file/db/thang.type/546e25d99df4a17d0d449be1/portrait.png', kind: 'Item'} + {slug: 'programmaticon-iii', name: 'Programmaticon III', original: '546e266e9df4a17d0d449be5', portraitURL: '/file/db/thang.type/546e266e9df4a17d0d449be5/portrait.png', kind: 'Item'} + {slug: 'programmaticon-iv', name: 'Programmaticon IV', original: '55240951f76d6ee949f66512', portraitURL: '/file/db/thang.type/55240951f76d6ee949f66512/portrait.png', kind: 'Item'} + {slug: 'programmaticon-v', name: 'Programmaticon V', original: '557871261ff17fef5abee3ee', portraitURL: '/file/db/thang.type/557871261ff17fef5abee3ee/portrait.png', kind: 'Item'} + {slug: 'pugicorn-pet', name: 'Pugicorn Pet', original: '577d5edcab818b210046b73c', portraitURL: '/file/db/thang.type/577d5edcab818b210046b73c/portrait.png', kind: 'Unit'} + {slug: 'pushcart', name: 'Pushcart', original: '54f119a6d2969f8405ef539f', portraitURL: '/file/db/thang.type/54f119a6d2969f8405ef539f/portrait.png', kind: 'Doodad'} + {slug: 'quartz-sense-stone', name: 'Quartz Sense Stone', original: '54693240a2b1f53ce79443c5', portraitURL: '/file/db/thang.type/54693240a2b1f53ce79443c5/portrait.png', kind: 'Item'} + {slug: 'ragged-silk-hat', name: 'Ragged Silk Hat', original: '546d4ba19df4a17d0d449aaf', portraitURL: '/file/db/thang.type/546d4ba19df4a17d0d449aaf/portrait.png', kind: 'Item'} + {slug: 'railgun', name: 'Railgun', original: '54ea8ea52b7506e891ca7191', portraitURL: '/file/db/thang.type/54ea8ea52b7506e891ca7191/portrait.png', kind: 'Item'} + {slug: 'rapidfire-rifle', name: 'Rapidfire Rifle', original: '54eaae422b7506e891ca71d5', portraitURL: '/file/db/thang.type/54eaae422b7506e891ca71d5/portrait.png', kind: 'Item'} + {slug: 'rat', name: 'Rat', original: '55c11b70c87e47c60604f974', portraitURL: '/file/db/thang.type/55c11b70c87e47c60604f974/portrait.png', kind: 'Doodad'} + {slug: 'razor-ring', name: 'Razor Ring', original: '54c97c9bdef3ad363ff998b7', portraitURL: '/file/db/thang.type/54c97c9bdef3ad363ff998b7/portrait.png', kind: 'Missile'} + {slug: 'razordisc-missile', name: 'Razordisc Missile', original: '5318d3e56ad8999d34bdf338', portraitURL: '/file/db/thang.type/5318d3e56ad8999d34bdf338/portrait.png', kind: 'Missile'} + {slug: 'rectangle', name: 'Rectangle', original: '568d915e1717e2f90e9a1250', portraitURL: '/file/db/thang.type/568d915e1717e2f90e9a1250/portrait.png', kind: 'Misc'} + {slug: 'red-button', name: 'Red Button', original: '56d102c0441ddd2f002ba760', portraitURL: '/file/db/thang.type/56d102c0441ddd2f002ba760/portrait.png', kind: 'Doodad'} + {slug: 'regen', name: 'Regen', original: '53024f8b27471514685d53e1', portraitURL: '/file/db/thang.type/53024f8b27471514685d53e1/portrait.png', kind: 'Mark'} + {slug: 'reindeer', name: 'Reindeer', original: '54e95a88f54ef5794f354f3d', portraitURL: '/file/db/thang.type/54e95a88f54ef5794f354f3d/portrait.png', kind: 'Doodad'} + {slug: 'reinforced-boots', name: 'Reinforced Boots', original: '546d4d259df4a17d0d449ac5', portraitURL: '/file/db/thang.type/546d4d259df4a17d0d449ac5/portrait.png', kind: 'Item'} + {slug: 'reinforced-crossbow', name: 'Reinforced Crossbow', original: '54eaacdd2b7506e891ca71cd', portraitURL: '/file/db/thang.type/54eaacdd2b7506e891ca71cd/portrait.png', kind: 'Item'} + {slug: 'reinforced-iron-chainmail-coif', name: 'Reinforced Iron Chainmail Coif', original: '546d46099df4a17d0d449a57', portraitURL: '/file/db/thang.type/546d46099df4a17d0d449a57/portrait.png', kind: 'Item'} + {slug: 'reinforced-iron-chainmail-tunic', name: 'Reinforced Iron Chainmail Tunic', original: '546d3bbb9df4a17d0d449a2f', portraitURL: '/file/db/thang.type/546d3bbb9df4a17d0d449a2f/portrait.png', kind: 'Item'} + {slug: 'repair', name: 'Repair', original: '52bcc4591f766a891c000003', portraitURL: '/file/db/thang.type/52bcc4591f766a891c000003/portrait.png', kind: 'Mark'} + {slug: 'ring-of-developer-experimentation', name: 'Ring of Developer Experimentation', original: '54bac99bacbf5aea089da177', portraitURL: '/file/db/thang.type/54bac99bacbf5aea089da177/portrait.png', kind: 'Item'} + {slug: 'ring-of-earth', name: 'Ring of Earth', original: '5441c35c4e9aeb727cc9711d', portraitURL: '/file/db/thang.type/5441c35c4e9aeb727cc9711d/portrait.png', kind: 'Item'} + {slug: 'ring-of-fire', name: 'Ring of Fire', original: '54692ea2a2b1f53ce79443ab', portraitURL: '/file/db/thang.type/54692ea2a2b1f53ce79443ab/portrait.png', kind: 'Item'} + {slug: 'ring-of-flowers', name: 'Ring of Flowers', original: '5523224b0676ecb7d5c89319', portraitURL: '/file/db/thang.type/5523224b0676ecb7d5c89319/portrait.png', kind: 'Item'} + {slug: 'ring-of-ice', name: 'Ring of Ice', original: '54692ed3a2b1f53ce79443af', portraitURL: '/file/db/thang.type/54692ed3a2b1f53ce79443af/portrait.png', kind: 'Item'} + {slug: 'ring-of-speed', name: 'Ring of Speed', original: '54692d2aa2b1f53ce794438f', portraitURL: '/file/db/thang.type/54692d2aa2b1f53ce794438f/portrait.png', kind: 'Item'} + {slug: 'riveted-dragonscale-chainmail-coif', name: 'Riveted Dragonscale Chainmail Coif', original: '546d47c09df4a17d0d449a6f', portraitURL: '/file/db/thang.type/546d47c09df4a17d0d449a6f/portrait.png', kind: 'Item'} + {slug: 'riveted-dragonscale-chainmail-tunic', name: 'Riveted Dragonscale Chainmail Tunic', original: '546d3d549df4a17d0d449a47', portraitURL: '/file/db/thang.type/546d3d549df4a17d0d449a47/portrait.png', kind: 'Item'} + {slug: 'robe-of-the-magi', name: 'Robe of the Magi', original: '54ea3ec22b7506e891ca7126', portraitURL: '/file/db/thang.type/54ea3ec22b7506e891ca7126/portrait.png', kind: 'Item'} + {slug: 'robobomb', name: 'Robobomb', original: '55b7fb22a337d9b0ea024bb4', portraitURL: '/file/db/thang.type/55b7fb22a337d9b0ea024bb4/portrait.png', kind: 'Unit'} + {slug: 'robot-walker', name: 'Robot Walker', original: '5301696ad82649ec2c0c9b0d', portraitURL: '/file/db/thang.type/5301696ad82649ec2c0c9b0d/portrait.png', kind: 'Unit'} + {slug: 'rock-1', name: 'Rock 1', original: '52afcc1fc5b1813ec2000010', portraitURL: '/file/db/thang.type/52afcc1fc5b1813ec2000010/portrait.png', kind: 'Doodad'} + {slug: 'rock-2', name: 'Rock 2', original: '52afcce4c5b1813ec2000012', portraitURL: '/file/db/thang.type/52afcce4c5b1813ec2000012/portrait.png', kind: 'Doodad'} + {slug: 'rock-3', name: 'Rock 3', original: '52afcd43c5b1813ec2000014', portraitURL: '/file/db/thang.type/52afcd43c5b1813ec2000014/portrait.png', kind: 'Doodad'} + {slug: 'rock-4', name: 'Rock 4', original: '52afcd7bc5b1813ec2000016', portraitURL: '/file/db/thang.type/52afcd7bc5b1813ec2000016/portrait.png', kind: 'Doodad'} + {slug: 'rock-5', name: 'Rock 5', original: '52afcdc7c5b1813ec2000018', portraitURL: '/file/db/thang.type/52afcdc7c5b1813ec2000018/portrait.png', kind: 'Doodad'} + {slug: 'rock-6', name: 'Rock 6', original: '54e95916f54ef5794f354f2d', portraitURL: '/file/db/thang.type/54e95916f54ef5794f354f2d/portrait.png', kind: 'Doodad'} + {slug: 'rock-7', name: 'Rock 7', original: '54e959d6f54ef5794f354f31', portraitURL: '/file/db/thang.type/54e959d6f54ef5794f354f31/portrait.png', kind: 'Doodad'} + {slug: 'rock-8', name: 'Rock 8', original: '54e95a10f54ef5794f354f35', portraitURL: '/file/db/thang.type/54e95a10f54ef5794f354f35/portrait.png', kind: 'Doodad'} + {slug: 'rock-cluster-1', name: 'Rock Cluster 1', original: '52afcb47c5b1813ec200000a', portraitURL: '/file/db/thang.type/52afcb47c5b1813ec200000a/portrait.png', kind: 'Doodad'} + {slug: 'rock-cluster-2', name: 'Rock Cluster 2', original: '52afcb98c5b1813ec200000c', portraitURL: '/file/db/thang.type/52afcb98c5b1813ec200000c/portrait.png', kind: 'Doodad'} + {slug: 'rock-cluster-3', name: 'Rock Cluster 3', original: '52afcbe0c5b1813ec200000e', portraitURL: '/file/db/thang.type/52afcbe0c5b1813ec200000e/portrait.png', kind: 'Doodad'} + {slug: 'rock-field-1', name: 'Rock Field 1', original: '54e95753f54ef5794f354f21', portraitURL: '/file/db/thang.type/54e95753f54ef5794f354f21/portrait.png', kind: 'Doodad'} + {slug: 'rock-field-2', name: 'Rock Field 2', original: '54e95861f54ef5794f354f25', portraitURL: '/file/db/thang.type/54e95861f54ef5794f354f25/portrait.png', kind: 'Doodad'} + {slug: 'rock-field-3', name: 'Rock Field 3', original: '54e958aaf54ef5794f354f29', portraitURL: '/file/db/thang.type/54e958aaf54ef5794f354f29/portrait.png', kind: 'Doodad'} + {slug: 'root', name: 'Root', original: '55c640feef141c65665beb67', portraitURL: '/file/db/thang.type/55c640feef141c65665beb67/portrait.png', kind: 'Mark'} + {slug: 'rough-sense-stone', name: 'Rough Sense Stone', original: '54693140a2b1f53ce79443bc', portraitURL: '/file/db/thang.type/54693140a2b1f53ce79443bc/portrait.png', kind: 'Item'} + {slug: 'roughedge', name: 'Roughedge', original: '544d7d918494308424f564a7', portraitURL: '/file/db/thang.type/544d7d918494308424f564a7/portrait.png', kind: 'Item'} + {slug: 'rs-demo', name: 'RS Demo', original: '56ce48892438c720001e3ca3', portraitURL: '/file/db/thang.type/56ce48892438c720001e3ca3/portrait.png', kind: 'undefined'} + {slug: 'runesword', name: 'Runesword', original: '54eaa9622b7506e891ca71b1', portraitURL: '/file/db/thang.type/54eaa9622b7506e891ca71b1/portrait.png', kind: 'Item'} + {slug: 'rusted-iron-breastplate', name: 'Rusted Iron Breastplate', original: '545d3fe42d03e700001b5a5f', portraitURL: '/file/db/thang.type/545d3fe42d03e700001b5a5f/portrait.png', kind: 'Item'} + {slug: 'rusted-iron-helmet', name: 'Rusted Iron Helmet', original: '546d38d09df4a17d0d449a07', portraitURL: '/file/db/thang.type/546d38d09df4a17d0d449a07/portrait.png', kind: 'Item'} + {slug: 'rusted-steel-scale-chainmail-coif', name: 'Rusted Steel Scale Chainmail Coif', original: '546d46419df4a17d0d449a5b', portraitURL: '/file/db/thang.type/546d46419df4a17d0d449a5b/portrait.png', kind: 'Item'} + {slug: 'rusted-steel-scale-chainmail-tunic', name: 'Rusted Steel Scale Chainmail Tunic', original: '546d3bf99df4a17d0d449a33', portraitURL: '/file/db/thang.type/546d3bf99df4a17d0d449a33/portrait.png', kind: 'Item'} + {slug: 'sand-01', name: 'Sand 01', original: '5484df79d7b7b862291456af', portraitURL: '/file/db/thang.type/5484df79d7b7b862291456af/portrait.png', kind: 'Floor'} + {slug: 'sand-02', name: 'Sand 02', original: '5484e7c5d7b7b862291456b3', portraitURL: '/file/db/thang.type/5484e7c5d7b7b862291456b3/portrait.png', kind: 'Floor'} + {slug: 'sand-03', name: 'Sand 03', original: '5484e81bd7b7b862291456b7', portraitURL: '/file/db/thang.type/5484e81bd7b7b862291456b7/portrait.png', kind: 'Floor'} + {slug: 'sand-04', name: 'Sand 04', original: '5484e857d7b7b862291456bb', portraitURL: '/file/db/thang.type/5484e857d7b7b862291456bb/portrait.png', kind: 'Floor'} + {slug: 'sand-05', name: 'Sand 05', original: '5484e89cd7b7b862291456bf', portraitURL: '/file/db/thang.type/5484e89cd7b7b862291456bf/portrait.png', kind: 'Floor'} + {slug: 'sand-06', name: 'Sand 06', original: '5484e8ddd7b7b862291456c3', portraitURL: '/file/db/thang.type/5484e8ddd7b7b862291456c3/portrait.png', kind: 'Floor'} + {slug: 'sand-yak', name: 'Sand Yak', original: '5480b2251bf0b10000711c51', portraitURL: '/file/db/thang.type/5480b2251bf0b10000711c51/portrait.png', kind: 'Unit'} + {slug: 'sapphire-sense-stone', name: 'Sapphire Sense Stone', original: '54693363a2b1f53ce79443d1', portraitURL: '/file/db/thang.type/54693363a2b1f53ce79443d1/portrait.png', kind: 'Item'} + {slug: 'sarcophagus', name: 'sarcophagus', original: '572d5a2d3ff46db2000a381b', portraitURL: '/file/db/thang.type/572d5a2d3ff46db2000a381b/portrait.png', kind: 'Doodad'} + {slug: 'scaled-gloves', name: 'Scaled Gloves', original: '5469496ca2b1f53ce794442d', portraitURL: '/file/db/thang.type/5469496ca2b1f53ce794442d/portrait.png', kind: 'Item'} + {slug: 'school-locker', name: 'School locker', original: '56eb14804eb67a25009be23e', portraitURL: '/file/db/thang.type/56eb14804eb67a25009be23e/portrait.png', kind: 'Doodad'} + {slug: 'scoreboard', name: 'Scoreboard', original: '56de0ff26f9cc02400831e06', portraitURL: '/file/db/thang.type/56de0ff26f9cc02400831e06/portrait.png', kind: 'Doodad'} + {slug: 'scorpion', name: 'Scorpion', original: '548cf5340f559d0000be7e5b', portraitURL: '/file/db/thang.type/548cf5340f559d0000be7e5b/portrait.png', kind: 'Doodad'} + {slug: 'selection', name: 'Selection', original: '546e23d49df4a17d0d449bb5', portraitURL: '/file/db/thang.type/546e23d49df4a17d0d449bb5/portrait.png', kind: 'Misc'} + {slug: 'shadow-guard-background', name: 'Shadow Guard Background', original: '55bfc4c950cac5d58def9a67', portraitURL: '/file/db/thang.type/55bfc4c950cac5d58def9a67/portrait.png', kind: 'Floor'} + {slug: 'shadowless-bird', name: 'Shadowless Bird', original: '55079c55cea461db22519e9d', portraitURL: '/file/db/thang.type/55079c55cea461db22519e9d/portrait.png', kind: 'Doodad'} + {slug: 'shadowless-cloud-1', name: 'Shadowless Cloud 1', original: '53e2df9cd12e873205b6bce8', portraitURL: '/file/db/thang.type/53e2df9cd12e873205b6bce8/portrait.png', kind: 'Doodad'} + {slug: 'shadowless-cloud-2', name: 'Shadowless Cloud 2', original: '53e2e0176c59f5340504102f', portraitURL: '/file/db/thang.type/53e2e0176c59f5340504102f/portrait.png', kind: 'Doodad'} + {slug: 'shadowless-cloud-3', name: 'Shadowless Cloud 3', original: '53e2e08eae44ec37059f2148', portraitURL: '/file/db/thang.type/53e2e08eae44ec37059f2148/portrait.png', kind: 'Doodad'} + {slug: 'sharpened-sword', name: 'Sharpened Sword', original: '544d7deb8494308424f564ab', portraitURL: '/file/db/thang.type/544d7deb8494308424f564ab/portrait.png', kind: 'Item'} + {slug: 'sharpsong', name: 'Sharpsong', original: '544d95c78494308424f56527', portraitURL: '/file/db/thang.type/544d95c78494308424f56527/portrait.png', kind: 'Item'} + {slug: 'sharpsong-missile', name: 'Sharpsong Missile', original: '544d98368494308424f5653e', portraitURL: '/file/db/thang.type/544d98368494308424f5653e/portrait.png', kind: 'Missile'} + {slug: 'shell', name: 'Shell', original: '52ba2c6c981fbb7e48000093', portraitURL: '/file/db/thang.type/52ba2c6c981fbb7e48000093/portrait.png', kind: 'Missile'} + {slug: 'shield', name: 'Shield', original: '573fa531d0bee72000a4255f', portraitURL: '/file/db/thang.type/573fa531d0bee72000a4255f/portrait.png', kind: 'Mark'} + {slug: 'short-sword', name: 'Short Sword', original: '544d7f1a8494308424f564b7', portraitURL: '/file/db/thang.type/544d7f1a8494308424f564b7/portrait.png', kind: 'Item'} + {slug: 'shrub-1', name: 'Shrub 1', original: '52b0a113ccbc671372000017', portraitURL: '/file/db/thang.type/52b0a113ccbc671372000017/portrait.png', kind: 'Doodad'} + {slug: 'shrub-2', name: 'Shrub 2', original: '52b0a15accbc671372000019', portraitURL: '/file/db/thang.type/52b0a15accbc671372000019/portrait.png', kind: 'Doodad'} + {slug: 'shrub-3', name: 'Shrub 3', original: '52b0a1a3ccbc67137200001b', portraitURL: '/file/db/thang.type/52b0a1a3ccbc67137200001b/portrait.png', kind: 'Doodad'} + {slug: 'sign', name: 'Sign', original: '5435cbe77b554def1f99c491', portraitURL: '/file/db/thang.type/5435cbe77b554def1f99c491/portrait.png', kind: 'Doodad'} + {slug: 'silver-coin', name: 'Silver Coin', original: '535ef1f64f10444d08486b61', portraitURL: '/file/db/thang.type/535ef1f64f10444d08486b61/portrait.png', kind: 'Misc'} + {slug: 'simple-boots', name: 'Simple Boots', original: '53e237bf53457600003e3f05', portraitURL: '/file/db/thang.type/53e237bf53457600003e3f05/portrait.png', kind: 'Item'} + {slug: 'simple-katana', name: 'Simple Katana', original: '544d7ed58494308424f564b3', portraitURL: '/file/db/thang.type/544d7ed58494308424f564b3/portrait.png', kind: 'Item'} + {slug: 'simple-rifle', name: 'Simple Rifle', original: '544d70a18494308424f5647a', portraitURL: '/file/db/thang.type/544d70a18494308424f5647a/portrait.png', kind: 'Item'} + {slug: 'simple-sword', name: 'Simple Sword', original: '53e218d853457600003e3ebe', portraitURL: '/file/db/thang.type/53e218d853457600003e3ebe/portrait.png', kind: 'Item'} + {slug: 'simple-wand', name: 'Simple Wand', original: '544d874f8494308424f564f5', portraitURL: '/file/db/thang.type/544d874f8494308424f564f5/portrait.png', kind: 'Item'} + {slug: 'simple-wristwatch', name: 'Simple Wristwatch', original: '54693797a2b1f53ce79443e9', portraitURL: '/file/db/thang.type/54693797a2b1f53ce79443e9/portrait.png', kind: 'Item'} + {slug: 'skeleton-bits-1', name: 'Skeleton Bits 1', original: '54ef85bdc1f3bd7c0593d125', portraitURL: '/file/db/thang.type/54ef85bdc1f3bd7c0593d125/portrait.png', kind: 'Doodad'} + {slug: 'skeleton-bits-2', name: 'Skeleton Bits 2', original: '54ef874370ff9c8005e1eb0d', portraitURL: '/file/db/thang.type/54ef874370ff9c8005e1eb0d/portrait.png', kind: 'Doodad'} + {slug: 'sky-span-background-1', name: 'Sky Span Background 1', original: '53e3f096ae44ec37059f92d8', portraitURL: '/file/db/thang.type/53e3f096ae44ec37059f92d8/portrait.png', kind: 'Floor'} + {slug: 'sky-span-background-2', name: 'Sky Span Background 2', original: '53e3f3556c59f5340504359e', portraitURL: '/file/db/thang.type/53e3f3556c59f5340504359e/portrait.png', kind: 'Floor'} + {slug: 'sky-span-background-3', name: 'Sky Span Background 3', original: '53e3f500ae44ec37059f9415', portraitURL: '/file/db/thang.type/53e3f500ae44ec37059f9415/portrait.png', kind: 'Doodad'} + {slug: 'sky-span-background-4', name: 'Sky Span Background 4', original: '53e3f5dbae44ec37059f944a', portraitURL: '/file/db/thang.type/53e3f5dbae44ec37059f944a/portrait.png', kind: 'Doodad'} + {slug: 'sky-span-background-5', name: 'Sky Span Background 5', original: '53e3f646d12e873205b72abd', portraitURL: '/file/db/thang.type/53e3f646d12e873205b72abd/portrait.png', kind: 'Floor'} + {slug: 'sky-span-background-6', name: 'Sky Span Background 6', original: '53e3f724d12e873205b72af9', portraitURL: '/file/db/thang.type/53e3f724d12e873205b72af9/portrait.png', kind: 'Floor'} + {slug: 'sky-span-background-7', name: 'Sky Span Background 7', original: '53e3f74dae44ec37059f94b3', portraitURL: '/file/db/thang.type/53e3f74dae44ec37059f94b3/portrait.png', kind: 'Doodad'} + {slug: 'sleep', name: 'Sleep', original: '5302504b222f73867774d7a1', portraitURL: '/file/db/thang.type/5302504b222f73867774d7a1/portrait.png', kind: 'Mark'} + {slug: 'slow', name: 'Slow', original: '5302511327471514685d5405', portraitURL: '/file/db/thang.type/5302511327471514685d5405/portrait.png', kind: 'Mark'} + {slug: 'snake', name: 'Snake', original: '548cf57c0f559d0000be7e5f', portraitURL: '/file/db/thang.type/548cf57c0f559d0000be7e5f/portrait.png', kind: 'Doodad'} + {slug: 'snake-pillar', name: 'Snake Pillar', original: '54ef8db1223edd8105aff2b9', portraitURL: '/file/db/thang.type/54ef8db1223edd8105aff2b9/portrait.png', kind: 'Doodad'} + {slug: 'soft-leather-gloves', name: 'Soft Leather Gloves', original: '546948e9a2b1f53ce7944425', portraitURL: '/file/db/thang.type/546948e9a2b1f53ce7944425/portrait.png', kind: 'Item'} + {slug: 'softened-leather-boots', name: 'Softened Leather Boots', original: '546d4d589df4a17d0d449ac9', portraitURL: '/file/db/thang.type/546d4d589df4a17d0d449ac9/portrait.png', kind: 'Item'} + {slug: 'sparkbomb', name: 'Sparkbomb', original: '54eb528449fa2d5c905ddf12', portraitURL: '/file/db/thang.type/54eb528449fa2d5c905ddf12/portrait.png', kind: 'Item'} + {slug: 'sparkbomb-missile', name: 'Sparkbomb Missile', original: '5535b3bd428ddac5686fcf7a', portraitURL: '/file/db/thang.type/5535b3bd428ddac5686fcf7a/portrait.png', kind: 'Missile'} + {slug: 'spear', name: 'Spear', original: '52ba2affd68e4b7c48000030', portraitURL: '/file/db/thang.type/52ba2affd68e4b7c48000030/portrait.png', kind: 'Missile'} + {slug: 'spider', name: 'Spider', original: '55c1353bc87e47c60604f997', portraitURL: '/file/db/thang.type/55c1353bc87e47c60604f997/portrait.png', kind: 'Doodad'} + {slug: 'spiderweb-1', name: 'Spiderweb 1', original: '54ef7c69223edd8105afc1f4', portraitURL: '/file/db/thang.type/54ef7c69223edd8105afc1f4/portrait.png', kind: 'Doodad'} + {slug: 'spiderweb-2', name: 'Spiderweb 2', original: '54ef7d41ace2147e058655a8', portraitURL: '/file/db/thang.type/54ef7d41ace2147e058655a8/portrait.png', kind: 'Doodad'} + {slug: 'spiderweb-3', name: 'Spiderweb 3', original: '54ef7ed7b4740779058410c9', portraitURL: '/file/db/thang.type/54ef7ed7b4740779058410c9/portrait.png', kind: 'Doodad'} + {slug: 'spiderweb-4', name: 'Spiderweb 4', original: '54ef8938ace2147e05867d6d', portraitURL: '/file/db/thang.type/54ef8938ace2147e05867d6d/portrait.png', kind: 'Doodad'} + {slug: 'spike-walls', name: 'Spike Walls', original: '5422f63718adb78d98d265f7', portraitURL: '/file/db/thang.type/5422f63718adb78d98d265f7/portrait.png', kind: 'Doodad'} + {slug: 'spiked-ogre-wall', name: 'spiked ogre wall', original: '578682dccca8994b002708eb', portraitURL: '/file/db/thang.type/578682dccca8994b002708eb/portrait.png', kind: 'Doodad'} + {slug: 'stalactite-1', name: 'Stalactite 1', original: '55760f0a1e82182d9e688912', portraitURL: '/file/db/thang.type/55760f0a1e82182d9e688912/portrait.png', kind: 'Doodad'} + {slug: 'stalactite-2', name: 'Stalactite 2', original: '55760f4a1e82182d9e688916', portraitURL: '/file/db/thang.type/55760f4a1e82182d9e688916/portrait.png', kind: 'Doodad'} + {slug: 'stalactite-3', name: 'Stalactite 3', original: '55760f6f1e82182d9e68891a', portraitURL: '/file/db/thang.type/55760f6f1e82182d9e68891a/portrait.png', kind: 'Doodad'} + {slug: 'stalagmite-1', name: 'Stalagmite 1', original: '55760e6f1e82182d9e688906', portraitURL: '/file/db/thang.type/55760e6f1e82182d9e688906/portrait.png', kind: 'Doodad'} + {slug: 'stalagmite-2', name: 'Stalagmite 2', original: '55760eb61e82182d9e68890a', portraitURL: '/file/db/thang.type/55760eb61e82182d9e68890a/portrait.png', kind: 'Doodad'} + {slug: 'stalagmite-3', name: 'Stalagmite 3', original: '55760ee51e82182d9e68890e', portraitURL: '/file/db/thang.type/55760ee51e82182d9e68890e/portrait.png', kind: 'Doodad'} + {slug: 'statue-stone-hooded', name: 'Statue Stone Hooded', original: '546e23469df4a17d0d449ba9', portraitURL: '/file/db/thang.type/546e23469df4a17d0d449ba9/portrait.png', kind: 'Doodad'} + {slug: 'steel-breastplate', name: 'Steel Breastplate', original: '546ab0a83777d61863292862', portraitURL: '/file/db/thang.type/546ab0a83777d61863292862/portrait.png', kind: 'Item'} + {slug: 'steel-helmet', name: 'Steel Helmet', original: '5441c2ed4e9aeb727cc9710b', portraitURL: '/file/db/thang.type/5441c2ed4e9aeb727cc9710b/portrait.png', kind: 'Item'} + {slug: 'steel-ring', name: 'Steel Ring', original: '54692dbca2b1f53ce794439b', portraitURL: '/file/db/thang.type/54692dbca2b1f53ce794439b/portrait.png', kind: 'Item'} + {slug: 'steel-shield', name: 'Steel Shield', original: '544d7bec8494308424f56497', portraitURL: '/file/db/thang.type/544d7bec8494308424f56497/portrait.png', kind: 'Item'} + {slug: 'steel-striker', name: 'Steel Striker', original: '544d7c948494308424f5649f', portraitURL: '/file/db/thang.type/544d7c948494308424f5649f/portrait.png', kind: 'Item'} + {slug: 'steel-wand', name: 'Steel Wand', original: '544d88e48494308424f56511', portraitURL: '/file/db/thang.type/544d88e48494308424f56511/portrait.png', kind: 'Item'} + {slug: 'stiff-lambswool-hat', name: 'Stiff Lambswool Hat', original: '546d4b379df4a17d0d449aa7', portraitURL: '/file/db/thang.type/546d4b379df4a17d0d449aa7/portrait.png', kind: 'Item'} + {slug: 'stone-builders-hammer', name: 'Stone Builder\'s Hammer', original: '54694bcca2b1f53ce7944451', portraitURL: '/file/db/thang.type/54694bcca2b1f53ce7944451/portrait.png', kind: 'Item'} + {slug: 'stone-fall-1', name: 'Stone Fall 1', original: '53e2e5046f406a3505b3ead6', portraitURL: '/file/db/thang.type/53e2e5046f406a3505b3ead6/portrait.png', kind: 'Doodad'} + {slug: 'stone-fall-2', name: 'Stone Fall 2', original: '53e2e66d6c59f534050410d0', portraitURL: '/file/db/thang.type/53e2e66d6c59f534050410d0/portrait.png', kind: 'Doodad'} + {slug: 'stone-fall-3', name: 'Stone Fall 3', original: '53e2e728ae44ec37059f2438', portraitURL: '/file/db/thang.type/53e2e728ae44ec37059f2438/portrait.png', kind: 'Doodad'} + {slug: 'stone-pillars', name: 'stone pillars', original: '572d5958f5da8e29013e4e8d', portraitURL: '/file/db/thang.type/572d5958f5da8e29013e4e8d/portrait.png', kind: 'Doodad'} + {slug: 'stone-statue', name: 'Stone Statue', original: '546e25479df4a17d0d449bd5', portraitURL: '/file/db/thang.type/546e25479df4a17d0d449bd5/portrait.png', kind: 'Doodad'} + {slug: 'stormbringer', name: 'Stormbringer', original: '54ea87342b7506e891ca7175', portraitURL: '/file/db/thang.type/54ea87342b7506e891ca7175/portrait.png', kind: 'Item'} + {slug: 'stretched-hide', name: 'Stretched Hide', original: '557608901e82182d9e6888ce', portraitURL: '/file/db/thang.type/557608901e82182d9e6888ce/portrait.png', kind: 'Doodad'} + {slug: 'student-a', name: 'Student A', original: '56d0edd0441ddd2f002ba5aa', portraitURL: '/file/db/thang.type/56d0edd0441ddd2f002ba5aa/portrait.png', kind: 'Unit'} + {slug: 'student-b', name: 'Student B', original: '56d0efc14292981f009f51de', portraitURL: '/file/db/thang.type/56d0efc14292981f009f51de/portrait.png', kind: 'Unit'} + {slug: 'stump-1', name: 'Stump 1', original: '54e955f6f54ef5794f354f09', portraitURL: '/file/db/thang.type/54e955f6f54ef5794f354f09/portrait.png', kind: 'Doodad'} + {slug: 'stump-2', name: 'Stump 2', original: '54e95634f54ef5794f354f0d', portraitURL: '/file/db/thang.type/54e95634f54ef5794f354f0d/portrait.png', kind: 'Doodad'} + {slug: 'stump-3', name: 'Stump 3', original: '557f91f9b43ce0b15a91b1cd', portraitURL: '/file/db/thang.type/557f91f9b43ce0b15a91b1cd/portrait.png', kind: 'Doodad'} + {slug: 'stump-4', name: 'Stump 4', original: '557f923eb43ce0b15a91b1d1', portraitURL: '/file/db/thang.type/557f923eb43ce0b15a91b1d1/portrait.png', kind: 'Doodad'} + {slug: 'stump-5', name: 'Stump 5', original: '557f925ab43ce0b15a91b1d5', portraitURL: '/file/db/thang.type/557f925ab43ce0b15a91b1d5/portrait.png', kind: 'Doodad'} + {slug: 'sturdy-bronze-shield', name: 'Sturdy Bronze Shield', original: '544d7b028494308424f5648b', portraitURL: '/file/db/thang.type/544d7b028494308424f5648b/portrait.png', kind: 'Item'} + {slug: 'sulphur-staff', name: 'Sulphur Staff', original: '54eab7132b7506e891ca71fa', portraitURL: '/file/db/thang.type/54eab7132b7506e891ca71fa/portrait.png', kind: 'Item'} + {slug: 'sundial-wristwatch', name: 'Sundial Wristwatch', original: '53e2396a53457600003e3f0f', portraitURL: '/file/db/thang.type/53e2396a53457600003e3f0f/portrait.png', kind: 'Item'} + {slug: 'sword', name: 'Sword', original: '52bcda141f766a891c00000a', portraitURL: '/file/db/thang.type/52bcda141f766a891c00000a/portrait.png', kind: 'Misc'} + {slug: 'sword-belt', name: 'Sword Belt', original: '5441beb74e9aeb727cc970d3', portraitURL: '/file/db/thang.type/5441beb74e9aeb727cc970d3/portrait.png', kind: 'Item'} + {slug: 'sword-fall-1', name: 'Sword Fall 1', original: '53e2e8a7d12e873205b6c0f1', portraitURL: '/file/db/thang.type/53e2e8a7d12e873205b6c0f1/portrait.png', kind: 'Doodad'} + {slug: 'sword-fall-2', name: 'Sword Fall 2', original: '53e2e9a9ae44ec37059f2571', portraitURL: '/file/db/thang.type/53e2e9a9ae44ec37059f2571/portrait.png', kind: 'Doodad'} + {slug: 'sword-of-the-forgotten', name: 'Sword of the Forgotten', original: '54eaaa522b7506e891ca71b9', portraitURL: '/file/db/thang.type/54eaaa522b7506e891ca71b9/portrait.png', kind: 'Item'} + {slug: 'sword-of-the-temple-guard', name: 'Sword of the Temple Guard', original: '54eaab372b7506e891ca71c1', portraitURL: '/file/db/thang.type/54eaab372b7506e891ca71c1/portrait.png', kind: 'Item'} + {slug: 'table', name: 'Table', original: '52e9987a427172ae56001ffd', portraitURL: '/file/db/thang.type/52e9987a427172ae56001ffd/portrait.png', kind: 'Doodad'} + {slug: 'tailored-linen-robe', name: 'Tailored Linen Robe', original: '546d49759df4a17d0d449a87', portraitURL: '/file/db/thang.type/546d49759df4a17d0d449a87/portrait.png', kind: 'Item'} + {slug: 'talus-1', name: 'Talus 1', original: '54e944a3f54ef5794f354ea9', portraitURL: '/file/db/thang.type/54e944a3f54ef5794f354ea9/portrait.png', kind: 'Floor'} + {slug: 'talus-2', name: 'Talus 2', original: '54e94880f54ef5794f354ead', portraitURL: '/file/db/thang.type/54e94880f54ef5794f354ead/portrait.png', kind: 'Floor'} + {slug: 'talus-3', name: 'Talus 3', original: '54e948daf54ef5794f354eb1', portraitURL: '/file/db/thang.type/54e948daf54ef5794f354eb1/portrait.png', kind: 'Floor'} + {slug: 'talus-4', name: 'Talus 4', original: '54e94908f54ef5794f354eb5', portraitURL: '/file/db/thang.type/54e94908f54ef5794f354eb5/portrait.png', kind: 'Floor'} + {slug: 'talus-5', name: 'Talus 5', original: '54e9493cf54ef5794f354eb9', portraitURL: '/file/db/thang.type/54e9493cf54ef5794f354eb9/portrait.png', kind: 'Floor'} + {slug: 'talus-6', name: 'Talus 6', original: '54e94965f54ef5794f354ebd', portraitURL: '/file/db/thang.type/54e94965f54ef5794f354ebd/portrait.png', kind: 'Floor'} + {slug: 'tarnished-bronze-breastplate', name: 'Tarnished Bronze Breastplate', original: '53e22eac53457600003e3efc', portraitURL: '/file/db/thang.type/53e22eac53457600003e3efc/portrait.png', kind: 'Item'} + {slug: 'tarnished-bronze-helmet', name: 'Tarnished Bronze Helmet', original: '546d38269df4a17d0d4499ff', portraitURL: '/file/db/thang.type/546d38269df4a17d0d4499ff/portrait.png', kind: 'Item'} + {slug: 'tarnished-copper-band', name: 'Tarnished Copper Band', original: '54692a75a2b1f53ce7944387', portraitURL: '/file/db/thang.type/54692a75a2b1f53ce7944387/portrait.png', kind: 'Item'} + {slug: 'tauran-helm', name: 'Tauran Helm', original: '54ea49982b7506e891ca7165', portraitURL: '/file/db/thang.type/54ea49982b7506e891ca7165/portrait.png', kind: 'Item'} + {slug: 'tauran-plate', name: 'Tauran Plate', original: '54ea4b302b7506e891ca716d', portraitURL: '/file/db/thang.type/54ea4b302b7506e891ca716d/portrait.png', kind: 'Item'} + {slug: 'teacher-b', name: 'Teacher B', original: '56de0554d048927700b4f741', portraitURL: '/file/db/thang.type/56de0554d048927700b4f741/portrait.png', kind: 'Doodad'} + {slug: 'tent-1', name: 'Tent 1', original: '548cf2280f559d0000be7e37', portraitURL: '/file/db/thang.type/548cf2280f559d0000be7e37/portrait.png', kind: 'Doodad'} + {slug: 'tent-2', name: 'Tent 2', original: '548cf2b10f559d0000be7e3b', portraitURL: '/file/db/thang.type/548cf2b10f559d0000be7e3b/portrait.png', kind: 'Doodad'} + {slug: 'tent-3', name: 'Tent 3', original: '548cf30b0f559d0000be7e3f', portraitURL: '/file/db/thang.type/548cf30b0f559d0000be7e3f/portrait.png', kind: 'Doodad'} + {slug: 'the-final-kithmaze-background', name: 'the final kithmaze background', original: '577ecc2b67053f25007eb916', portraitURL: '/file/db/thang.type/577ecc2b67053f25007eb916/portrait.png', kind: 'Floor'} + {slug: 'the-gauntlet-background', name: 'The Gauntlet Background', original: '572d631812f2abce00164c15', portraitURL: '/file/db/thang.type/572d631812f2abce00164c15/portrait.png', kind: 'Floor'} + {slug: 'the-monolith', name: 'The Monolith', original: '54eabcb72b7506e891ca7226', portraitURL: '/file/db/thang.type/54eabcb72b7506e891ca7226/portrait.png', kind: 'Item'} + {slug: 'the-precious', name: 'The Precious', original: '54eb56ae49fa2d5c905ddf2a', portraitURL: '/file/db/thang.type/54eb56ae49fa2d5c905ddf2a/portrait.png', kind: 'Item'} + {slug: 'thick-burlap-robe', name: 'Thick Burlap Robe', original: '546d48989df4a17d0d449a77', portraitURL: '/file/db/thang.type/546d48989df4a17d0d449a77/portrait.png', kind: 'Item'} + {slug: 'thin-burlap-robe', name: 'Thin Burlap Robe', original: '546d485b9df4a17d0d449a73', portraitURL: '/file/db/thang.type/546d485b9df4a17d0d449a73/portrait.png', kind: 'Item'} + {slug: 'thoktars-discarded-hammer', name: 'Thoktar\'s Discarded Hammer', original: '54694cd6a2b1f53ce7944466', portraitURL: '/file/db/thang.type/54694cd6a2b1f53ce7944466/portrait.png', kind: 'Item'} + {slug: 'thornprick', name: 'Thornprick', original: '54692e75a2b1f53ce79443a7', portraitURL: '/file/db/thang.type/54692e75a2b1f53ce79443a7/portrait.png', kind: 'Item'} + {slug: 'threadbare-burlap-wizards-hat', name: 'Threadbare Burlap Wizards Hat', original: '546d4a909df4a17d0d449a9b', portraitURL: '/file/db/thang.type/546d4a909df4a17d0d449a9b/portrait.png', kind: 'Item'} + {slug: 'throne', name: 'Throne', original: '54efa174933e1e7b05846fe6', portraitURL: '/file/db/thang.type/54efa174933e1e7b05846fe6/portrait.png', kind: 'Doodad'} + {slug: 'tomb-ring', name: 'Tomb Ring', original: '54eb55d849fa2d5c905ddf26', portraitURL: '/file/db/thang.type/54eb55d849fa2d5c905ddf26/portrait.png', kind: 'Item'} + {slug: 'tool-belt', name: 'Tool Belt', original: '5441beff4e9aeb727cc970d9', portraitURL: '/file/db/thang.type/5441beff4e9aeb727cc970d9/portrait.png', kind: 'Item'} + {slug: 'torch', name: 'Torch', original: '52aa608b20fccb0000000005', portraitURL: '/file/db/thang.type/52aa608b20fccb0000000005/portrait.png', kind: 'Doodad'} + {slug: 'torn-silk-cloak', name: 'Torn Silk Cloak', original: '546d49a79df4a17d0d449a8b', portraitURL: '/file/db/thang.type/546d49a79df4a17d0d449a8b/portrait.png', kind: 'Item'} + {slug: 'torture-table', name: 'Torture Table', original: '54ef8fd4b474077905843564', portraitURL: '/file/db/thang.type/54ef8fd4b474077905843564/portrait.png', kind: 'Doodad'} + {slug: 'tower-ruined', name: 'Tower Ruined', original: '54f117c548724e7d052b540b', portraitURL: '/file/db/thang.type/54f117c548724e7d052b540b/portrait.png', kind: 'Doodad'} + {slug: 'training-dummy', name: 'Training Dummy', original: '53e65923bc5cc012113e07b1', portraitURL: '/file/db/thang.type/53e65923bc5cc012113e07b1/portrait.png', kind: 'Doodad'} + {slug: 'trap-belt', name: 'Trap Belt', original: '54694a8fa2b1f53ce7944439', portraitURL: '/file/db/thang.type/54694a8fa2b1f53ce7944439/portrait.png', kind: 'Item'} + {slug: 'treasure-chest', name: 'Treasure Chest', original: '52aa3be0ccbd588d4d000005', portraitURL: '/file/db/thang.type/52aa3be0ccbd588d4d000005/portrait.png', kind: 'Doodad'} + {slug: 'tree-1', name: 'Tree 1', original: '52b09ef7ccbc67137200000f', portraitURL: '/file/db/thang.type/52b09ef7ccbc67137200000f/portrait.png', kind: 'Doodad'} + {slug: 'tree-2', name: 'Tree 2', original: '52b09fdeccbc671372000011', portraitURL: '/file/db/thang.type/52b09fdeccbc671372000011/portrait.png', kind: 'Doodad'} + {slug: 'tree-3', name: 'Tree 3', original: '52b0a04fccbc671372000013', portraitURL: '/file/db/thang.type/52b0a04fccbc671372000013/portrait.png', kind: 'Doodad'} + {slug: 'tree-4', name: 'Tree 4', original: '52b0a0a5ccbc671372000015', portraitURL: '/file/db/thang.type/52b0a0a5ccbc671372000015/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-1', name: 'Tree Stand 1', original: '541cc7c48e78524aad94de7d', portraitURL: '/file/db/thang.type/541cc7c48e78524aad94de7d/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-2', name: 'Tree Stand 2', original: '542068f38e78524aad94de83', portraitURL: '/file/db/thang.type/542068f38e78524aad94de83/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-3', name: 'Tree Stand 3', original: '5420693d8e78524aad94de89', portraitURL: '/file/db/thang.type/5420693d8e78524aad94de89/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-4', name: 'Tree Stand 4', original: '542069888e78524aad94de8f', portraitURL: '/file/db/thang.type/542069888e78524aad94de8f/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-5', name: 'Tree Stand 5', original: '542092628e78524aad94deca', portraitURL: '/file/db/thang.type/542092628e78524aad94deca/portrait.png', kind: 'Doodad'} + {slug: 'tree-stand-6', name: 'Tree Stand 6', original: '542092c38e78524aad94ded0', portraitURL: '/file/db/thang.type/542092c38e78524aad94ded0/portrait.png', kind: 'Doodad'} + {slug: 'true-names-background', name: 'True Names Background', original: '55e451bd206f7df7df6ba966', portraitURL: '/file/db/thang.type/55e451bd206f7df7df6ba966/portrait.png', kind: 'Floor'} + {slug: 'twilight-glasses', name: 'Twilight Glasses', original: '546941fda2b1f53ce794441d', portraitURL: '/file/db/thang.type/546941fda2b1f53ce794441d/portrait.png', kind: 'Item'} + {slug: 'twisted-pine-wand', name: 'Twisted Pine Wand', original: '544d877d8494308424f564f9', portraitURL: '/file/db/thang.type/544d877d8494308424f564f9/portrait.png', kind: 'Item'} + {slug: 'undead', name: 'Undead', original: '55c284933767fd3435eb4471', portraitURL: '/file/db/thang.type/55c284933767fd3435eb4471/portrait.png', kind: 'Mark'} + {slug: 'undergrowth-dagger', name: 'Undergrowth Dagger', original: '544d95e68494308424f5652b', portraitURL: '/file/db/thang.type/544d95e68494308424f5652b/portrait.png', kind: 'Item'} + {slug: 'undergrowth-dagger-missile', name: 'Undergrowth Dagger Missile', original: '544d99618494308424f56541', portraitURL: '/file/db/thang.type/544d99618494308424f56541/portrait.png', kind: 'Missile'} + {slug: 'undying-ring', name: 'Undying Ring', original: '54eb54d349fa2d5c905ddf1e', portraitURL: '/file/db/thang.type/54eb54d349fa2d5c905ddf1e/portrait.png', kind: 'Item'} + {slug: 'unholy-tome-i', name: 'Unholy Tome I', original: '546374bc3839c6e02811d308', portraitURL: '/file/db/thang.type/546374bc3839c6e02811d308/portrait.png', kind: 'Item'} + {slug: 'unholy-tome-ii', name: 'Unholy Tome II', original: '5463756f3839c6e02811d30c', portraitURL: '/file/db/thang.type/5463756f3839c6e02811d30c/portrait.png', kind: 'Item'} + {slug: 'unholy-tome-iii', name: 'Unholy Tome III', original: '5463758f3839c6e02811d30f', portraitURL: '/file/db/thang.type/5463758f3839c6e02811d30f/portrait.png', kind: 'Item'} + {slug: 'unholy-tome-iv', name: 'Unholy Tome IV', original: '546376b63839c6e02811d31b', portraitURL: '/file/db/thang.type/546376b63839c6e02811d31b/portrait.png', kind: 'Item'} + {slug: 'unholy-tome-v', name: 'Unholy Tome V', original: '546376da3839c6e02811d31e', portraitURL: '/file/db/thang.type/546376da3839c6e02811d31e/portrait.png', kind: 'Item'} + {slug: 'viking-helmet', name: 'Viking Helmet', original: '5441c3144e9aeb727cc97111', portraitURL: '/file/db/thang.type/5441c3144e9aeb727cc97111/portrait.png', kind: 'Item'} + {slug: 'viking-helmet-doodad', name: 'Viking Helmet Doodad', original: '5518239d1f12482609b44f76', portraitURL: '/file/db/thang.type/5518239d1f12482609b44f76/portrait.png', kind: 'Doodad'} + {slug: 'vine-staff', name: 'Vine Staff', original: '54eab92b2b7506e891ca720a', portraitURL: '/file/db/thang.type/54eab92b2b7506e891ca720a/portrait.png', kind: 'Item'} + {slug: 'volcano', name: 'Volcano', original: '55c64512ef141c65665beb7e', portraitURL: '/file/db/thang.type/55c64512ef141c65665beb7e/portrait.png', kind: 'Doodad'} + {slug: 'vr-artist', name: 'VR Artist', original: '56d0c6bf087ee32400763d49', portraitURL: '/file/db/thang.type/56d0c6bf087ee32400763d49/portrait.png', kind: 'Unit'} + {slug: 'vr-breaker', name: 'VR Breaker', original: '56d0e6e563103d2a00af5795', portraitURL: '/file/db/thang.type/56d0e6e563103d2a00af5795/portrait.png', kind: 'Unit'} + {slug: 'vr-door', name: 'VR Door', original: '56aa6bf503ec4e2000878867', portraitURL: '/file/db/thang.type/56aa6bf503ec4e2000878867/portrait.png', kind: 'Doodad'} + {slug: 'vr-floor', name: 'VR Floor', original: '56a2e305b0b7242000e9986e', portraitURL: '/file/db/thang.type/56a2e305b0b7242000e9986e/portrait.png', kind: 'Floor'} + {slug: 'vr-oracle', name: 'VR Oracle', original: '56d0d144a7daf22000023a13', portraitURL: '/file/db/thang.type/56d0d144a7daf22000023a13/portrait.png', kind: 'Unit'} + {slug: 'vr-security', name: 'VR Security', original: '56d758b787781b1f00cf4b20', portraitURL: '/file/db/thang.type/56d758b787781b1f00cf4b20/portrait.png', kind: 'Unit'} + {slug: 'vr-tinker', name: 'VR Tinker', original: '56d07d682a1e1736005b1b37', portraitURL: '/file/db/thang.type/56d07d682a1e1736005b1b37/portrait.png', kind: 'Unit'} + {slug: 'vr-wall', name: 'vr-wall', original: '56b0c75302b7db290079b542', portraitURL: '/file/db/thang.type/56b0c75302b7db290079b542/portrait.png', kind: 'Wall'} + {slug: 'vr-wyrm', name: 'VR Wyrm', original: '56bb944d203af82000b2a406', portraitURL: '/file/db/thang.type/56bb944d203af82000b2a406/portrait.png', kind: 'Unit'} + {slug: 'wagon-broken', name: 'Wagon Broken', original: '548cf1cd0f559d0000be7e33', portraitURL: '/file/db/thang.type/548cf1cd0f559d0000be7e33/portrait.png', kind: 'Doodad'} + {slug: 'wakka-maul-background', name: 'Wakka Maul Background', original: '5654eae2f9285e86053f7504', portraitURL: '/file/db/thang.type/5654eae2f9285e86053f7504/portrait.png', kind: 'Floor'} + {slug: 'warcry', name: 'Warcry', original: '53024777222f73867774d6cd', portraitURL: '/file/db/thang.type/53024777222f73867774d6cd/portrait.png', kind: 'Mark'} + {slug: 'waterfall', name: 'Waterfall', original: '53e2eaffae44ec37059f262a', portraitURL: '/file/db/thang.type/53e2eaffae44ec37059f262a/portrait.png', kind: 'Doodad'} + {slug: 'weak-charge', name: 'Weak Charge', original: '544d957d8494308424f5651f', portraitURL: '/file/db/thang.type/544d957d8494308424f5651f/portrait.png', kind: 'Item'} + {slug: 'weak-charge-missile', name: 'Weak Charge Missile', original: '544d97798494308424f5653b', portraitURL: '/file/db/thang.type/544d97798494308424f5653b/portrait.png', kind: 'Missile'} + {slug: 'weighted-throwing-knives', name: 'Weighted Throwing Knives', original: '544d96108494308424f5652f', portraitURL: '/file/db/thang.type/544d96108494308424f5652f/portrait.png', kind: 'Item'} + {slug: 'weighted-throwing-knives-missile', name: 'Weighted Throwing Knives Missile', original: '544d99b98494308424f56545', portraitURL: '/file/db/thang.type/544d99b98494308424f56545/portrait.png', kind: 'Missile'} + {slug: 'well', name: 'Well', original: '52b094cbccbc671372000004', portraitURL: '/file/db/thang.type/52b094cbccbc671372000004/portrait.png', kind: 'Doodad'} + {slug: 'white-deerhide-gloves', name: 'White Deerhide Gloves', original: '54694936a2b1f53ce7944429', portraitURL: '/file/db/thang.type/54694936a2b1f53ce7944429/portrait.png', kind: 'Item'} + {slug: 'windwalker-coif', name: 'Windwalker Coif', original: '54ea48512b7506e891ca7157', portraitURL: '/file/db/thang.type/54ea48512b7506e891ca7157/portrait.png', kind: 'Item'} + {slug: 'windwalker-mail', name: 'Windwalker Mail', original: '54ea46092b7506e891ca7143', portraitURL: '/file/db/thang.type/54ea46092b7506e891ca7143/portrait.png', kind: 'Item'} + {slug: 'winged-boots', name: 'Winged Boots', original: '546d4e5c9df4a17d0d449ad9', portraitURL: '/file/db/thang.type/546d4e5c9df4a17d0d449ad9/portrait.png', kind: 'Item'} + {slug: 'wizard-bird-f', name: 'Wizard Bird F', original: '52fc0c9e7e01835453bd8ef8', portraitURL: '/file/db/thang.type/52fc0c9e7e01835453bd8ef8/portrait.png', kind: 'Unit'} + {slug: 'wizard-bird-m', name: 'Wizard Bird M', original: '52fd015f3a58c6c50fcf4782', portraitURL: '/file/db/thang.type/52fd015f3a58c6c50fcf4782/portrait.png', kind: 'Unit'} + {slug: 'wizard-doctor', name: 'Wizard Doctor', original: '52fc04fbab6e45c813bc7ced', portraitURL: '/file/db/thang.type/52fc04fbab6e45c813bc7ced/portrait.png', kind: 'Unit'} + {slug: 'wizard-dude', name: 'Wizard Dude', original: '53e126a4e06b897606d38bef', portraitURL: '/file/db/thang.type/53e126a4e06b897606d38bef/portrait.png', kind: 'Unit'} + {slug: 'wizard-hermes', name: 'Wizard Hermes', original: '52fc09daab6e45c813bc7d52', portraitURL: '/file/db/thang.type/52fc09daab6e45c813bc7d52/portrait.png', kind: 'Unit'} + {slug: 'wizard-knight', name: 'Wizard Knight', original: '52fc00ffab6e45c813bc7cb2', portraitURL: '/file/db/thang.type/52fc00ffab6e45c813bc7cb2/portrait.png', kind: 'Unit'} + {slug: 'wizard-ninja-m', name: 'Wizard Ninja M', original: '52fd04aff0cd954d619a9a4c', portraitURL: '/file/db/thang.type/52fd04aff0cd954d619a9a4c/portrait.png', kind: 'Unit'} + {slug: 'wizard-overseer-f', name: 'Wizard Overseer F', original: '52fc11fbb2b91c0d5a7b6a14', portraitURL: '/file/db/thang.type/52fc11fbb2b91c0d5a7b6a14/portrait.png', kind: 'Unit'} + {slug: 'wizard-overseer-m', name: 'Wizard Overseer M', original: '52fd0728ccb2653821eaf8b0', portraitURL: '/file/db/thang.type/52fd0728ccb2653821eaf8b0/portrait.png', kind: 'Unit'} + {slug: 'wizard-purple', name: 'Wizard Purple', original: '52fd0e16c7e6cf99160e7b6a', portraitURL: '/file/db/thang.type/52fd0e16c7e6cf99160e7b6a/portrait.png', kind: 'Unit'} + {slug: 'wizard-spine', name: 'Wizard Spine', original: '52fcfed63a58c6c50fcf4732', portraitURL: '/file/db/thang.type/52fcfed63a58c6c50fcf4732/portrait.png', kind: 'Unit'} + {slug: 'wizard-spine-m', name: 'Wizard Spine M', original: '52fd0c70f0cd954d619a9b10', portraitURL: '/file/db/thang.type/52fd0c70f0cd954d619a9b10/portrait.png', kind: 'Unit'} + {slug: 'wizard-thorn-f', name: 'Wizard Thorn F', original: '52fc1460b2b91c0d5a7b6af3', portraitURL: '/file/db/thang.type/52fc1460b2b91c0d5a7b6af3/portrait.png', kind: 'Unit'} + {slug: 'wizard-thorn-m', name: 'Wizard Thorn M', original: '52fd0a40f0cd954d619a9ad7', portraitURL: '/file/db/thang.type/52fd0a40f0cd954d619a9ad7/portrait.png', kind: 'Unit'} + {slug: 'wizard-top-hat', name: 'Wizard Top Hat', original: '52fd124accb2653821eaf991', portraitURL: '/file/db/thang.type/52fd124accb2653821eaf991/portrait.png', kind: 'Unit'} + {slug: 'wooden-builders-hammer', name: 'Wooden Builder\'s Hammer', original: '54694ba3a2b1f53ce794444d', portraitURL: '/file/db/thang.type/54694ba3a2b1f53ce794444d/portrait.png', kind: 'Item'} + {slug: 'wooden-glasses', name: 'Wooden Glasses', original: '53e2167653457600003e3eb3', portraitURL: '/file/db/thang.type/53e2167653457600003e3eb3/portrait.png', kind: 'Item'} + {slug: 'wooden-shield', name: 'Wooden Shield', original: '53e22aa153457600003e3ef5', portraitURL: '/file/db/thang.type/53e22aa153457600003e3ef5/portrait.png', kind: 'Item'} + {slug: 'wooden-strand', name: 'Wooden Strand', original: '54692e3ea2b1f53ce79443a3', portraitURL: '/file/db/thang.type/54692e3ea2b1f53ce79443a3/portrait.png', kind: 'Item'} + {slug: 'workers-gloves', name: 'Worker\'s Gloves', original: '5469425ca2b1f53ce7944421', portraitURL: '/file/db/thang.type/5469425ca2b1f53ce7944421/portrait.png', kind: 'Item'} + {slug: 'worn-dragonplate', name: 'Worn Dragonplate', original: '546ab1a13777d61863292872', portraitURL: '/file/db/thang.type/546ab1a13777d61863292872/portrait.png', kind: 'Item'} + {slug: 'worn-dragonplate-helmet', name: 'Worn Dragonplate Helmet', original: '546d3a199df4a17d0d449a1b', portraitURL: '/file/db/thang.type/546d3a199df4a17d0d449a1b/portrait.png', kind: 'Item'} + {slug: 'worn-dragonshield', name: 'Worn Dragonshield', original: '54eabd662b7506e891ca722e', portraitURL: '/file/db/thang.type/54eabd662b7506e891ca722e/portrait.png', kind: 'Item'} + {slug: 'wyrm2', name: 'wyrm2', original: '56c32fd1807b9f36005e5fd0', portraitURL: '/file/db/thang.type/56c32fd1807b9f36005e5fd0/portrait.png', kind: 'Unit'} + {slug: 'wyvernclaw', name: 'Wyvernclaw', original: '54ea35fd2b7506e891ca70d5', portraitURL: '/file/db/thang.type/54ea35fd2b7506e891ca70d5/portrait.png', kind: 'Item'} + {slug: 'x-mark-bones', name: 'X Mark Bones', original: '54938352e9850ae3e8fbdd64', portraitURL: '/file/db/thang.type/54938352e9850ae3e8fbdd64/portrait.png', kind: 'Doodad'} + {slug: 'x-mark-forest', name: 'X Mark Forest', original: '549381a7e9850ae3e8fbdd60', portraitURL: '/file/db/thang.type/549381a7e9850ae3e8fbdd60/portrait.png', kind: 'Doodad'} + {slug: 'x-mark-red', name: 'X Mark Red', original: '5493844be9850ae3e8fbdd70', portraitURL: '/file/db/thang.type/5493844be9850ae3e8fbdd70/portrait.png', kind: 'Doodad'} + {slug: 'x-mark-stone', name: 'X Mark Stone', original: '549383aae9850ae3e8fbdd68', portraitURL: '/file/db/thang.type/549383aae9850ae3e8fbdd68/portrait.png', kind: 'Doodad'} + {slug: 'x-mark-wood', name: 'X Mark Wood', original: '54938408e9850ae3e8fbdd6c', portraitURL: '/file/db/thang.type/54938408e9850ae3e8fbdd6c/portrait.png', kind: 'Doodad'} + {slug: 'x-marker', name: 'X Marker', original: '5452ec9f06a59e000067e518', portraitURL: '/file/db/thang.type/5452ec9f06a59e000067e518/portrait.png', kind: 'Doodad'} + {slug: 'x-ray-goggles', name: 'X-Ray Goggles', original: '53e2392453457600003e3f0d', portraitURL: '/file/db/thang.type/53e2392453457600003e3f0d/portrait.png', kind: 'Item'} + {slug: 'yeti', name: 'Yeti', original: '54e91dc5970f0b0a263c03de', portraitURL: '/file/db/thang.type/54e91dc5970f0b0a263c03de/portrait.png', kind: 'Unit'} + {slug: 'yeti-cave', name: 'Yeti Cave', original: '557f8f84b43ce0b15a91b1c7', portraitURL: '/file/db/thang.type/557f8f84b43ce0b15a91b1c7/portrait.png', kind: 'Doodad'} + {slug: 'yeti-skin', name: 'Yeti Skin', original: '557f370ab43ce0b15a91b171', portraitURL: '/file/db/thang.type/557f370ab43ce0b15a91b171/portrait.png', kind: 'Doodad'} + ] diff --git a/app/views/play/level/modal/ProgressView.coffee b/app/views/play/level/modal/ProgressView.coffee index 552d28fe0..0c35c61da 100644 --- a/app/views/play/level/modal/ProgressView.coffee +++ b/app/views/play/level/modal/ProgressView.coffee @@ -10,6 +10,8 @@ module.exports = class ProgressView extends CocoView events: 'click #done-btn': 'onClickDoneButton' 'click #next-level-btn': 'onClickNextLevelButton' + 'click #ladder-btn': 'onClickLadderButton' + 'click #share-level-btn': 'onClickShareLevelButton' initialize: (options) -> @level = options.level @@ -17,13 +19,24 @@ module.exports = class ProgressView extends CocoView @classroom = options.classroom @nextLevel = options.nextLevel @levelSessions = options.levelSessions + @session = options.session # Translate and Markdownify level description, but take out any images (we don't have room for arena banners, etc.). # Images in Markdown are like ![description](url) @nextLevel.get('description', true) # Make sure the defaults are available @nextLevelDescription = marked(utils.i18n(@nextLevel.attributesWithDefaults, 'description').replace(/!\[.*?\]\(.*?\)\n*/g, '')) + if @level.get('shareable') is 'project' + @shareURL = "#{window.location.origin}/play/#{@level.get('type')}-level/#{@level.get('slug')}/#{@session.id}" + @shareURL += "?course=#{@course.id}" if @course onClickDoneButton: -> @trigger 'done' onClickNextLevelButton: -> @trigger 'next-level' + + onClickLadderButton: -> + @trigger 'ladder' + + onClickShareLevelButton: -> + @$('#share-level-input').val(@shareURL).select() + @tryCopy() diff --git a/app/views/play/level/modal/VictoryModal.coffee b/app/views/play/level/modal/VictoryModal.coffee index fcc8bdb9b..360ff0937 100644 --- a/app/views/play/level/modal/VictoryModal.coffee +++ b/app/views/play/level/modal/VictoryModal.coffee @@ -71,7 +71,7 @@ module.exports = class VictoryModal extends ModalView c.me = me c.levelName = utils.i18n @level.attributes, 'name' c.level = @level - if c.level.get('type') is 'ladder' + if c.level.isType('ladder') c.readyToRank = @session.readyToRank() c diff --git a/app/views/play/level/tome/CastButtonView.coffee b/app/views/play/level/tome/CastButtonView.coffee index deade19b4..f29520726 100644 --- a/app/views/play/level/tome/CastButtonView.coffee +++ b/app/views/play/level/tome/CastButtonView.coffee @@ -18,9 +18,6 @@ module.exports = class CastButtonView extends CocoView 'tome:cast-spells': 'onCastSpells' 'tome:manual-cast-denied': 'onManualCastDenied' 'god:new-world-created': 'onNewWorld' - 'real-time-multiplayer:created-game': 'onJoinedRealTimeMultiplayerGame' - 'real-time-multiplayer:joined-game': 'onJoinedRealTimeMultiplayerGame' - 'real-time-multiplayer:left-game': 'onLeftRealTimeMultiplayerGame' 'goal-manager:new-goal-states': 'onNewGoalStates' 'god:goals-calculated': 'onGoalsCalculated' 'playback:ended-changed': 'onPlaybackEndedChanged' @@ -43,9 +40,9 @@ module.exports = class CastButtonView extends CocoView super() @castButton = $('.cast-button', @$el) spell.view?.createOnCodeChangeHandlers() for spellKey, spell of @spells - if @options.level.get('hidesSubmitUntilRun') or @options.level.get 'hidesRealTimePlayback' + if @options.level.get('hidesSubmitUntilRun') or @options.level.get('hidesRealTimePlayback') or @options.level.isType('web-dev') @$el.find('.submit-button').hide() # Hide Submit for the first few until they run it once. - if @options.session.get('state')?.complete and @options.level.get 'hidesRealTimePlayback' + if @options.session.get('state')?.complete and (@options.level.get('hidesRealTimePlayback') or @options.level.isType('web-dev')) @$el.find('.done-button').show() if @options.level.get('slug') in ['course-thornbush-farm', 'thornbush-farm'] @$el.find('.submit-button').hide() # Hide submit until first win so that script can explain it. @@ -71,9 +68,7 @@ module.exports = class CastButtonView extends CocoView Backbone.Mediator.publish 'tome:manual-cast', {} onCastRealTimeButtonClick: (e) -> - if @inRealTimeMultiplayerSession - Backbone.Mediator.publish 'real-time-multiplayer:manual-cast', {} - else if @options.level.get('replayable') and (timeUntilResubmit = @options.session.timeUntilResubmit()) > 0 + if @options.level.get('replayable') and (timeUntilResubmit = @options.session.timeUntilResubmit()) > 0 Backbone.Mediator.publish 'tome:manual-cast-denied', timeUntilResubmit: timeUntilResubmit else Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} @@ -81,7 +76,7 @@ module.exports = class CastButtonView extends CocoView onDoneButtonClick: (e) -> return if @options.level.hasLocalChanges() # Don't award achievements when beating level changed in level editor - @options.session.recordScores @world.scores, @options.level + @options.session.recordScores @world?.scores, @options.level Backbone.Mediator.publish 'level:show-victory', showModal: true onSpellChanged: (e) -> @@ -118,7 +113,7 @@ module.exports = class CastButtonView extends CocoView @winnable = winnable @$el.toggleClass 'winnable', @winnable Backbone.Mediator.publish 'tome:winnability-updated', winnable: @winnable, level: @options.level - if @options.level.get 'hidesRealTimePlayback' + if @options.level.get('hidesRealTimePlayback') or @options.level.isType('web-dev') @$el.find('.done-button').toggle @winnable else if @winnable and @options.level.get('slug') in ['course-thornbush-farm', 'thornbush-farm'] @$el.find('.submit-button').show() # Hide submit until first win so that script can explain it. @@ -178,9 +173,3 @@ module.exports = class CastButtonView extends CocoView return unless placeholder.length @ladderSubmissionView = new LadderSubmissionView session: @options.session, level: @options.level, mirrorSession: @mirrorSession @insertSubView @ladderSubmissionView, placeholder - - onJoinedRealTimeMultiplayerGame: (e) -> - @inRealTimeMultiplayerSession = true - - onLeftRealTimeMultiplayerGame: (e) -> - @inRealTimeMultiplayerSession = false diff --git a/app/views/play/level/tome/DocFormatter.coffee b/app/views/play/level/tome/DocFormatter.coffee index 93cdd1d6f..b09d7092b 100644 --- a/app/views/play/level/tome/DocFormatter.coffee +++ b/app/views/play/level/tome/DocFormatter.coffee @@ -42,12 +42,15 @@ module.exports = class DocFormatter @fillOutDoc() fillOutDoc: -> + # TODO: figure out better ways to format html/css/scripting docs for web-dev levels if _.isString @doc @doc = name: @doc, type: typeof @options.thang[@doc] if @options.isSnippet @doc.type = 'snippet' @doc.owner = 'snippets' @doc.shortName = @doc.shorterName = @doc.title = @doc.name + else if @doc.owner in ['HTML', 'CSS'] + @doc.shortName = @doc.shorterName = @doc.title = @doc.name else @doc.owner ?= 'this' ownerName = @doc.ownerName = if @doc.owner isnt 'this' then @doc.owner else switch @options.language @@ -139,7 +142,7 @@ module.exports = class DocFormatter if @doc.args arg.example = arg.example.replace thisToken[@options.language], 'hero' for arg in @doc.args when arg.example - if @doc.shortName is 'loop' and @options.level.get('type', true) in ['course', 'course-ladder'] + if @doc.shortName is 'loop' and @options.level.isType('course', 'course-ladder') @replaceSimpleLoops() replaceSimpleLoops: -> @@ -185,6 +188,7 @@ module.exports = class DocFormatter [docName, args] formatValue: (v) -> + return null if @options.level.isType('web-dev') return null if @doc.type is 'snippet' return @options.thang.now() if @doc.name is 'now' return '[Function]' if not v and @doc.type is 'function' diff --git a/app/views/play/level/tome/Problem.coffee b/app/views/play/level/tome/Problem.coffee index a42addd5d..9b5b22fa8 100644 --- a/app/views/play/level/tome/Problem.coffee +++ b/app/views/play/level/tome/Problem.coffee @@ -23,6 +23,7 @@ module.exports = class Problem raw: text, text: text, type: @aetherProblem.level ? 'error' + createdBy: 'aether' buildMarkerRange: -> return unless @aetherProblem.range diff --git a/app/views/play/level/tome/ProblemAlertView.coffee b/app/views/play/level/tome/ProblemAlertView.coffee index c6508eb17..c9ffc1363 100644 --- a/app/views/play/level/tome/ProblemAlertView.coffee +++ b/app/views/play/level/tome/ProblemAlertView.coffee @@ -14,7 +14,6 @@ module.exports = class ProblemAlertView extends CocoView 'level:restart': 'onHideProblemAlert' 'tome:jiggle-problem-alert': 'onJiggleProblemAlert' 'tome:manual-cast': 'onHideProblemAlert' - 'real-time-multiplayer:manual-cast': 'onHideProblemAlert' events: 'click .close': 'onRemoveClicked' diff --git a/app/views/play/level/tome/Spell.coffee b/app/views/play/level/tome/Spell.coffee index 71b68fb3d..ddd1f187c 100644 --- a/app/views/play/level/tome/Spell.coffee +++ b/app/views/play/level/tome/Spell.coffee @@ -1,5 +1,5 @@ SpellView = require './SpellView' -SpellListTabEntryView = require './SpellListTabEntryView' +SpellTopBarView = require './SpellTopBarView' {me} = require 'core/auth' {createAetherOptions} = require 'lib/aether_utils' utils = require 'core/utils' @@ -7,7 +7,7 @@ utils = require 'core/utils' module.exports = class Spell loaded: false view: null - entryView: null + topBarView: null constructor: (options) -> @spellKey = options.spellKey @@ -20,8 +20,6 @@ module.exports = class Spell @supermodel = options.supermodel @skipProtectAPI = options.skipProtectAPI @worker = options.worker - @levelID = options.levelID - @levelType = options.level.get('type', true) @level = options.level p = options.programmableMethod @@ -47,30 +45,41 @@ module.exports = class Spell if p.aiSource and not @otherSession and not @canWrite() @source = @originalSource = p.aiSource @isAISource = true - @thangs = {} if @canRead() # We can avoid creating these views if we'll never use them. - @view = new SpellView {spell: @, level: options.level, session: @session, otherSession: @otherSession, worker: @worker, god: options.god, @supermodel} + @view = new SpellView {spell: @, level: options.level, session: @session, otherSession: @otherSession, worker: @worker, god: options.god, @supermodel, levelID: options.levelID} @view.render() # Get it ready and code loaded in advance - @tabView = new SpellListTabEntryView + @topBarView = new SpellTopBarView hintsState: options.hintsState spell: @ supermodel: @supermodel codeLanguage: @language level: options.level - @tabView.render() + session: options.session + courseID: options.courseID + @topBarView.render() Backbone.Mediator.publish 'tome:spell-created', spell: @ destroy: -> @view?.destroy() - @tabView?.destroy() - @thangs = null + @topBarView?.destroy() + @thang = null @worker = null setLanguage: (@language) -> + @language = 'html' if @level.isType('web-dev') #console.log 'setting language to', @language, 'so using original source', @languages[language] ? @languages.javascript @originalSource = @languages[@language] ? @languages.javascript @originalSource = @addPicoCTFProblem() if window.serverConfig.picoCTF + if @level.isType('web-dev') + # Pull apart the structural wrapper code and the player code, remember the wrapper code, and strip indentation on player code. + playerCode = @originalSource.match(/\n([\s\S]*)\n *<\/playercode>/)[1] + playerCodeLines = playerCode.split('\n') + indentation = playerCodeLines[0].length - playerCodeLines[0].trim().length + playerCode = (line.substr(indentation) for line in playerCodeLines).join('\n') + @wrapperCode = @originalSource.replace /[\s\S]*<\/playercode>/, '☃' # ☃ serves as placeholder for constructHTML + @originalSource = playerCode + # Translate comments chosen spoken language. return unless @commentContext context = $.extend true, {}, @commentContext @@ -87,7 +96,7 @@ module.exports = class Spell catch e console.error "Couldn't create example code template of", @originalSource, "\nwith context", context, "\nError:", e - if /loop/.test(@originalSource) and @levelType in ['course', 'course-ladder'] + if /loop/.test(@originalSource) and @level.isType('course', 'course-ladder') # Temporary hackery to make it look like we meant while True: in our sample code until we can update everything @originalSource = switch @language when 'python' then @originalSource.replace /loop:/, 'while True:' @@ -96,6 +105,9 @@ module.exports = class Spell when 'coffeescript' then @originalSource else @originalSource + constructHTML: (source) -> + @wrapperCode.replace '☃', source + addPicoCTFProblem: -> return @originalSource unless problem = @level.picoCTFProblem description = """ @@ -105,13 +117,13 @@ module.exports = class Spell ("// #{line}" for line in description.split('\n')).join('\n') + '\n' + @originalSource addThang: (thang) -> - if @thangs[thang.id] - @thangs[thang.id].thang = thang + if @thang?.thang.id is thang.id + @thang.thang = thang else - @thangs[thang.id] = {thang: thang, aether: @createAether(thang), castAether: null} + @thang = {thang: thang, aether: @createAether(thang), castAether: null} removeThangID: (thangID) -> - delete @thangs[thangID] + @thang = null if @thang?.thang.id is thangID canRead: (team) -> (team ? me.team) in @permissions.read or (team ? me.team) in @permissions.readwrite @@ -127,28 +139,16 @@ module.exports = class Spell @source = source else source = @getSource() - [pure, problems] = [null, null] - for thangID, spellThang of @thangs - unless pure - pure = spellThang.aether.transpile source - problems = spellThang.aether.problems - #console.log 'aether transpiled', source.length, 'to', spellThang.aether.pure.length, 'for', thangID, @spellKey - else - spellThang.aether.raw = source - spellThang.aether.pure = pure - spellThang.aether.problems = problems - #console.log 'aether reused transpilation for', thangID, @spellKey + unless @language is 'html' + @thang?.aether.transpile source null hasChanged: (newSource=null, currentSource=null) -> (newSource ? @originalSource) isnt (currentSource ? @source) hasChangedSignificantly: (newSource=null, currentSource=null, cb) -> - for thangID, spellThang of @thangs - aether = spellThang.aether - break - unless aether - console.error @toString(), 'couldn\'t find a spellThang with aether of', @thangs + unless aether = @thang?.aether + console.error @toString(), 'couldn\'t find a spellThang with aether', @thang cb false if @worker workerMessage = @@ -169,9 +169,9 @@ module.exports = class Spell createAether: (thang) -> writable = @permissions.readwrite.length > 0 and not @isAISource - skipProtectAPI = @skipProtectAPI or not writable or @levelType in ['game-dev'] + skipProtectAPI = @skipProtectAPI or not writable or @level.isType('game-dev') problemContext = @createProblemContext thang - includeFlow = (@levelType in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) and not skipProtectAPI + includeFlow = @level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev') and not skipProtectAPI aetherOptions = createAetherOptions functionName: @name codeLanguage: @language @@ -190,10 +190,9 @@ module.exports = class Spell aether updateLanguageAether: (@language) -> - for thangId, spellThang of @thangs - spellThang.aether?.setLanguage @language - spellThang.castAether = null - Backbone.Mediator.publish 'tome:spell-changed-language', spell: @, language: @language + @thang?.aether?.setLanguage @language + @thang?.castAether = null + Backbone.Mediator.publish 'tome:spell-changed-language', spell: @, language: @language if @worker workerMessage = function: 'updateLanguageAether' diff --git a/app/views/play/level/tome/SpellDebugView.coffee b/app/views/play/level/tome/SpellDebugView.coffee index ddc787731..d02314259 100644 --- a/app/views/play/level/tome/SpellDebugView.coffee +++ b/app/views/play/level/tome/SpellDebugView.coffee @@ -17,7 +17,6 @@ module.exports = class SpellDebugView extends CocoView 'god:new-world-created': 'onNewWorld' 'god:debug-value-return': 'handleDebugValue' 'god:debug-world-load-progress-changed': 'handleWorldLoadProgressChanged' - 'tome:spell-shown': 'changeCurrentThangAndSpell' 'tome:cast-spells': 'onTomeCast' 'surface:frame-changed': 'onFrameChanged' 'tome:spell-has-changed-significantly-calculation': 'onSpellChangedCalculation' @@ -98,10 +97,6 @@ module.exports = class SpellDebugView extends CocoView currentObject = currentObject[key] currentObject[keys[keys.length - 1]] = value - changeCurrentThangAndSpell: (thangAndSpellObject) -> - @thang = thangAndSpellObject.thang - @spell = thangAndSpellObject.spell - handleDebugValue: (e) -> {key, value} = e @workerIsSimulating = false diff --git a/app/views/play/level/tome/SpellListEntryThangsView.coffee b/app/views/play/level/tome/SpellListEntryThangsView.coffee deleted file mode 100644 index 599cabe51..000000000 --- a/app/views/play/level/tome/SpellListEntryThangsView.coffee +++ /dev/null @@ -1,33 +0,0 @@ -CocoView = require 'views/core/CocoView' -ThangAvatarView = require 'views/play/level/ThangAvatarView' -template = require 'templates/play/level/tome/spell_list_entry_thangs' - -module.exports = class SpellListEntryThangsView extends CocoView - className: 'spell-list-entry-thangs-view' - template: template - - constructor: (options) -> - super options - @thangs = options.thangs - @thang = options.thang - @spell = options.spell - @avatars = [] - - afterRender: -> - super() - avatar.destroy() for avatar in @avatars if @avatars - @avatars = [] - spellName = @spell.name - for thang in @thangs - avatar = new ThangAvatarView thang: thang, includeName: true, supermodel: @supermodel, creator: @ - @$el.append avatar.el - avatar.render() - avatar.setSelected thang is @thang - avatar.$el.data('thang-id', thang.id).click (e) -> - Backbone.Mediator.publish 'level:select-sprite', thangID: $(@).data('thang-id'), spellName: spellName - avatar.onProblemsUpdated spell: @spell - @avatars.push avatar - - destroy: -> - avatar.destroy() for avatar in @avatars - super() diff --git a/app/views/play/level/tome/SpellListEntryView.coffee b/app/views/play/level/tome/SpellListEntryView.coffee deleted file mode 100644 index 92c5462d0..000000000 --- a/app/views/play/level/tome/SpellListEntryView.coffee +++ /dev/null @@ -1,115 +0,0 @@ -# TODO: This still needs a way to send problem states to its Thang - -CocoView = require 'views/core/CocoView' -ThangAvatarView = require 'views/play/level/ThangAvatarView' -SpellListEntryThangsView = require 'views/play/level/tome/SpellListEntryThangsView' -template = require 'templates/play/level/tome/spell_list_entry' - -module.exports = class SpellListEntryView extends CocoView - tagName: 'div' #'li' - className: 'spell-list-entry-view' - template: template - controlsEnabled: true - - subscriptions: - 'tome:problems-updated': 'onProblemsUpdated' - 'tome:spell-changed-language': 'onSpellChangedLanguage' - 'level:disable-controls': 'onDisableControls' - 'level:enable-controls': 'onEnableControls' - 'god:new-world-created': 'onNewWorld' - - events: - 'click': 'onClick' - 'mouseenter .thang-avatar-view': 'onMouseEnterAvatar' - 'mouseleave .thang-avatar-view': 'onMouseLeaveAvatar' - - constructor: (options) -> - super options - @spell = options.spell - @showTopDivider = options.showTopDivider - - getRenderData: (context={}) -> - context = super context - context.spell = @spell - context.thangNames = (thangID for thangID, spellThang of @spell.thangs when spellThang.thang.exists).join(', ') # + ', Marcus, Robert, Phoebe, Will Smith, Zap Brannigan, You, Gandaaaaalf' - context.showTopDivider = @showTopDivider - context - - getPrimarySpellThang: -> - if @lastSelectedThang - spellThang = _.find @spell.thangs, (spellThang) => spellThang.thang.id is @lastSelectedThang.id - return spellThang if spellThang - for thangID, spellThang of @spell.thangs - continue unless spellThang.thang.exists - return spellThang # Just do the first one else - - afterRender: -> - super() - return unless @options.showTopDivider # Don't repeat Thang avatars when not changed from previous entry - return @$el.hide() unless spellThang = @getPrimarySpellThang() - @$el.show() - @avatar?.destroy() - @avatar = new ThangAvatarView thang: spellThang.thang, includeName: false, supermodel: @supermodel - @$el.prepend @avatar.el # Before rendering, so render can use parent for popover - @avatar.render() - @avatar.setSharedThangs _.size @spell.thangs - @$el.addClass 'shows-top-divider' if @options.showTopDivider - - setSelected: (selected, @lastSelectedThang) -> - @avatar?.setSelected selected - - onClick: (e) -> - spellThang = @getPrimarySpellThang() - Backbone.Mediator.publish 'level:select-sprite', thangID: spellThang.thang.id, spellName: @spell.name - - onMouseEnterAvatar: (e) -> - return unless @controlsEnabled and _.size(@spell.thangs) > 1 - @showThangs() - - onMouseLeaveAvatar: (e) -> - return unless @controlsEnabled and _.size(@spell.thangs) > 1 - @hideThangsTimeout = _.delay @hideThangs, 100 - - showThangs: -> - clearTimeout @hideThangsTimeout if @hideThangsTimeout - return if @thangsView - spellThang = @getPrimarySpellThang() - return unless spellThang - @thangsView = new SpellListEntryThangsView thangs: (spellThang.thang for thangID, spellThang of @spell.thangs), thang: spellThang.thang, spell: @spell, supermodel: @supermodel - @thangsView.render() - @$el.append @thangsView.el - @thangsView.$el.mouseenter (e) => @onMouseEnterAvatar() - @thangsView.$el.mouseleave (e) => @onMouseLeaveAvatar() - - hideThangs: => - return unless @thangsView - @thangsView.off 'mouseenter mouseleave' - @thangsView.$el.remove() - @thangsView.destroy() - @thangsView = null - - onProblemsUpdated: (e) -> - return unless e.spell is @spell - @$el.toggleClass 'user-code-problem', e.problems.length - - onSpellChangedLanguage: (e) -> - return unless e.spell is @spell - @render() # So that we can update parameters if needed - - onDisableControls: (e) -> @toggleControls e, false - onEnableControls: (e) -> @toggleControls e, true - toggleControls: (e, enabled) -> - return if e.controls and not ('editor' in e.controls) - return if enabled is @controlsEnabled - @controlsEnabled = enabled - disabled = not enabled - # Should refactor the disabling list so we can target the spell list separately? - # Should not call it 'editor' any more? - @$el.toggleClass('disabled', disabled).find('*').prop('disabled', disabled) - - onNewWorld: (e) -> - @lastSelectedThang = e.world.thangMap[@lastSelectedThang.id] if @lastSelectedThang - - destroy: -> - @avatar?.destroy() - super() diff --git a/app/views/play/level/tome/SpellListView.coffee b/app/views/play/level/tome/SpellListView.coffee deleted file mode 100644 index 59a966d75..000000000 --- a/app/views/play/level/tome/SpellListView.coffee +++ /dev/null @@ -1,96 +0,0 @@ -# The SpellListView has SpellListEntryViews, which have ThangAvatarViews. -# The SpellListView serves as a dropdown triggered from a SpellListTabEntryView, which actually isn't in a list, just had a lot of similar parts. -# There is only one SpellListView, and it belongs to the TomeView. - -# TODO: showTopDivider should change when we reorder - -CocoView = require 'views/core/CocoView' -template = require 'templates/play/level/tome/spell_list' -{me} = require 'core/auth' -SpellListEntryView = require './SpellListEntryView' - -module.exports = class SpellListView extends CocoView - className: 'spell-list-view' - id: 'spell-list-view' - template: template - - subscriptions: - 'god:new-world-created': 'onNewWorld' - - constructor: (options) -> - super options - @entries = [] - @sortSpells() - - sortSpells: -> - # Keep only spells for which we have permissions - spells = _.filter @options.spells, (s) -> s.canRead() - @spells = _.sortBy spells, @sortScoreForSpell - #console.log 'Kept sorted spells', @spells - - sortScoreForSpell: (s) => - # Sort by most spells per fewest Thangs - # Lower comes first - score = 0 - # Selected spell at the top - score -= 9001900190019001 if s is @spell - # Spells for selected thang at the top - score -= 900190019001 if @thang and @thang.id of s.thangs - # Read-only spells at the bottom - score += 90019001 unless s.canWrite() - # The more Thangs sharing a spell, the lower - score += 9001 * _.size(s.thangs) - # The more spells per Thang, the higher - score -= _.filter(@spells, (s2) -> thangID of s2.thangs).length for thangID of s.thangs - score - - sortEntries: -> - # Call sortSpells before this - @entries = _.sortBy @entries, (entry) => _.indexOf @spells, entry.spell - @$el.append entry.$el for entry in @entries - - afterRender: -> - super() - @addSpellListEntries() - - addSpellListEntries: -> - newEntries = [] - lastThangs = null - for spell, index in @spells - continue if _.find @entries, spell: spell - theseThangs = _.keys(spell.thangs) - changedThangs = not lastThangs or not _.isEqual theseThangs, lastThangs - lastThangs = theseThangs - newEntries.push entry = new SpellListEntryView spell: spell, showTopDivider: changedThangs, supermodel: @supermodel, level: @options.level - @entries.push entry - for entry in newEntries - @$el.append entry.el - entry.render() # Render after appending so that we can access parent container for popover - - rerenderEntries: -> - entry.render() for entry in @entries - - onNewWorld: (e) -> - @thang = e.world.thangMap[@thang.id] if @thang - - setThangAndSpell: (@thang, @spell) -> - @entries[0]?.setSelected false - @sortSpells() - @sortEntries() - @entries[0].setSelected true, @thang - - addThang: (thang) -> - @sortSpells() - @addSpellListEntries() - - adjustSpells: (spells) -> - for entry in @entries when _.isEmpty entry.spell.thangs - entry.$el.remove() - entry.destroy() - @spells = @options.spells = spells - @sortSpells() - @addSpellListEntries() - - destroy: -> - entry.destroy() for entry in @entries - super() diff --git a/app/views/play/level/tome/SpellPaletteEntryView.coffee b/app/views/play/level/tome/SpellPaletteEntryView.coffee index cbada6610..1b400b673 100644 --- a/app/views/play/level/tome/SpellPaletteEntryView.coffee +++ b/app/views/play/level/tome/SpellPaletteEntryView.coffee @@ -33,7 +33,7 @@ module.exports = class SpellPaletteEntryView extends CocoView afterRender: -> super() - @$el.addClass(@doc.type) + @$el.addClass _.string.slugify @doc.type placement = -> if $('body').hasClass('dialogue-view-active') then 'top' else 'left' @$el.popover( animation: false @@ -53,6 +53,7 @@ module.exports = class SpellPaletteEntryView extends CocoView oldEditor.destroy() for oldEditor in @aceEditors @aceEditors = [] aceEditors = @aceEditors + # Initialize Ace for each popover code snippet popover?.$tip?.find('.docs-ace').each -> aceEditor = utils.initializeACE @, codeLanguage aceEditors.push aceEditor @@ -84,7 +85,7 @@ module.exports = class SpellPaletteEntryView extends CocoView Backbone.Mediator.publish 'tome:palette-pin-toggled', entry: @, pinned: @popoverPinned onClick: (e) => - if true or @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] + if true or @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev') # Jiggle instead of pin for hero levels # Actually, do it all the time, because we recently busted the pin CSS. TODO: restore pinning jigglyPopover = $('.spell-palette-popover.popover') diff --git a/app/views/play/level/tome/SpellPaletteView.coffee b/app/views/play/level/tome/SpellPaletteView.coffee index 87f7b21f4..e8d4a6405 100644 --- a/app/views/play/level/tome/SpellPaletteView.coffee +++ b/app/views/play/level/tome/SpellPaletteView.coffee @@ -87,6 +87,7 @@ module.exports = class SpellPaletteView extends CocoView entry.$el.addClass 'first-entry' @$el.addClass 'hero' @$el.toggleClass 'shortenize', Boolean @shortenize + @$el.toggleClass 'web-dev', @options.level.isType('web-dev') @updateMaxHeight() unless application.isIPadApp afterInsert: -> @@ -100,7 +101,9 @@ module.exports = class SpellPaletteView extends CocoView return unless @isHero # We figure out how many columns we can fit, width-wise, and then guess how many rows will be needed. # We can then assign a height based on the number of rows, and the flex layout will do the rest. - columnWidth = if @shortenize then 175 else 212 + columnWidth = 212 + columnWidth = 175 if @shortenize + columnWidth = 100 if @options.level.isType('web-dev') nColumns = Math.floor @$el.find('.properties-this').innerWidth() / columnWidth # will always have 2 columns, since at 1024px screen we have 424px .properties columns = ({items: [], nEntries: 0} for i in [0 ... nColumns]) orderedColumns = [] @@ -153,11 +156,13 @@ module.exports = class SpellPaletteView extends CocoView JSON: 'programmableJSONProperties' LoDash: 'programmableLoDashProperties' Vector: 'programmableVectorProperties' + HTML: 'programmableHTMLProperties' + CSS: 'programmableCSSProperties' snippets: 'programmableSnippets' else propStorage = 'this': ['apiProperties', 'apiMethods'] - if not (@options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) or not @options.programmable + if not @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') or not @options.programmable @organizePalette propStorage, allDocs, excludedDocs else @organizePaletteHero propStorage, allDocs, excludedDocs @@ -192,14 +197,14 @@ module.exports = class SpellPaletteView extends CocoView return 'more' if entry.doc.owner is 'this' and entry.doc.name in (propGroups.more ? []) entry.doc.owner @entries = _.sortBy @entries, (entry) -> - order = ['this', 'more', 'Math', 'Vector', 'String', 'Object', 'Array', 'Function', 'snippets'] + order = ['this', 'more', 'Math', 'Vector', 'String', 'Object', 'Array', 'Function', 'HTML', 'CSS', 'snippets'] index = order.indexOf groupForEntry entry index = String.fromCharCode if index is -1 then order.length else index index += entry.doc.name if tabbify and _.find @entries, ((entry) -> entry.doc.owner isnt 'this') @entryGroups = _.groupBy @entries, groupForEntry else - i18nKey = if @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] then 'play_level.tome_your_skills' else 'play_level.tome_available_spells' + i18nKey = if @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') then 'play_level.tome_your_skills' else 'play_level.tome_available_spells' defaultGroup = $.i18n.t i18nKey @entryGroups = {} @entryGroups[defaultGroup] = @entries @@ -243,7 +248,7 @@ module.exports = class SpellPaletteView extends CocoView console.log @thang.id, "couldn't find item ThangType for", slot, thangTypeName # Get any Math-, Vector-, etc.-owned properties into their own tabs - for owner, storage of propStorage when not (owner in ['this', 'more', 'snippets']) + for owner, storage of propStorage when not (owner in ['this', 'more', 'snippets', 'HTML', 'CSS']) continue unless @thang[storage]?.length @tabs ?= {} @tabs[owner] = [] @@ -257,7 +262,7 @@ module.exports = class SpellPaletteView extends CocoView # Assign any unassigned properties to the hero itself. for owner, storage of propStorage - continue unless owner in ['this', 'more', 'snippets'] + continue unless owner in ['this', 'more', 'snippets', 'HTML', 'CSS'] for prop in _.reject(@thang[storage] ? [], (prop) -> itemsByProp[prop] or prop[0] is '_') # no private properties continue if prop is 'say' and @options.level.get 'hidesSay' # Hide for Dungeon Campaign continue if prop is 'moveXY' and @options.level.get('slug') is 'slalom' # Hide for Slalom @@ -282,7 +287,10 @@ module.exports = class SpellPaletteView extends CocoView doc ?= prop if doc @entries.push @addEntry(doc, @shortenize, owner is 'snippets', item, propIndex > 0) - @entryGroups = _.groupBy @entries, (entry) -> itemsByProp[entry.doc.name]?.get('name') ? 'Hero' + if @options.level.isType('web-dev') + @entryGroups = _.groupBy @entries, (entry) -> entry.doc.type + else + @entryGroups = _.groupBy @entries, (entry) -> itemsByProp[entry.doc.name]?.get('name') ? 'Hero' iOSEntryGroups = {} for group, entries of @entryGroups iOSEntryGroups[group] = diff --git a/app/views/play/level/tome/SpellListTabEntryView.coffee b/app/views/play/level/tome/SpellTopBarView.coffee similarity index 54% rename from app/views/play/level/tome/SpellListTabEntryView.coffee rename to app/views/play/level/tome/SpellTopBarView.coffee index b15798bbb..7c8051f86 100644 --- a/app/views/play/level/tome/SpellListTabEntryView.coffee +++ b/app/views/play/level/tome/SpellTopBarView.coffee @@ -1,32 +1,31 @@ -SpellListEntryView = require './SpellListEntryView' -ThangAvatarView = require 'views/play/level/ThangAvatarView' -template = require 'templates/play/level/tome/spell_list_tab_entry' -LevelComponent = require 'models/LevelComponent' -DocFormatter = require './DocFormatter' +template = require 'templates/play/level/tome/spell-top-bar-view' ReloadLevelModal = require 'views/play/level/modal/ReloadLevelModal' +CocoView = require 'views/core/CocoView' +ImageGalleryModal = require 'views/play/level/modal/ImageGalleryModal' -module.exports = class SpellListTabEntryView extends SpellListEntryView +module.exports = class SpellTopBarView extends CocoView template: template - id: 'spell-list-tab-entry-view' + id: 'spell-top-bar-view' + controlsEnabled: true subscriptions: 'level:disable-controls': 'onDisableControls' 'level:enable-controls': 'onEnableControls' 'tome:spell-loaded': 'onSpellLoaded' 'tome:spell-changed': 'onSpellChanged' - 'god:new-world-created': 'onNewWorld' 'tome:spell-changed-language': 'onSpellChangedLanguage' 'tome:toggle-maximize': 'onToggleMaximize' events: - 'click .spell-list-button': 'onDropdownClick' 'click .reload-code': 'onCodeReload' 'click .beautify-code': 'onBeautifyClick' 'click .fullscreen-code': 'onToggleMaximize' 'click .hints-button': 'onClickHintsButton' + 'click .image-gallery-button': 'onClickImageGalleryButton' constructor: (options) -> @hintsState = options.hintsState + @spell = options.spell super(options) getRenderData: (context={}) -> @@ -35,9 +34,7 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView shift = $.i18n.t 'keyboard_shortcuts.shift' context.beautifyShortcutVerbose = "#{ctrl}+#{shift}+B: #{$.i18n.t 'keyboard_shortcuts.beautify'}" context.maximizeShortcutVerbose = "#{ctrl}+#{shift}+M: #{$.i18n.t 'keyboard_shortcuts.maximize_editor'}" - context.includeSpellList = @options.level.get('slug') in ['break-the-prison', 'zone-of-danger', 'k-means-cluster-wars', 'brawlwood', 'dungeon-arena', 'sky-span', 'minimax-tic-tac-toe'] context.codeLanguage = @options.codeLanguage - context.levelType = @options.level.get 'type', true context afterRender: -> @@ -45,68 +42,19 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView @$el.addClass 'spell-tab' @attachTransitionEventListener() - onNewWorld: (e) -> - @thang = e.world.thangMap[@thang.id] if @thang - - setThang: (thang) -> - return if thang.id is @thang?.id - @thang = thang - @spellThang = @spell.thangs[@thang.id] - @buildAvatar() - @buildDocs() unless @docsBuilt - - buildAvatar: -> - avatar = new ThangAvatarView thang: @thang, includeName: false, supermodel: @supermodel - if @avatar - @avatar.$el.replaceWith avatar.$el - @avatar.destroy() - else - @$el.find('.thang-avatar-placeholder').replaceWith avatar.$el - @avatar = avatar - @avatar.render() - - buildDocs: -> - return if @spell.name is 'plan' # Too confusing for beginners - @docsBuilt = true - lcs = @supermodel.getModels LevelComponent - found = false - for lc in lcs when not found - for doc in lc.get('propertyDocumentation') ? [] - if doc.name is @spell.name - found = true - break - return unless found - docFormatter = new DocFormatter doc: doc, thang: @thang, language: @options.codeLanguage, selectedMethod: true - @$el.find('.method-signature').popover( - animation: true - html: true - placement: 'bottom' - trigger: 'hover' - content: docFormatter.formatPopover() - container: @$el.parent() - ).on 'show.bs.popover', => - @playSound 'spell-tab-entry-open', 0.75 - - onMouseEnterAvatar: (e) -> # Don't call super - onMouseLeaveAvatar: (e) -> # Don't call super - onClick: (e) -> # Don't call super onDisableControls: (e) -> @toggleControls e, false onEnableControls: (e) -> @toggleControls e, true + onClickImageGalleryButton: (e) -> + @openModalView new ImageGalleryModal() + onClickHintsButton: -> return unless @hintsState? @hintsState.set('hidden', not @hintsState.get('hidden')) window.tracker?.trackEvent 'Hints Clicked', category: 'Students', levelSlug: @options.level.get('slug'), hintCount: @hintsState.get('hints')?.length ? 0, ['Mixpanel'] - onDropdownClick: (e) -> - return unless @controlsEnabled - Backbone.Mediator.publish 'tome:toggle-spell-list', {} - @playSound 'spell-list-open' - onCodeReload: (e) -> - #return unless @controlsEnabled - #Backbone.Mediator.publish 'tome:reload-code', spell: @spell # Old: just reload the current code - @openModalView new ReloadLevelModal() # New: prompt them to restart the level + @openModalView new ReloadLevelModal() onBeautifyClick: (e) -> return unless @controlsEnabled @@ -134,14 +82,10 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView onSpellChangedLanguage: (e) -> return unless e.spell is @spell @options.codeLanguage = e.language - @$el.find('.method-signature').popover 'destroy' @render() - @docsBuilt = false - @buildDocs() if @thang @updateReloadButton() toggleControls: (e, enabled) -> - # Don't call super; do it differently return if e.controls and not ('editor' in e.controls) return if enabled is @controlsEnabled @controlsEnabled = enabled @@ -163,8 +107,5 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView $codearea.on transitionListener, => $codearea.css 'z-index', 2 unless $('html').hasClass 'fullscreen-editor' - destroy: -> - @avatar?.destroy() - @$el.find('.method-signature').popover 'destroy' super() diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee index c3e2b7e14..f288a12d1 100644 --- a/app/views/play/level/tome/SpellView.coffee +++ b/app/views/play/level/tome/SpellView.coffee @@ -21,6 +21,7 @@ module.exports = class SpellView extends CocoView controlsEnabled: true eventsSuppressed: true writable: true + languagesThatUseWorkers: ['html'] keyBindings: 'default': null @@ -61,7 +62,6 @@ module.exports = class SpellView extends CocoView @supermodel = options.supermodel @worker = options.worker @session = options.session - @listenTo(@session, 'change:multiplayer', @onMultiplayerChanged) @spell = options.spell @problems = [] @savedProblems = {} # Cache saved user code problems to prevent duplicates @@ -77,12 +77,9 @@ module.exports = class SpellView extends CocoView @fillACE() @createOnCodeChangeHandlers() @lockDefaultCode() - if @session.get('multiplayer') - @createFirepad() - else - # needs to happen after the code generating this view is complete - _.defer @onAllLoaded + _.defer @onAllLoaded # Needs to happen after the code generating this view is complete + # This ACE is used for the code editor, and is only instantiated once per level. createACE: -> # Test themes and settings here: http://ace.ajax.org/build/kitchen-sink.html aceConfig = me.get('aceConfig') ? {} @@ -90,7 +87,7 @@ module.exports = class SpellView extends CocoView @ace = ace.edit @$el.find('.ace')[0] @aceSession = @ace.getSession() @aceDoc = @aceSession.getDocument() - @aceSession.setUseWorker false + @aceSession.setUseWorker @spell.language in @languagesThatUseWorkers @aceSession.setMode utils.aceEditModes[@spell.language] @aceSession.setWrapLimitRange null @aceSession.setUseWrapMode true @@ -231,7 +228,7 @@ module.exports = class SpellView extends CocoView disableSpaces = @options.level.get('disableSpaces') or false aceConfig = me.get('aceConfig') ? {} disableSpaces = false if aceConfig.keyBindings and aceConfig.keyBindings isnt 'default' # Not in vim/emacs mode - disableSpaces = false if @spell.language in ['lua', 'java', 'coffeescript'] # Don't disable for more advanced/experimental languages + disableSpaces = false if @spell.language in ['lua', 'java', 'coffeescript', 'html'] # Don't disable for more advanced/experimental languages if not disableSpaces or (_.isNumber(disableSpaces) and disableSpaces < me.level()) return @ace.execCommand 'insertstring', ' ' line = @aceDoc.getLine @ace.getCursorPosition().row @@ -402,7 +399,6 @@ module.exports = class SpellView extends CocoView wrapper => orig.apply obj, args obj[method] - finishRange = (row, startRow, startColumn) => range = new Range startRow, startColumn, row, @aceSession.getLine(row).length - 1 range.start = @aceDoc.createAnchor range.start @@ -476,6 +472,7 @@ module.exports = class SpellView extends CocoView # TODO: Turn on more autocompletion based on level sophistication # TODO: E.g. using the language default snippets yields a bunch of crazy non-beginner suggestions # TODO: Options logic shouldn't exist both here and in updateAutocomplete() + return if @spell.language is 'html' popupFontSizePx = @options.level.get('autocompleteFontSizePx') ? 16 @zatanna = new Zatanna @ace, basic: false @@ -502,8 +499,6 @@ module.exports = class SpellView extends CocoView return unless @zatanna and @autocomplete @zatanna.addCodeCombatSnippets @options.level, @, e - - translateFindNearest: -> # If they have advanced glasses but are playing a level which assumes earlier glasses, we'll adjust the sample code to use the more advanced APIs instead. oldSource = @getSource() @@ -514,14 +509,9 @@ module.exports = class SpellView extends CocoView @updateACEText newSource _.delay (=> @recompile?()), 1000 - onMultiplayerChanged: -> - if @session.get('multiplayer') - @createFirepad() - else - @firepad?.dispose() - createFirepad: -> - # load from firebase or the original source if there's nothing there + # Currently not called; could be brought back for future multiplayer modes. + # Load from firebase or the original source if there's nothing there. return if @firepadLoading @eventsSuppressed = true @loaded = false @@ -532,19 +522,18 @@ module.exports = class SpellView extends CocoView @fireRef = new Firebase fireURL firepadOptions = userId: me.id @firepad = Firepad.fromACE @fireRef, @ace, firepadOptions - @firepad.on 'ready', @onFirepadLoaded @firepadLoading = true - - onFirepadLoaded: => - @firepadLoading = false - firepadSource = @ace.getValue() - if firepadSource - @spell.source = firepadSource - else - @ace.setValue @previousSource - @aceSession.setUndoManager(new UndoManager()) - @ace.clearSelection() - @onAllLoaded() + @firepad.on 'ready', => + return if @destroyed + @firepadLoading = false + firepadSource = @ace.getValue() + if firepadSource + @spell.source = firepadSource + else + @ace.setValue @previousSource + @aceSession.setUndoManager(new UndoManager()) + @ace.clearSelection() + @onAllLoaded() onAllLoaded: => @spell.transpile @spell.source @@ -552,9 +541,10 @@ module.exports = class SpellView extends CocoView Backbone.Mediator.publish 'tome:spell-loaded', spell: @spell @eventsSuppressed = false # Now that the initial change is in, we can start running any changed code @createToolbarView() + @updateHTML create: true if @options.level.isType('web-dev') createDebugView: -> - return if @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] # We'll turn this on later, maybe, but not yet. + return if @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') # We'll turn this on later, maybe, but not yet. @debugView = new SpellDebugView ace: @ace, thang: @thang, spell:@spell @$el.append @debugView.render().$el.hide() @@ -573,7 +563,7 @@ module.exports = class SpellView extends CocoView @saveSpade() getSource: -> - @ace.getValue() # could also do @firepad.getText() + @ace.getValue() setThang: (thang) -> @focus() @@ -581,7 +571,7 @@ module.exports = class SpellView extends CocoView @updateLines() return if thang.id is @thang?.id @thang = thang - @spellThang = @spell.thangs[@thang.id] + @spellThang = @spell.thang @createDebugView() unless @debugView @debugView?.thang = @thang @createTranslationView() unless @translationView @@ -624,11 +614,11 @@ module.exports = class SpellView extends CocoView lineHeight = @ace.renderer.lineHeight or 20 tomeHeight = $('#tome-view').innerHeight() spellPaletteView = $('#spell-palette-view') - spellListTabEntryHeight = $('#spell-list-tab-entry-view').outerHeight() + spellTopBarHeight = $('#spell-top-bar-view').outerHeight() spellToolbarHeight = $('.spell-toolbar-view').outerHeight() @spellPaletteHeight ?= spellPaletteView.outerHeight() # Remember this until resize, since we change it afterward spellPaletteAllowedHeight = Math.min @spellPaletteHeight, tomeHeight / 3 - maxHeight = tomeHeight - spellListTabEntryHeight - spellToolbarHeight - spellPaletteAllowedHeight + maxHeight = tomeHeight - spellTopBarHeight - spellToolbarHeight - spellPaletteAllowedHeight linesAtMaxHeight = Math.floor(maxHeight / lineHeight) lines = Math.max 8, Math.min(screenLineCount + 2, linesAtMaxHeight) # 2 lines buffer is nice @@ -675,6 +665,7 @@ module.exports = class SpellView extends CocoView cast = @$el.parent().length @recompile cast, e.realTime @focus() if cast + @updateHTML create: true if @options.level.isType('web-dev') onCodeReload: (e) -> return unless e.spell is @spell or not e.spell @@ -725,6 +716,8 @@ module.exports = class SpellView extends CocoView ] onSignificantChange.push _.debounce @checkRequiredCode, 750 if @options.level.get 'requiredCode' onSignificantChange.push _.debounce @checkSuspectCode, 750 if @options.level.get 'suspectCode' + onAnyChange.push _.throttle @updateHTML, 10 if @options.level.isType('web-dev') + @onCodeChangeMetaHandler = => return if @eventsSuppressed #@playSound 'code-change', volume: 0.5 # Currently not using this sound. @@ -737,6 +730,9 @@ module.exports = class SpellView extends CocoView onCursorActivity: => # Used to refresh autocast delay; doesn't do anything at the moment. + updateHTML: (options={}) => + Backbone.Mediator.publish 'tome:html-updated', html: @spell.constructHTML(@getSource()), create: Boolean(options.create) + # Design for a simpler system? # * Keep Aether linting, debounced, on any significant change # - All problems just vanish when you make any change to the code @@ -795,10 +791,12 @@ module.exports = class SpellView extends CocoView else finishUpdatingAether(aether) + # Clear annotations and highlights generated by Aether, but not by the ACE worker clearAetherDisplay: -> problem.destroy() for problem in @problems @problems = [] - @aceSession.setAnnotations [] + nonAetherAnnotations = _.reject @aceSession.getAnnotations(), (annotation) -> annotation.createdBy is 'aether' + @aceSession.setAnnotations nonAetherAnnotations @highlightCurrentLine {} # This'll remove all highlights displayAether: (aether, isCast=false) -> @@ -806,12 +804,12 @@ module.exports = class SpellView extends CocoView isCast = isCast or not _.isEmpty(aether.metrics) or _.some aether.getAllProblems(), {type: 'runtime'} problem.destroy() for problem in @problems # Just in case another problem was added since clearAetherDisplay() ran. @problems = [] - annotations = [] + annotations = @aceSession.getAnnotations() seenProblemKeys = {} for aetherProblem, problemIndex in aether.getAllProblems() continue if key = aetherProblem.userInfo?.key and key of seenProblemKeys seenProblemKeys[key] = true if key - @problems.push problem = new Problem aether, aetherProblem, @ace, isCast, @spell.levelID + @problems.push problem = new Problem aether, aetherProblem, @ace, isCast, @options.levelID if isCast and problemIndex is 0 if problem.aetherProblem.range? lineOffsetPx = 0 @@ -859,7 +857,7 @@ module.exports = class SpellView extends CocoView @userCodeProblem.set 'errRange', aetherProblem.range if aetherProblem.range @userCodeProblem.set 'errType', aetherProblem.type if aetherProblem.type @userCodeProblem.set 'language', aether.language.id if aether.language?.id - @userCodeProblem.set 'levelID', @spell.levelID if @spell.levelID + @userCodeProblem.set 'levelID', @options.levelID if @options.levelID @userCodeProblem.save() null @@ -907,16 +905,14 @@ module.exports = class SpellView extends CocoView return if @spell.source.indexOf('while') isnt -1 # If they're working with while-loops, it's more likely to be an incomplete infinite loop, so don't preload. return if @spell.source.length > 500 # Only preload on really short methods return if @spellThang?.castAether?.metrics?.statementsExecuted > 2000 # Don't preload if they are running significant amounts of user code + return if @options.level.isType('web-dev') oldSource = @spell.source - oldSpellThangAethers = {} - for thangID, spellThang of @spell.thangs - oldSpellThangAethers[thangID] = spellThang.aether.serialize() # Get raw, pure, and problems + oldSpellThangAether = @spell.thang?.aether.serialize() @spell.transpile @getSource() @cast true @spell.source = oldSource - for thangID, spellThang of @spell.thangs - for key, value of oldSpellThangAethers[thangID] - spellThang.aether[key] = value + for key, value of oldSpellThangAether + @spell.thang.aether[key] = value onSpellChanged: (e) -> @spellHasChanged = true @@ -933,10 +929,10 @@ module.exports = class SpellView extends CocoView return unless e.god is @options.god return @onInfiniteLoop e if e.problem.id is 'runtime_InfiniteLoop' return unless e.problem.userInfo.methodName is @spell.name - return unless spellThang = _.find @spell.thangs, (spellThang, thangID) -> thangID is e.problem.userInfo.thangID + return unless @spell.thang?.thang.id is e.problem.userInfo.thangID @spell.hasChangedSignificantly @getSource(), null, (hasChanged) => return if hasChanged - spellThang.aether.addProblem e.problem + @spell.thang.aether.addProblem e.problem @lastUpdatedAetherSpellThang = null # force a refresh without a re-transpile @updateAether false, false @@ -957,13 +953,14 @@ module.exports = class SpellView extends CocoView @updateAether false, false onNewWorld: (e) -> - @spell.removeThangID thangID for thangID of @spell.thangs when not e.world.getThangByID thangID - for thangID, spellThang of @spell.thangs - thang = e.world.getThangByID(thangID) - aether = e.world.userCodeMap[thangID]?[@spell.name] # Might not be there if this is a new Programmable Thang. - spellThang.castAether = aether - spellThang.aether = @spell.createAether thang - #console.log thangID, @spell.spellKey, 'ran', aether.metrics.callsExecuted, 'times over', aether.metrics.statementsExecuted, 'statements, with max recursion depth', aether.metrics.maxDepth, 'and full flow/metrics', aether.metrics, aether.flow + if thang = e.world.getThangByID @spell.thang?.thang.id + aether = e.world.userCodeMap[thang.id]?[@spell.name] + @spell.thang.castAether = aether + @spell.thang.aether = @spell.createAether thang + #console.log thang.id, @spell.spellKey, 'ran', aether.metrics.callsExecuted, 'times over', aether.metrics.statementsExecuted, 'statements, with max recursion depth', aether.metrics.maxDepth, 'and full flow/metrics', aether.metrics, aether.flow + else + @spell.thang = null + @spell.transpile() # TODO: is there any way we can avoid doing this if it hasn't changed? Causes a slight hang. @updateAether false, false @@ -990,8 +987,6 @@ module.exports = class SpellView extends CocoView @ace.insert "{x=#{e.x}, y=#{e.y}}" else @ace.insert "{x: #{e.x}, y: #{e.y}}" - - @highlightCurrentLine() onStatementIndexUpdated: (e) -> @@ -1156,7 +1151,7 @@ module.exports = class SpellView extends CocoView toggleBackground: => # TODO: make the background an actual background and do the CSS trick - # used in spell_list_entry.sass for disabling + # used in spell-top-bar-view.sass for disabling background = @$el.find('img.code-background')[0] if background.naturalWidth is 0 # not loaded yet return _.delay @toggleBackground, 100 @@ -1260,7 +1255,7 @@ module.exports = class SpellView extends CocoView @debugView?.destroy() @translationView?.destroy() @toolbarView?.destroy() - @zatanna.addSnippets [], @editorLang if @editorLang? + @zatanna?.addSnippets [], @editorLang if @editorLang? $(window).off 'resize', @onWindowResize window.clearTimeout @saveSpadeTimeout @saveSpadeTimeout = null diff --git a/app/views/play/level/tome/TomeView.coffee b/app/views/play/level/tome/TomeView.coffee index 442b8cf4b..d35bbbb4c 100644 --- a/app/views/play/level/tome/TomeView.coffee +++ b/app/views/play/level/tome/TomeView.coffee @@ -2,36 +2,26 @@ # - a CastButtonView, which has # - a cast button # - a submit/done button -# - for each spell (programmableMethod): +# - for each spell (programmableMethod) (which is now just always only 'plan') # - a Spell, which has -# - a list of Thangs that share that Spell, with one aether per Thang per Spell +# - a Thang that uses that Spell, with an aether and a castAether # - a SpellView, which has # - tons of stuff; the meat -# - a SpellListView, which has -# - for each spell: -# - a SpellListEntryView, which has -# - icons for each Thang -# - the spell name -# - a reload button -# - documentation for that method (in a popover) +# - a SpellTopBarView, which has some controls # - a SpellPaletteView, which has # - for each programmableProperty: # - a SpellPaletteEntryView # -# The CastButtonView and SpellListView always show. +# The CastButtonView always shows. # The SpellPaletteView shows the entries for the currently selected Programmable Thang. # The SpellView shows the code and runtime state for the currently selected Spell and, specifically, Thang. -# The SpellView obscures most of the SpellListView when present. We might mess with this. # You can switch a SpellView to showing the runtime state of another Thang sharing that Spell. # SpellPaletteViews are destroyed and recreated whenever you switch Thangs. -# The SpellListView shows spells to which your team has read or readwrite access. -# It doubles as a Thang selector, since it's there when nothing is selected. CocoView = require 'views/core/CocoView' template = require 'templates/play/level/tome/tome' {me} = require 'core/auth' Spell = require './Spell' -SpellListView = require './SpellListView' SpellPaletteView = require './SpellPaletteView' CastButtonView = require './CastButtonView' @@ -44,7 +34,6 @@ module.exports = class TomeView extends CocoView subscriptions: 'tome:spell-loaded': 'onSpellLoaded' 'tome:cast-spell': 'onCastSpell' - 'tome:toggle-spell-list': 'onToggleSpellList' 'tome:change-language': 'updateLanguageForAllSpells' 'surface:sprite-selected': 'onSpriteSelected' 'god:new-world-created': 'onNewWorld' @@ -52,16 +41,16 @@ module.exports = class TomeView extends CocoView 'tome:select-primary-sprite': 'onSelectPrimarySprite' events: - 'click #spell-view': 'onSpellViewClick' 'click': 'onClick' afterRender: -> super() @worker = @createWorker() programmableThangs = _.filter @options.thangs, (t) -> t.isProgrammable and t.programmableMethods - @createSpells programmableThangs, programmableThangs[0]?.world # Do before spellList, thangList, and castButton - unless @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] - @spellList = @insertSubView new SpellListView spells: @spells, supermodel: @supermodel, level: @options.level + if @options.level.isType('web-dev') + if @fakeProgrammableThang = @createFakeProgrammableThang() + programmableThangs = [@fakeProgrammableThang] + @createSpells programmableThangs, programmableThangs[0]?.world # Do before castButton @castButton = @insertSubView new CastButtonView spells: @spells, level: @options.level, session: @options.session, god: @options.god @teamSpellMap = @generateTeamSpellMap(@spells) unless programmableThangs.length @@ -72,10 +61,8 @@ module.exports = class TomeView extends CocoView delete @options.thangs onNewWorld: (e) -> - thangs = _.filter e.world.thangs, 'inThangList' - programmableThangs = _.filter thangs, (t) -> t.isProgrammable and t.programmableMethods + programmableThangs = _.filter e.thangs, (t) -> t.isProgrammable and t.programmableMethods and t.inThangList @createSpells programmableThangs, e.world - @spellList?.adjustSpells @spells onCommentMyCode: (e) -> for spellKey, spell of @spells when spell.canWrite() @@ -114,33 +101,31 @@ module.exports = class TomeView extends CocoView @thangSpells[thang.id] = [] for methodName, method of thang.programmableMethods pathComponents = [thang.id, methodName] - if method.cloneOf - pathComponents[0] = method.cloneOf # referencing another Thang's method pathComponents[0] = _.string.slugify pathComponents[0] spellKey = pathComponents.join '/' @thangSpells[thang.id].push spellKey - unless method.cloneOf - skipProtectAPI = @getQueryVariable 'skip_protect_api', (@options.levelID in ['gridmancer', 'minimax-tic-tac-toe']) - spell = @spells[spellKey] = new Spell - hintsState: @options.hintsState - programmableMethod: method - spellKey: spellKey - pathComponents: pathPrefixComponents.concat(pathComponents) - session: @options.session - otherSession: @options.otherSession - supermodel: @supermodel - skipProtectAPI: skipProtectAPI - worker: @worker - language: language - spectateView: @options.spectateView - spectateOpponentCodeLanguage: @options.spectateOpponentCodeLanguage - observing: @options.observing - levelID: @options.levelID - level: @options.level - god: @options.god + skipProtectAPI = @getQueryVariable 'skip_protect_api', false + spell = @spells[spellKey] = new Spell + hintsState: @options.hintsState + programmableMethod: method + spellKey: spellKey + pathComponents: pathPrefixComponents.concat(pathComponents) + session: @options.session + otherSession: @options.otherSession + supermodel: @supermodel + skipProtectAPI: skipProtectAPI + worker: @worker + language: language + spectateView: @options.spectateView + spectateOpponentCodeLanguage: @options.spectateOpponentCodeLanguage + observing: @options.observing + levelID: @options.levelID + level: @options.level + god: @options.god + courseID: @options.courseID for thangID, spellKeys of @thangSpells - thang = world.getThangByID thangID + thang = @fakeProgrammableThang ? world.getThangByID thangID if thang @spells[spellKey].addThang thang for spellKey in spellKeys else @@ -161,6 +146,7 @@ module.exports = class TomeView extends CocoView @cast e?.preload, e?.realTime cast: (preload=false, realTime=false) -> + return if @options.level.isType('web-dev') sessionState = @options.session.get('state') ? {} if realTime sessionState.submissionCount = (sessionState.submissionCount ? 0) + 1 @@ -172,53 +158,27 @@ module.exports = class TomeView extends CocoView difficulty = Math.max 0, difficulty - 1 # Show the difficulty they won, not the next one. Backbone.Mediator.publish 'tome:cast-spells', spells: @spells, preload: preload, realTime: realTime, submissionCount: sessionState.submissionCount ? 0, flagHistory: sessionState.flagHistory ? [], difficulty: difficulty, god: @options.god, fixedSeed: @options.fixedSeed - onToggleSpellList: (e) -> - @spellList?.rerenderEntries() - @spellList?.$el.toggle() - - onSpellViewClick: (e) -> - @spellList?.$el.hide() - onClick: (e) -> Backbone.Mediator.publish 'tome:focus-editor', {} unless $(e.target).parents('.popover').length - clearSpellView: -> - @spellView?.dismiss() - @spellView?.$el.after('
    ').detach() - @spellView = null - @spellTabView?.$el.after('
    ').detach() - @spellTabView = null - @removeSubView @spellPaletteView if @spellPaletteView - @spellPaletteView = null - @$el.find('#spell-palette-view').hide() - @castButton?.$el.hide() - onSpriteSelected: (e) -> - return if @spellView and @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] # Never deselect the hero in the Tome. - thang = e.thang - spellName = e.spellName - @spellList?.$el.hide() - return @clearSpellView() unless thang - spell = @spellFor thang, spellName - unless spell?.canRead() - @clearSpellView() - @updateSpellPalette thang, spell if spell - return + return if @spellView and @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev'] # Never deselect the hero in the Tome. + spell = @spellFor e.thang, e.spellName + if spell?.canRead() + @setSpellView spell, e.thang + + setSpellView: (spell, thang) -> unless spell.view is @spellView - @clearSpellView() @spellView = spell.view - @spellTabView = spell.tabView + @spellTopBarView = spell.topBarView @$el.find('#' + @spellView.id).after(@spellView.el).remove() - @$el.find('#' + @spellTabView.id).after(@spellTabView.el).remove() + @$el.find('#' + @spellTopBarView.id).after(@spellTopBarView.el).remove() @castButton?.attachTo @spellView - Backbone.Mediator.publish 'tome:spell-shown', thang: thang, spell: spell @updateSpellPalette thang, spell - @spellList?.setThangAndSpell thang, spell @spellView?.setThang thang - @spellTabView?.setThang thang updateSpellPalette: (thang, spell) -> - return unless thang and @spellPaletteView?.thang isnt thang and thang.programmableProperties or thang.apiProperties + return unless thang and @spellPaletteView?.thang isnt thang and (thang.programmableProperties or thang.apiProperties or thang.programmableHTMLProperties) useHero = /hero/.test(spell.getSource()) or not /(self[\.\:]|this\.|\@)/.test(spell.getSource()) @spellPaletteView = @insertSubView new SpellPaletteView { thang, @supermodel, programmable: spell?.canRead(), language: spell?.language ? @options.session.get('codeLanguage'), session: @options.session, level: @options.level, courseID: @options.courseID, courseInstanceID: @options.courseInstanceID, useHero } @spellPaletteView.toggleControls {}, spell.view.controlsEnabled if spell?.view # TODO: know when palette should have been disabled but didn't exist @@ -246,13 +206,26 @@ module.exports = class TomeView extends CocoView @cast() onSelectPrimarySprite: (e) -> - # This is only fired by PlayLevelView for hero levels currently - # TODO: Don't hard code these hero names + if @options.level.isType('web-dev') + @setSpellView @spells['hero-placeholder/plan'], @fakeProgrammableThang + return + # This is fired by PlayLevelView if @options.session.get('team') is 'ogres' Backbone.Mediator.publish 'level:select-sprite', thangID: 'Hero Placeholder 1' else Backbone.Mediator.publish 'level:select-sprite', thangID: 'Hero Placeholder' + createFakeProgrammableThang: -> + return null unless hero = _.find @options.level.get('thangs'), id: 'Hero Placeholder' + return null unless programmableConfig = _.find(hero.components, (component) -> component.config?.programmableMethods).config + usesHTMLConfig = _.find(hero.components, (component) -> component.config?.programmableHTMLProperties).config + console.warn "Couldn't find usesHTML config; is it presented and not defaulted on the Hero Placeholder?" unless usesHTMLConfig + thang = + id: 'Hero Placeholder' + isProgrammable: true + thang = _.merge thang, programmableConfig, usesHTMLConfig + thang + destroy: -> spell.destroy() for spellKey, spell of @spells @worker?.terminate() diff --git a/app/views/play/level/tome/editor/zatanna.coffee b/app/views/play/level/tome/editor/zatanna.coffee index 81d2ef5d6..57907b17e 100644 --- a/app/views/play/level/tome/editor/zatanna.coffee +++ b/app/views/play/level/tome/editor/zatanna.coffee @@ -1,6 +1,6 @@ utils = require 'core/utils' -defaults = +defaults = autoLineEndings: # Mapping ace mode language to line endings to automatically insert # E.g. javascript: ";" @@ -69,7 +69,7 @@ module.exports = class Zatanna @editor.commands.on 'afterExec', @doLiveCompletion setAceOptions: () -> - aceOptions = + aceOptions = 'enableLiveAutocompletion': @options.liveCompletion 'enableBasicAutocompletion': @options.basic 'enableSnippets': @options.completers.snippets @@ -92,20 +92,20 @@ module.exports = class Zatanna else if typeof comp is 'string' if @completers[comp]? and @editor.completers[@completers[comp].pos] isnt @completers[comp].comp @editor.completers.splice(@completers[comp].pos, 0, @completers[comp].comp) - else + else @editor.completers = [] for type, comparator of @completers if @options.completers[type] is true - @activateCompleter type + @activateCompleter type addSnippets: (snippets, language) -> @options.language = language ace.config.loadModule 'ace/ext/language_tools', () => @snippetManager = ace.require('ace/snippets').snippetManager snippetModulePath = 'ace/snippets/' + language - ace.config.loadModule snippetModulePath, (m) => + ace.config.loadModule snippetModulePath, (m) => if m? - @snippetManager.files[language] = m + @snippetManager.files[language] = m @snippetManager.unregister m.snippets if m.snippets?.length > 0 @snippetManager.unregister @oldSnippets if @oldSnippets? m.snippets = if @options.snippetsLangDefaults then @snippetManager.parseSnippetFile m.snippetText else [] @@ -265,7 +265,7 @@ module.exports = class Zatanna when 'python' then 'loop:\n self.moveRight()\n ${1:}' when 'javascript' then 'loop {\n this.moveRight();\n ${1:}\n}' else content - if /loop/.test(content) and level.get('type') in ['course', 'course-ladder'] + if /loop/.test(content) and level.isType('course', 'course-ladder') # Temporary hackery to make it look like we meant while True: in our loop snippets until we can update everything content = switch e.language when 'python' then content.replace /loop:/, 'while True:' diff --git a/app/views/play/menu/GameMenuModal.coffee b/app/views/play/menu/GameMenuModal.coffee index a62a47cc2..b0489865c 100644 --- a/app/views/play/menu/GameMenuModal.coffee +++ b/app/views/play/menu/GameMenuModal.coffee @@ -5,7 +5,6 @@ submenuViews = [ require 'views/play/menu/SaveLoadView' require 'views/play/menu/OptionsView' require 'views/play/menu/GuideView' - require 'views/play/menu/MultiplayerView' ] module.exports = class GameMenuModal extends ModalView @@ -31,11 +30,10 @@ module.exports = class GameMenuModal extends ModalView getRenderData: (context={}) -> context = super(context) docs = @options.level.get('documentation') ? {} - submenus = ['guide', 'options', 'save-load', 'multiplayer'] + submenus = ['guide', 'options', 'save-load'] submenus = _.without submenus, 'options' if window.serverConfig.picoCTF submenus = _.without submenus, 'guide' unless docs.specificArticles?.length or docs.generalArticles?.length or window.serverConfig.picoCTF submenus = _.without submenus, 'save-load' unless me.isAdmin() or /https?:\/\/localhost/.test(window.location.href) - submenus = _.without submenus, 'multiplayer' unless me.isAdmin() or (@level?.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] and @level.get('slug') not in ['ace-of-coders', 'elemental-wars']) @includedSubmenus = submenus context.showTab = @options.showTab ? submenus[0] context.submenus = submenus @@ -43,11 +41,10 @@ module.exports = class GameMenuModal extends ModalView 'options': 'cog' 'guide': 'list' 'save-load': 'floppy-disk' - 'multiplayer': 'globe' context showsChooseHero: -> - return false if @level?.get('type') in ['course', 'course-ladder'] + return false if @level?.isType('course', 'course-ladder') return false if @options.levelID in ['zero-sum', 'ace-of-coders', 'elemental-wars'] return true @@ -55,7 +52,6 @@ module.exports = class GameMenuModal extends ModalView super() @insertSubView new submenuView @options for submenuView in submenuViews firstView = switch @options.showTab - when 'multiplayer' then @subviews.multiplayer_view when 'guide' then @subviews.guide_view else if 'guide' in @includedSubmenus then @subviews.guide_view else @subviews.options_view diff --git a/app/views/play/menu/GuideView.coffee b/app/views/play/menu/GuideView.coffee index d456ad687..fcd794117 100644 --- a/app/views/play/menu/GuideView.coffee +++ b/app/views/play/menu/GuideView.coffee @@ -19,7 +19,7 @@ module.exports = class LevelGuideView extends CocoView @levelSlug = options.level.get('slug') @sessionID = options.session.get('_id') @requiresSubscription = not me.isPremium() - @isCourseLevel = options.level.get('type', true) in ['course', 'course-ladder'] + @isCourseLevel = options.level.isType('course', 'course-ladder') @helpVideos = if @isCourseLevel then [] else options.level.get('helpVideos') ? [] @trackedHelpVideoStart = @trackedHelpVideoFinish = false # A/B Testing video tutorial styles diff --git a/app/views/play/menu/MultiplayerView.coffee b/app/views/play/menu/MultiplayerView.coffee deleted file mode 100644 index 13b28f149..000000000 --- a/app/views/play/menu/MultiplayerView.coffee +++ /dev/null @@ -1,229 +0,0 @@ -CocoView = require 'views/core/CocoView' -template = require 'templates/play/menu/multiplayer-view' -{me} = require 'core/auth' -ThangType = require 'models/ThangType' -LadderSubmissionView = require 'views/play/common/LadderSubmissionView' -RealTimeModel = require 'models/RealTimeModel' -RealTimeCollection = require 'collections/RealTimeCollection' - -module.exports = class MultiplayerView extends CocoView - id: 'multiplayer-view' - className: 'tab-pane' - template: template - - subscriptions: - 'ladder:game-submitted': 'onGameSubmitted' - - events: - 'click textarea': 'onClickLink' - 'change #multiplayer': 'updateLinkSection' - 'click #create-game-button': 'onCreateRealTimeGame' - 'click #join-game-button': 'onJoinRealTimeGame' - 'click #leave-game-button': 'onLeaveRealTimeGame' - - constructor: (options) -> - super(options) - @level = options.level - @levelID = @level?.get 'slug' - @session = options.session - @listenTo @session, 'change:multiplayer', @updateLinkSection - @watchRealTimeSessions() if @level?.get('type') in ['hero-ladder', 'course-ladder'] and me.isAdmin() - - destroy: -> - @realTimeSessions?.off 'add', @onRealTimeSessionAdded - @currentRealTimeSession?.off 'change', @onCurrentRealTimeSessionChanged - collection.off() for id, collection of @realTimeSessionsPlayers - super() - - getRenderData: -> - c = super() - c.joinLink = "#{document.location.href.replace(/\?.*/, '').replace('#', '')}?session=#{@session.id}" - c.multiplayer = @session.get 'multiplayer' - c.team = @session.get 'team' - c.levelSlug = @levelID - # For now, ladderGame will disallow multiplayer, because session code combining doesn't play nice yet. - if @level?.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] - c.ladderGame = true - c.readyToRank = @session?.readyToRank() - - # Real-time multiplayer stuff - if @level?.get('type') in ['hero-ladder', 'course-ladder'] and me.isAdmin() - c.levelID = @session.get('levelID') - c.realTimeSessions = @realTimeSessions - c.currentRealTimeSession = @currentRealTimeSession if @currentRealTimeSession - c.realTimeSessionsPlayers = @realTimeSessionsPlayers if @realTimeSessionsPlayers - # console.log 'MultiplayerView getRenderData', c.levelID - # console.log 'realTimeSessions', c.realTimeSessions - # console.log c.realTimeSessions.at(c.realTimeSessions.length - 1).get('state') if c.realTimeSessions.length > 0 - # console.log 'currentRealTimeSession', c.currentRealTimeSession - # console.log 'realTimeSessionPlayers', c.realTimeSessionsPlayers - - c - - afterRender: -> - super() - @updateLinkSection() - @ladderSubmissionView = new LadderSubmissionView session: @session, level: @level - @insertSubView @ladderSubmissionView, @$el.find('.ladder-submission-view') - @$el.find('#created-multiplayer-session').toggle Boolean(@currentRealTimeSession?) - @$el.find('#create-game-button').toggle Boolean(not (@currentRealTimeSession?)) - - onClickLink: (e) -> - e.target.select() - - onGameSubmitted: (e) -> - # Preserve the supermodel as we navigate back to the ladder. - viewArgs = [{supermodel: if @options.hasReceivedMemoryWarning then null else @supermodel}, @levelID] - ladderURL = "/play/ladder/#{@levelID}" - if leagueID = @getQueryVariable 'league' - leagueType = if @level?.get('type') is 'course-ladder' then 'course' else 'clan' - viewArgs.push leagueType - viewArgs.push leagueID - ladderURL += "/#{leagueType}/#{leagueID}" - ladderURL += '#my-matches' - Backbone.Mediator.publish 'router:navigate', route: ladderURL, viewClass: 'views/ladder/LadderView', viewArgs: viewArgs - - updateLinkSection: -> - multiplayer = @$el.find('#multiplayer').prop('checked') - la = @$el.find('#link-area') - la.toggle if @level?.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] then false else Boolean(multiplayer) - true - - onHidden: -> - multiplayer = Boolean(@$el.find('#multiplayer').prop('checked')) - @session.set('multiplayer', multiplayer) - - # Real-time Multiplayer ###################################################### - # - # This view is responsible for joining and leaving real-time multiplayer games. - # - # It performs these actions: - # Display your current game (level, players) - # Display open games - # Create game button, if not in a game - # Join game button - # Leave game button, if in a game - # - # It monitors these: - # Real-time multiplayer sessions (for open games, player states) - # Current real-time multiplayer game session for changes - # Players for real-time multiplayer game session - # - # Real-time state variables: - # @realTimeSessionsPlayers - Collection of player lists for active real-time multiplayer sessions - # @realTimeSessions - Active real-time multiplayer sessions - # @currentRealTimeSession - Our current real-time multiplayer session - # - # TODO: Ditch backfire and just use Firebase directly. Easier to debug, richer APIs (E.g. presence stuff). - - watchRealTimeSessions: -> - # Setup monitoring of real-time multiplayer level sessions - @realTimeSessionsPlayers = {} - # TODO: only request sessions for this level, !team, etc. - @realTimeSessions = new RealTimeCollection("multiplayer_level_sessions/#{@levelID}") - @realTimeSessions.on 'add', @onRealTimeSessionAdded - @realTimeSessions.each (rts) => @watchRealTimeSession rts - - watchRealTimeSession: (rts) -> - return if rts.get('state') is 'finished' - return if rts.get('levelID') isnt @session.get('levelID') - # console.log 'MultiplayerView watchRealTimeSession', rts - # Setup monitoring of players for given session - # TODO: verify we need this - realTimeSession = new RealTimeModel("multiplayer_level_sessions/#{@levelID}/#{rts.id}") - realTimeSession.on 'change', @onRealTimeSessionChanged - @realTimeSessionsPlayers[rts.id] = new RealTimeCollection("multiplayer_level_sessions/#{@levelID}/#{rts.id}/players") - @realTimeSessionsPlayers[rts.id].on 'add', @onRealTimePlayerAdded - @findCurrentRealTimeSession rts - - findCurrentRealTimeSession: (rts) -> - # Look for our current real-time session (level, level state, member player) - return if @currentRealTimeSession or not @realTimeSessionsPlayers? - if rts.get('levelID') is @session.get('levelID') and rts.get('state') isnt 'finished' - @realTimeSessionsPlayers[rts.id].each (player) => - if player.id is me.id and player.get('state') isnt 'left' - # console.log 'MultiplayerView found current real-time session', rts - @currentRealTimeSession = new RealTimeModel("multiplayer_level_sessions/#{@levelID}/#{rts.id}") - @currentRealTimeSession.on 'change', @onCurrentRealTimeSessionChanged - - # TODO: Is this necessary? Shouldn't everyone already know we joined a game at this point? - Backbone.Mediator.publish 'real-time-multiplayer:joined-game', realTimeSessionID: @currentRealTimeSession.id - - onRealTimeSessionAdded: (rts) => - @watchRealTimeSession rts - @render() - - onRealTimeSessionChanged: (rts) => - # console.log 'MultiplayerView onRealTimeSessionChanged', rts.get('state') - # TODO: @realTimeSessions isn't updated before we call render() here - # TODO: so this game isn't updated in open games list - @render?() - - onCurrentRealTimeSessionChanged: (rts) => - # console.log 'MultiplayerView onCurrentRealTimeSessionChanged', rts - if rts.get('state') is 'finished' - @currentRealTimeSession.off 'change', @onCurrentRealTimeSessionChanged - @currentRealTimeSession = null - @render?() - - onRealTimePlayerAdded: (e) => - @render?() - - onCreateRealTimeGame: -> - @playSound 'menu-button-click' - s = @realTimeSessions.create { - creator: @session.get('creator') - creatorName: @session.get('creatorName') - levelID: @session.get('levelID') - created: (new Date()).toISOString() - state: 'creating' - } - @currentRealTimeSession = @realTimeSessions.get(s.id) - @currentRealTimeSession.on 'change', @onCurrentRealTimeSessionChanged - # TODO: s.id === @currentRealTimeSession.id ? - players = new RealTimeCollection("multiplayer_level_sessions/#{@levelID}/#{@currentRealTimeSession.id}/players") - players.create - id: me.id - state: 'coding' - name: @session.get('creatorName') - team: @session.get('team') - level_session: @session.id - Backbone.Mediator.publish 'real-time-multiplayer:created-game', realTimeSessionID: @currentRealTimeSession.id - @render() - - onJoinRealTimeGame: (e) -> - return if @currentRealTimeSession - @playSound 'menu-button-click' - item = @$el.find(e.target).data('item') - @currentRealTimeSession = @realTimeSessions.get(item.id) - @currentRealTimeSession.on 'change', @onCurrentRealTimeSessionChanged - if @realTimeSessionsPlayers[item.id] - - # TODO: SpellView updateTeam() should take care of this team swap update in the real-time multiplayer session - creatorID = @currentRealTimeSession.get('creator') - creator = @realTimeSessionsPlayers[item.id].get(creatorID) - creatorTeam = creator.get('team') - myTeam = @session.get('team') - if myTeam is creatorTeam - myTeam = if creatorTeam is 'humans' then 'ogres' else 'humans' - - @realTimeSessionsPlayers[item.id].create - id: me.id - state: 'coding' - name: me.get('name') - team: myTeam - level_session: @session.id - else - console.error 'MultiplayerView onJoinRealTimeGame did not have a players collection', @currentRealTimeSession - Backbone.Mediator.publish 'real-time-multiplayer:joined-game', realTimeSessionID: @currentRealTimeSession.id - @render() - - onLeaveRealTimeGame: (e) -> - @playSound 'menu-button-click' - if @currentRealTimeSession - @currentRealTimeSession.off 'change', @onCurrentRealTimeSessionChanged - @currentRealTimeSession = null - Backbone.Mediator.publish 'real-time-multiplayer:left-game', userID: me.id - else - console.error "Tried to leave a game with no currentMultiplayerSession" - @render() diff --git a/bower.json b/bower.json index 333ffe8ec..85732107b 100644 --- a/bower.json +++ b/bower.json @@ -32,9 +32,8 @@ "firepad": "~0.1.2", "marked": "~0.3.0", "moment": "~2.5.0", - "aether": "~0.5.6", + "aether": "~0.5.21", "underscore.string": "~2.3.3", - "firebase": "~1.0.2", "d3": "~3.4.4", "jsondiffpatch": "0.1.8", "nanoscroller": "~0.8.0", @@ -44,7 +43,6 @@ "validated-backbone-mediator": "~0.1.3", "jquery.browser": "~0.0.6", "modernizr": "~2.8.3", - "backfire": "~0.3.0", "fastclick": "~1.0.3", "three.js": "~0.71.0", "lscache": "~1.0.5", @@ -64,9 +62,6 @@ "backbone": { "main": "backbone.js" }, - "backfire": { - "main": "backbone-firebase.min.js" - }, "lodash": { "main": "dist/lodash.js" }, @@ -112,13 +107,12 @@ "aether": { "main": [ "build/aether.js", - "build/clojure.js", "build/coffeescript.js", - "build/io.js", "build/javascript.js", "build/lua.js", "build/python.js", - "build/java.js" + "build/java.js", + "build/html.js" ] } }, diff --git a/config.coffee b/config.coffee index 75e614786..739525c65 100644 --- a/config.coffee +++ b/config.coffee @@ -115,7 +115,7 @@ exports.config = 'javascripts/app/vendor/aether-lua.js': 'bower_components/aether/build/lua.js' 'javascripts/app/vendor/aether-java.js': 'bower_components/aether/build/java.js' 'javascripts/app/vendor/aether-python.js': 'bower_components/aether/build/python.js' - 'javascripts/app/vendor/aether-java.js': 'bower_components/aether/build/java.js' + 'javascripts/app/vendor/aether-html.js': 'bower_components/aether/build/html.js' # Any vendor libraries we don't want the client to load immediately 'javascripts/app/vendor/d3.js': regJoin('^bower_components/d3') diff --git a/package.json b/package.json index fe72f18cf..5b3aeff4f 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dependencies": { "JQDeferred": "~2.1.0", "ace-builds": "https://github.com/ajaxorg/ace-builds/archive/3fb55e8e374ab02ce47c1ae55ffb60a1835f3055.tar.gz", - "aether": "~0.5.6", + "aether": "~0.5.21", "async": "0.2.x", "aws-sdk": "~2.0.0", "bayesian-battle": "0.0.7", diff --git a/server/middleware/classrooms.coffee b/server/middleware/classrooms.coffee index 4ef0abfb8..e95af999a 100644 --- a/server/middleware/classrooms.coffee +++ b/server/middleware/classrooms.coffee @@ -145,6 +145,7 @@ module.exports = query = {} query = {adminOnly: {$ne: true}} unless req.user?.isAdmin() courses = yield Course.find(query) + courses = Course.sortCourses courses campaigns = yield Campaign.find({_id: {$in: (course.get('campaignID') for course in courses)}}) campaignMap = {} campaignMap[campaign.id] = campaign for campaign in campaigns diff --git a/server/middleware/course-instances.coffee b/server/middleware/course-instances.coffee index 362dc046c..e32f1eccb 100644 --- a/server/middleware/course-instances.coffee +++ b/server/middleware/course-instances.coffee @@ -140,6 +140,18 @@ module.exports = res.status(200).send(classroom) + fetchCourse: wrap (req, res) -> + courseInstance = yield database.getDocFromHandle(req, CourseInstance) + if not courseInstance + throw new errors.NotFound('Course Instance not found.') + + course = yield Course.findById(courseInstance.get('courseID')).select(parse.getProjectFromReq(req)) + if not course + throw new errors.NotFound('Course not found.') + + res.status(200).send(course.toObject({req: req})) + + fetchRecent: wrap (req, res) -> query = {$and: [{name: {$ne: 'Single Player'}}, {hourOfCode: {$ne: true}}]} query["$and"].push(_id: {$gte: objectIdFromTimestamp(req.body.startDay + "T00:00:00.000Z")}) if req.body.startDay? diff --git a/server/middleware/courses.coffee b/server/middleware/courses.coffee index 22cd0a48d..34b05715d 100644 --- a/server/middleware/courses.coffee +++ b/server/middleware/courses.coffee @@ -57,4 +57,5 @@ module.exports = dbq = Model.find(query) dbq.select(parse.getProjectFromReq(req)) results = yield database.viewSearch(dbq, req) + results = Course.sortCourses results res.send(results) diff --git a/server/models/AnalyticsLogEvent.coffee b/server/models/AnalyticsLogEvent.coffee index e824ad776..e00234607 100644 --- a/server/models/AnalyticsLogEvent.coffee +++ b/server/models/AnalyticsLogEvent.coffee @@ -31,6 +31,6 @@ AnalyticsLogEventSchema.statics.logEvent = (user, event, properties={}) -> unless config.proxy analyticsMongoose = mongoose.createConnection() analyticsMongoose.open "mongodb://#{config.mongo.analytics_host}:#{config.mongo.analytics_port}/#{config.mongo.analytics_db}", (error) -> - log.error "Couldnt connect to analytics", error if error - + log.error "Couldn't connect to analytics", error if error + module.exports = AnalyticsLogEvent = analyticsMongoose.model('analytics.log.event', AnalyticsLogEventSchema, config.mongo.analytics_collection) diff --git a/server/models/Course.coffee b/server/models/Course.coffee index 7ab4ec71d..bab22640f 100644 --- a/server/models/Course.coffee +++ b/server/models/Course.coffee @@ -13,4 +13,32 @@ CourseSchema.statics.editableProperties = [] CourseSchema.statics.jsonSchema = jsonSchema +CourseSchema.statics.sortCourses = (courses) -> + ordering = [ + 'introduction-to-computer-science' + 'computer-science-2' + 'game-dev-1' + 'web-dev-1' + 'computer-science-3' + 'game-dev-2' + 'web-dev-2' + 'computer-science-4' + 'game-dev-3' + 'web-dev-3' + 'computer-science-5' + 'game-dev-4' + 'web-dev-4' + 'computer-science-6' + 'game-dev-5' + 'web-dev-5' + 'computer-science-7' + 'game-dev-6' + 'web-dev-6' + 'computer-science-8' + ] + _.sortBy courses, (course) -> + index = ordering.indexOf(course.get?('slug') or course.slug) + index = 9001 if index is -1 + index + module.exports = Course = mongoose.model 'course', CourseSchema, 'courses' diff --git a/server/models/LevelSession.coffee b/server/models/LevelSession.coffee index bc3076fdc..b68263841 100644 --- a/server/models/LevelSession.coffee +++ b/server/models/LevelSession.coffee @@ -83,7 +83,7 @@ LevelSessionSchema.pre 'save', (next) -> next() LevelSessionSchema.statics.privateProperties = ['code', 'submittedCode', 'unsubscribed'] -LevelSessionSchema.statics.editableProperties = ['multiplayer', 'players', 'code', 'codeLanguage', 'completed', 'state', +LevelSessionSchema.statics.editableProperties = ['players', 'code', 'codeLanguage', 'completed', 'state', 'levelName', 'creatorName', 'levelID', 'chat', 'teamSpells', 'submitted', 'submittedCodeLanguage', 'unsubscribed', 'playtime', 'heroConfig', 'team', @@ -110,7 +110,7 @@ if config.mongo.level_session_replica_string? levelSessionMongo = mongoose.createConnection() levelSessionMongo.open config.mongo.level_session_replica_string, (error) -> if error - log.error "Couldnt connect to session mongo!", error + log.error "Couldn't connect to session mongo!", error else log.info "Connected to seperate level session server with string", config.mongo.level_session_replica_string else @@ -122,7 +122,7 @@ if config.mongo.level_session_aux_replica_string? auxLevelSessionMongo = mongoose.createConnection() auxLevelSessionMongo.open config.mongo.level_session_aux_replica_string, (error) -> if error - log.error "Couldnt connect to AUX session mongo!", error + log.error "Couldn't connect to AUX session mongo!", error else log.info "Connected to seperate level AUX session server with string", config.mongo.level_session_aux_replica_string diff --git a/server/routes/index.coffee b/server/routes/index.coffee index 67e6d2c26..2d1493096 100644 --- a/server/routes/index.coffee +++ b/server/routes/index.coffee @@ -88,7 +88,8 @@ module.exports.setup = (app) -> app.get('/db/course_instance/:handle/levels/:levelOriginal/sessions/:sessionID/next', mw.courseInstances.fetchNextLevel) app.post('/db/course_instance/:handle/members', mw.auth.checkLoggedIn(), mw.courseInstances.addMembers) app.get('/db/course_instance/:handle/classroom', mw.auth.checkLoggedIn(), mw.courseInstances.fetchClassroom) - + app.get('/db/course_instance/:handle/course', mw.auth.checkLoggedIn(), mw.courseInstances.fetchCourse) + app.put('/db/user/:handle', mw.users.resetEmailVerifiedFlag) app.delete('/db/user/:handle', mw.users.removeFromClassrooms) app.get('/db/user', mw.users.fetchByGPlusID, mw.users.fetchByFacebookID) diff --git a/spec/server/functional/course_instance.spec.coffee b/spec/server/functional/course_instance.spec.coffee index 5c00cec82..cbcbd8bb6 100644 --- a/spec/server/functional/course_instance.spec.coffee +++ b/spec/server/functional/course_instance.spec.coffee @@ -375,6 +375,25 @@ describe 'GET /db/course_instance/:handle/classroom', -> expect(res.statusCode).toBe(403) done() +describe 'GET /db/course_instance/:handle/course', -> + + beforeEach utils.wrap (done) -> + yield utils.clearModels [User, CourseInstance, Classroom] + @course = new Course({}) + yield @course.save() + @courseInstance = new CourseInstance({courseID: @course._id}) + yield @courseInstance.save() + @url = getURL("/db/course_instance/#{@courseInstance.id}/course") + done() + + it 'returns the course instance\'s referenced course', utils.wrap (done) -> + user = yield utils.initUser() + yield utils.loginUser user + [res, body] = yield request.getAsync(@url, {json: true}) + expect(res.statusCode).toBe(200) + expect(body._id).toBe(@course.id) + done() + describe 'POST /db/course_instance/-/recent', -> url = getURL('/db/course_instance/-/recent')