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')