From d77625bc77ad536039ec2d7c1c684cae41c36dcb Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Thu, 28 Jul 2016 13:39:58 -0700 Subject: [PATCH] Game dev levels (#3810) * Tweak API doc behavior and styling * Instead of moving to the left during active dialogues, just move to the top * Allow pointer events * Adjust close button * Re-enable pinning API docs for game-dev and web-dev levels * Make sidebar in PlayGameDevLevelView stretch, better layout columns * Set up content of PlayGameDevLevelView sidebar to scroll * Add rest of PlayGameDevLevelView sidebar content, rework what loading looks like * Finish PlayGameDevLevelView * Add share area below * Cover the brown background, paint it gray * Tweak PlayGameDevLevelView * Have progress bar show everything * Fix Surface resize handling * Fix PlayGameDevLevelView resizing incorrectly when playing * Add GameDevVictoryModal to PlayGameDevLevelView * Don't show missing-doctype annotation in Ace * Hook up GameDevVictoryModal copy button * Fix onChangeAnnotation runtime error * Fix onLevelLoaded runtime error * Have CourseVictoryModal link to /courses when course is done * Trim, update CourseDetailsView * Remove last vestiges of teacherMode * Remove giant navigation buttons at top * Quick switch to flat style * Add analytics for game-dev * Update Analytics events for gamedev * Prefix event names with context * Send to Mixpanel * Include more properties * Mostly set up indefinite play and autocast for game-dev levels * Set up cast buttons and shortcut for game-dev * Add rudimentary instructions when students play game-dev levels * Couple tweaks * fix a bit of code that expects frames to always stick around * have PlayGameDevLevelView render a couple frames on load * API Docs use 'game' instead of 'hero' * Move tags to head without combining * Add HTML comment-start string Fixes missing entry point arrows * Fix some whitespace --- app/assets/javascripts/web-dev-listener.js | 36 ++- .../javascripts/workers/worker_world.js | 5 + app/assets/web-dev-iframe.html | 8 +- app/core/urls.coffee | 5 + app/lib/Angel.coffee | 10 +- app/lib/God.coffee | 7 +- app/lib/surface/Surface.coffee | 18 +- app/lib/world/world.coffee | 24 +- app/lib/world/world_frame.coffee | 2 +- app/schemas/subscriptions/tome.coffee | 2 + ...control_bar.sass => control-bar-view.sass} | 0 ...playback.sass => level-playback-view.sass} | 0 .../level/modal/game-dev-victory-modal.sass | 8 + .../play/level/play-game-dev-level-view.sass | 43 +++- app/styles/play/level/tome/spell.sass | 4 + .../play/level/tome/spell_palette_entry.sass | 15 +- app/styles/style-flat.sass | 3 + app/templates/courses/course-details.jade | 212 +++++++----------- ...control_bar.jade => control-bar-view.jade} | 0 ...playback.jade => level-playback-view.jade} | 19 +- .../level/modal/game-dev-victory-modal.jade | 17 ++ .../play/level/play-game-dev-level-view.jade | 80 ++++--- .../play/level/tome/cast-button-view.jade | 25 +++ .../play/level/tome/cast_button.jade | 17 -- app/templates/play/play-level-view.jade | 15 +- app/views/courses/CourseDetailsView.coffee | 5 - app/views/play/level/ControlBarView.coffee | 2 +- app/views/play/level/LevelGoalsView.coffee | 3 +- app/views/play/level/LevelPlaybackView.coffee | 6 +- .../play/level/PlayGameDevLevelView.coffee | 68 +++++- app/views/play/level/PlayLevelView.coffee | 12 +- app/views/play/level/WebSurfaceView.coffee | 6 +- .../level/modal/CourseVictoryModal.coffee | 4 +- .../level/modal/GameDevVictoryModal.coffee | 25 +++ .../play/level/modal/ProgressView.coffee | 18 +- .../play/level/tome/CastButtonView.coffee | 12 +- app/views/play/level/tome/DocFormatter.coffee | 1 + .../level/tome/SpellPaletteEntryView.coffee | 5 +- app/views/play/level/tome/SpellView.coffee | 30 ++- app/views/play/level/tome/TomeView.coffee | 16 +- 40 files changed, 533 insertions(+), 255 deletions(-) create mode 100644 app/core/urls.coffee rename app/styles/play/level/{control_bar.sass => control-bar-view.sass} (100%) rename app/styles/play/level/{playback.sass => level-playback-view.sass} (100%) create mode 100644 app/styles/play/level/modal/game-dev-victory-modal.sass rename app/templates/play/level/{control_bar.jade => control-bar-view.jade} (100%) rename app/templates/play/level/{playback.jade => level-playback-view.jade} (74%) create mode 100644 app/templates/play/level/modal/game-dev-victory-modal.jade create mode 100644 app/templates/play/level/tome/cast-button-view.jade delete mode 100644 app/templates/play/level/tome/cast_button.jade create mode 100644 app/views/play/level/modal/GameDevVictoryModal.coffee diff --git a/app/assets/javascripts/web-dev-listener.js b/app/assets/javascripts/web-dev-listener.js index 58f11db96..54e2680ad 100644 --- a/app/assets/javascripts/web-dev-listener.js +++ b/app/assets/javascripts/web-dev-listener.js @@ -3,7 +3,11 @@ window.addEventListener('message', receiveMessage, false); var concreteDom; +var concreteStyles; +var concreteScripts; var virtualDom; +var virtualStyles; +var virtualScripts; var goalStates; var allowedOrigins = [ @@ -54,11 +58,31 @@ function create({ dom, styles, 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); + replaceNodes('[for="player-styles"]', unwrapConcreteNodes(concreteStyles)); + replaceNodes('[for="player-scripts"]', unwrapConcreteNodes(concreteScripts)); +} + +function unwrapConcreteNodes(wrappedNodes) { + return wrappedNodes.children; +} + +function replaceNodes(selector, newNodes){ + $newNodes = $(newNodes).clone() + $(selector + ':not(:first)').remove(); + + firstNode = $(selector).first(); + $newNodes.attr('for', firstNode.attr('for')); + + newFirstNode = $newNodes[0]; + try { + firstNode.replaceWith(newFirstNode); // Removes newFirstNode from its array (!!) + } catch (e) { + console.log('Failed to update some nodes:', e); + } + + $(newFirstNode).after($newNodes); } function update({ dom, styles, scripts }) { @@ -68,11 +92,13 @@ function update({ dom, styles, scripts }) { 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 scriptChanges = deku.diff.diffNode(virtualScripts, scripts); + // scriptChanges.reduce(deku.dom.update(dispatch, context), concreteScripts); // Rerender + // replaceNodes('[for="player-scripts"]', unwrapConcreteNodes(concreteScripts)); var styleChanges = deku.diff.diffNode(virtualStyles, styles); styleChanges.reduce(deku.dom.update(dispatch, context), concreteStyles); // Rerender + replaceNodes('[for="player-styles"]', unwrapConcreteNodes(concreteStyles)); virtualDom = dom; virtualStyles = styles; diff --git a/app/assets/javascripts/workers/worker_world.js b/app/assets/javascripts/workers/worker_world.js index 5857ba810..7cb6da6a6 100644 --- a/app/assets/javascripts/workers/worker_world.js +++ b/app/assets/javascripts/workers/worker_world.js @@ -389,6 +389,8 @@ self.runWorld = function runWorld(args) { self.world.preloading = args.preload; self.world.headless = args.headless; self.world.realTime = args.realTime; + self.world.indefiniteLength = args.indefiniteLength; + self.world.justBegin = args.justBegin; self.goalManager = new GoalManager(self.world); self.goalManager.setGoals(args.goals); self.goalManager.setCode(args.userCodeMap); @@ -434,6 +436,9 @@ self.onWorldLoaded = function onWorldLoaded() { var diff = t1 - self.t0; var goalStates = self.goalManager.getGoalStates(); var totalFrames = self.world.totalFrames; + if(self.world.indefiniteLength) { + totalFrames = self.world.frames.length; + } if(self.world.ended) { var overallStatus = self.goalManager.checkOverallStatus(); var lastFrameHash = self.world.frames[totalFrames - 2].hash diff --git a/app/assets/web-dev-iframe.html b/app/assets/web-dev-iframe.html index f547d9d4f..22f77b53b 100644 --- a/app/assets/web-dev-iframe.html +++ b/app/assets/web-dev-iframe.html @@ -35,9 +35,13 @@ - - + + diff --git a/app/core/urls.coffee b/app/core/urls.coffee new file mode 100644 index 000000000..09687210a --- /dev/null +++ b/app/core/urls.coffee @@ -0,0 +1,5 @@ +module.exports = + playDevLevel: ({level, session, course}) -> + shareURL = "#{window.location.origin}/play/#{level.get('type')}-level/#{level.get('slug')}/#{session.id}" + shareURL += "?course=#{course.id}" if course + return shareURL diff --git a/app/lib/Angel.coffee b/app/lib/Angel.coffee index 7279424b0..942329cf8 100644 --- a/app/lib/Angel.coffee +++ b/app/lib/Angel.coffee @@ -111,7 +111,9 @@ module.exports = class Angel extends CocoClass when 'user-code-problem' @publishGodEvent 'user-code-problem', problem: event.data.problem when 'world-load-progress-changed' - @publishGodEvent 'world-load-progress-changed', progress: event.data.progress + progress = event.data.progress + progress = Math.min(progress, 0.9) if @work.indefiniteLength + @publishGodEvent 'world-load-progress-changed', { progress } unless event.data.progress is 1 or @work.preload or @work.headless or @work.synchronous or @deserializationQueue.length or (@shared.firstWorld and not @shared.spectate) @worker.postMessage func: 'serializeFramesSoFar' # Stream it! @@ -138,6 +140,7 @@ module.exports = class Angel extends CocoClass return if @aborting # Toggle BOX2D_ENABLED during deserialization so that if we have box2d in the namespace, the Collides Components still don't try to create bodies for deserialized Thangs upon attachment. window.BOX2D_ENABLED = false + streamingWorld?.indefiniteLength = @work.indefiniteLength @streamingWorld = World.deserialize serialized, @shared.worldClassMap, @shared.lastSerializedWorldFrames, @finishBeholdingWorld(goalStates), startFrame, endFrame, @work.level, streamingWorld window.BOX2D_ENABLED = true @shared.lastSerializedWorldFrames = serialized.frames @@ -145,7 +148,10 @@ module.exports = class Angel extends CocoClass finishBeholdingWorld: (goalStates) -> (world) => return if @aborting or @destroyed finished = world.frames.length is world.totalFrames - firstChangedFrame = world.findFirstChangedFrame @shared.world + if @work?.indefiniteLength and world.victory? + finished = true + world.totalFrames = world.frames.length + firstChangedFrame = if @work.indefiniteLength then 0 else world.findFirstChangedFrame @shared.world eventType = if finished then 'new-world-created' else 'streaming-world-updated' if finished @shared.world = world diff --git a/app/lib/God.coffee b/app/lib/God.coffee index 60c15c5b6..9932a4ad1 100644 --- a/app/lib/God.coffee +++ b/app/lib/God.coffee @@ -20,6 +20,7 @@ module.exports = class God extends CocoClass options ?= {} @retrieveValueFromFrame = _.throttle @retrieveValueFromFrame, 1000 @gameUIState ?= options.gameUIState or new GameUIState() + @indefiniteLength = options.indefiniteLength or false super() # Angels are all given access to this. @@ -71,9 +72,9 @@ module.exports = class God extends CocoClass @lastFixedSeed = e.fixedSeed @lastFlagHistory = (flag for flag in e.flagHistory when flag.source isnt 'code') @lastDifficulty = e.difficulty - @createWorld e.spells, e.preload, e.realTime + @createWorld e.spells, e.preload, e.realTime, e.justBegin - createWorld: (spells, preload, realTime) -> + createWorld: (spells, preload, realTime, justBegin) -> console.log "#{@nick}: Let there be light upon #{@level.name}! (preload: #{preload})" userCodeMap = @getUserCodeMap spells @@ -107,6 +108,8 @@ module.exports = class God extends CocoClass preload synchronous: not Worker? # Profiling world simulation is easier on main thread, or we are IE9. realTime + justBegin + indefiniteLength: @indefiniteLength and realTime } @angelsShare.workQueue.push work angel.workIfIdle() for angel in @angelsShare.angels diff --git a/app/lib/surface/Surface.coffee b/app/lib/surface/Surface.coffee index 4f63e1ce3..c2d9deb2f 100644 --- a/app/lib/surface/Surface.coffee +++ b/app/lib/surface/Surface.coffee @@ -92,9 +92,9 @@ module.exports = Surface = class Surface extends CocoClass }) @realTimeInputEvents = @gameUIState.get('realTimeInputEvents') @listenTo(@gameUIState, 'sprite:mouse-down', @onSpriteMouseDown) + @onResize = _.debounce @onResize, resizeDelay @initEasel() @initAudio() - @onResize = _.debounce @onResize, resizeDelay $(window).on 'resize', @onResize if @world.ended _.defer => @setWorld @world @@ -131,8 +131,9 @@ module.exports = Surface = class Surface extends CocoClass @handleEvents }) @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. + unless @options.levelType is 'game-dev' + @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. @initCoordinates() @webGLStage.enableMouseOver(10) @webGLStage.addEventListener 'stagemousemove', @onMouseMove @@ -227,6 +228,7 @@ module.exports = Surface = class Surface extends CocoClass restoreWorldState: -> frame = @world.getFrame(@getCurrentFrame()) + return unless frame frame.restoreState() current = Math.max(0, Math.min(@currentFrame, @world.frames.length - 1)) if current - Math.floor(current) > 0.01 and Math.ceil(current) < @world.frames.length - 1 @@ -325,12 +327,12 @@ module.exports = Surface = class Surface extends CocoClass @surfacePauseTimeout = _.delay performToggle, 2000 @lankBoss.stop() @trailmaster?.stop() - @playbackOverScreen.show() + @playbackOverScreen?.show() else performToggle() @lankBoss.play() @trailmaster?.play() - @playbackOverScreen.hide() + @playbackOverScreen?.hide() @@ -348,7 +350,7 @@ module.exports = Surface = class Surface extends CocoClass world: @world ) - if @lastFrame < @world.frames.length and @currentFrame >= @world.totalFrames - 1 + if (not @world.indefiniteLength) and @lastFrame < @world.frames.length and @currentFrame >= @world.totalFrames - 1 @ended = true @setPaused true Backbone.Mediator.publish 'surface:playback-ended', {} @@ -551,6 +553,9 @@ module.exports = Surface = class Surface extends CocoClass if application.isIPadApp newWidth = 1024 newHeight = newWidth / aspectRatio + else if @options.resizeStrategy is 'wrapper-size' + newWidth = $('#canvas-wrapper').width() + newHeight = newWidth / aspectRatio else if @realTime or @options.spectateGame pageHeight = $('#page-container').height() - $('#control-bar-view').outerHeight() - $('#playback-view').outerHeight() newWidth = Math.min pageWidth, pageHeight * aspectRatio @@ -576,6 +581,7 @@ module.exports = Surface = class Surface extends CocoClass return if newWidth is oldWidth and newHeight is oldHeight and not @options.spectateGame return if newWidth < 200 or newHeight < 200 @normalCanvas.add(@webGLCanvas).attr width: newWidth, height: newHeight + @trigger 'resize', { width: newWidth, height: newHeight } # Cannot do this to the webGLStage because it does not use scaleX/Y. # Instead the LayerAdapter scales webGL-enabled layers. diff --git a/app/lib/world/world.coffee b/app/lib/world/world.coffee index cff8ad5be..106daea8b 100644 --- a/app/lib/world/world.coffee +++ b/app/lib/world/world.coffee @@ -28,6 +28,7 @@ module.exports = class World debugging: false # Whether we are just rerunning to debug a world we've already cast headless: false # Whether we are just simulating for goal states instead of all serialized results framesSerializedSoFar: 0 + framesClearedSoFar: 0 apiProperties: ['age', 'dt'] realTimeBufferMax: REAL_TIME_BUFFER_MAX / 1000 constructor: (@userCodeMap, classMap) -> @@ -96,6 +97,7 @@ module.exports = class World loadFrames: (loadedCallback, errorCallback, loadProgressCallback, preloadedCallback, skipDeferredLoading, loadUntilFrame) -> return if @aborted + @totalFrames = 2 if @justBegin console.log 'Warning: loadFrames called on empty World (no thangs).' unless @thangs.length continueLaterFn = => @loadFrames(loadedCallback, errorCallback, loadProgressCallback, preloadedCallback, skipDeferredLoading, loadUntilFrame) unless @destroyed @@ -116,7 +118,13 @@ module.exports = class World @lastRealTimeUpdate ?= 0 frameToLoadUntil = if loadUntilFrame then loadUntilFrame + 1 else @totalFrames # Might stop early if debugging. i = @frames.length - while i < frameToLoadUntil and i < @totalFrames + while true + if @indefiniteLength + break if not @realTime # realtime has been stopped + break if @victory? # game won or lost # TODO: give a couple seconds of buffer after victory is set instead of ending instantly + else + break if i >= frameToLoadUntil + break if i >= @totalFrames return unless @shouldContinueLoading t1, loadProgressCallback, skipDeferredLoading, continueLaterFn @adjustFlowSettings loadUntilFrame if @debugging try @@ -379,6 +387,11 @@ module.exports = class World @freeMemoryBeforeFinalSerialization() if @ended startFrame = @framesSerializedSoFar endFrame = @frames.length + if @indefiniteLength + toClear = Math.max(@framesSerializedSoFar-10, 0) + for i in _.range(@framesClearedSoFar, toClear) + @frames[i] = null + @framesClearedSoFar = @framesSerializedSoFar #console.log "... world serializing frames from", startFrame, "to", endFrame, "of", @totalFrames [transferableObjects, nontransferableObjects] = [0, 0] serializedFlagHistory = (_.omit(_.clone(flag), 'processed') for flag in @flagHistory) @@ -525,6 +538,14 @@ module.exports = class World perf.framesCPUTime = 0 w.frames = [] unless streamingWorld clearTimeout @deserializationTimeout if @deserializationTimeout + + if w.indefiniteLength + clearTo = Math.max(w.frames.length - 100, 0) + if clearTo > w.framesClearedSoFar + for i in _.range(w.framesClearedSoFar, clearTo) + w.frames[i] = null + w.framesClearedSoFar = clearTo + @deserializationTimeout = _.delay @deserializeSomeFrames, 1, o, w, finishedWorldCallback, perf, startFrame, endFrame w # Return in-progress deserializing world @@ -588,6 +609,7 @@ module.exports = class World lastPos = x: null, y: null for frameIndex in [lastFrameIndex .. 0] by -1 frame = @frames[frameIndex] + continue unless frame # may have been evicted for game dev levels if pos = frame.thangStateMap[thangID]?.getStateForProp 'pos' pos = camera.worldToSurface {x: pos.x, y: pos.y} if camera # without z if not lastPos.x? or (Math.abs(lastPos.x - pos.x) + Math.abs(lastPos.y - pos.y)) > 1 diff --git a/app/lib/world/world_frame.coffee b/app/lib/world/world_frame.coffee index 7fb0940bc..7fc437e8a 100644 --- a/app/lib/world/world_frame.coffee +++ b/app/lib/world/world_frame.coffee @@ -10,7 +10,7 @@ module.exports = class WorldFrame getNextFrame: -> # Optimized. Must be called while thangs are current at this frame. nextTime = @time + @world.dt - return null if nextTime > @world.lifespan + return null if nextTime > @world.lifespan and not @world.indefiniteLength @hash = @world.rand.seed @hash += system.update() for system in @world.systems nextFrame = new WorldFrame(@world, nextTime) diff --git a/app/schemas/subscriptions/tome.coffee b/app/schemas/subscriptions/tome.coffee index 06008d209..f51853c76 100644 --- a/app/schemas/subscriptions/tome.coffee +++ b/app/schemas/subscriptions/tome.coffee @@ -6,6 +6,7 @@ module.exports = thang: {type: 'object'} preload: {type: 'boolean'} realTime: {type: 'boolean'} + justBegin: {type: 'boolean'} 'tome:cast-spells': c.object {title: 'Cast Spells', description: 'Published when spells are cast', required: ['spells', 'preload', 'realTime', 'submissionCount', 'flagHistory', 'difficulty', 'god']}, spells: {type: 'object'} @@ -16,6 +17,7 @@ module.exports = flagHistory: {type: 'array'} difficulty: {type: 'integer'} god: {type: 'object'} + justBegin: {type: 'boolean'} 'tome:manual-cast': c.object {title: 'Manually Cast Spells', description: 'Published when you wish to manually recast all spells', required: []}, realTime: {type: 'boolean'} diff --git a/app/styles/play/level/control_bar.sass b/app/styles/play/level/control-bar-view.sass similarity index 100% rename from app/styles/play/level/control_bar.sass rename to app/styles/play/level/control-bar-view.sass diff --git a/app/styles/play/level/playback.sass b/app/styles/play/level/level-playback-view.sass similarity index 100% rename from app/styles/play/level/playback.sass rename to app/styles/play/level/level-playback-view.sass diff --git a/app/styles/play/level/modal/game-dev-victory-modal.sass b/app/styles/play/level/modal/game-dev-victory-modal.sass new file mode 100644 index 000000000..a29e6d8ef --- /dev/null +++ b/app/styles/play/level/modal/game-dev-victory-modal.sass @@ -0,0 +1,8 @@ +#game-dev-victory-modal + .share-row + margin: 20px 0 + + #copy-url-input + width: 50% + margin: 0 10px + display: inline-block diff --git a/app/styles/play/level/play-game-dev-level-view.sass b/app/styles/play/level/play-game-dev-level-view.sass index c14a38add..e76e68c6a 100644 --- a/app/styles/play/level/play-game-dev-level-view.sass +++ b/app/styles/play/level/play-game-dev-level-view.sass @@ -1,9 +1,19 @@ #play-game-dev-level-view + .container-fluid + overflow: hidden + background: #333 + padding: 15px + height: 100vh + + #game-row + display: flex + #canvas-wrapper width: 100% position: relative overflow: hidden z-index: 0 + border-radius: 5px #webgl-surface background-color: #333 @@ -18,5 +28,34 @@ display: block z-index: 2 - #play-btn - text-transform: uppercase + #info-col + .panel + height: 100% + display: flex + flex-direction: column + + .panel-body + flex-grow: 1 + overflow: scroll + + .panel-footer + min-height: 70px + + #play-btn + text-transform: uppercase + + #share-panel-body + display: flex + align-items: center + + #share-text-div, #copy-url-div + flex-grow: 1 + + #share-text-div + margin-right: 20px + + #copy-url-input + width: 50% + + #copy-url-div + margin-left: 20px diff --git a/app/styles/play/level/tome/spell.sass b/app/styles/play/level/tome/spell.sass index e0e9bb894..222e0a765 100644 --- a/app/styles/play/level/tome/spell.sass +++ b/app/styles/play/level/tome/spell.sass @@ -158,6 +158,10 @@ .ace_gutter-cell.ace_error background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTM5jWRgMAAAAVdEVYdENyZWF0aW9uIFRpbWUAMi8xNy8wOCCcqlgAAAQRdEVYdFhNTDpjb20uYWRvYmUueG1wADw/eHBhY2tldCBiZWdpbj0iICAgIiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDQuMS1jMDM0IDQ2LjI3Mjk3NiwgU2F0IEphbiAyNyAyMDA3IDIyOjExOjQxICAgICAgICAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp4YXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iPgogICAgICAgICA8eGFwOkNyZWF0b3JUb29sPkFkb2JlIEZpcmV3b3JrcyBDUzM8L3hhcDpDcmVhdG9yVG9vbD4KICAgICAgICAgPHhhcDpDcmVhdGVEYXRlPjIwMDgtMDItMTdUMDI6MzY6NDVaPC94YXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPHhhcDpNb2RpZnlEYXRlPjIwMDgtMDMtMjRUMTk6MDA6NDJaPC94YXA6TW9kaWZ5RGF0ZT4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiAgICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2UvcG5nPC9kYzpmb3JtYXQ+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDUdUmQAAAD5SURBVDiNpZMxagMxEEWfgiCXcB3IbXwD7zbaM0nNyjdIl1O4Dk7pbsslEFbEZFKsJsiJrGDy4YM0M//zRyAoINAJyB8cS43RwwIdMFrvaeE8DADxXqQ3Jstn6GaQ5L3M0GQxsyaZoJtA3r2XCS6o+FkvZkdOIG/eywl+UVHrqcYm4BNIjb1rPdXYBTivj3gVtZ5q/p8gAfPhcLOBamzKcW41UI1dgA/qez4bU6muUE0zwVYEgKeKkWruEnTHENg4R8pFZblCyY1zHEMgQTQAe9gB8cE5XkO4GhugmIk76L+z+Wzy6FzT4CWLXf5MF8upSdMB4gC9Xr4AiezTJHGxdq0AAAAASUVORK5CYII=) + // NOTE! This hides all info annotations because removing specific annotations with listeners means they flicker. This hides that flicker. see: SpellView.onChangeAnnotation + .ace_gutter-cell.ace_info + background-image: none + .ace_gutter-cell.entry-point:not(.next-entry-point):after opacity: 0.5 diff --git a/app/styles/play/level/tome/spell_palette_entry.sass b/app/styles/play/level/tome/spell_palette_entry.sass index 7a81978aa..73b1e5c73 100644 --- a/app/styles/play/level/tome/spell_palette_entry.sass +++ b/app/styles/play/level/tome/spell_palette_entry.sass @@ -46,23 +46,22 @@ color: rgb(243, 169, 49) -body:not(.dialogue-view-active) +body.dialogue-view-active .spell-palette-popover.popover - right: 45% - min-width: 500px - margin-top: -17% - + top: 50px !important + .spell-palette-popover.popover // Only those popovers which are our direct children (spell documentation) left: auto !important + right: 45% max-width: 600px + min-width: 500px padding: 0 border-style: solid border-image: url(/images/level/popover_border_background.png) 16 12 fill round border-width: 16px 12px @include box-shadow(0 0 0 #000) // Prevent flickering in weird scenarios where popover goes over its own property - pointer-events: none // Jiggle animation // TODO: consolidate with problem_alert.sass jiggle @@ -96,8 +95,8 @@ body:not(.dialogue-view-active) .close position: absolute - top: 5% - right: 5% + top: -7px + right: 2px font-size: 28px font-weight: bold @include opacity(0.6) diff --git a/app/styles/style-flat.sass b/app/styles/style-flat.sass index eafb2e2ef..814546769 100644 --- a/app/styles/style-flat.sass +++ b/app/styles/style-flat.sass @@ -90,6 +90,9 @@ body[lang='ru'], body[lang='uk'], body[lang='bg'], body[lang^='mk'], body[lang=' top: 20px font-size: 40px opacity: 0.5 + + hr + border-top: 1px solid gray // Navbar diff --git a/app/templates/courses/course-details.jade b/app/templates/courses/course-details.jade index 2ee0f69b0..e0935904b 100644 --- a/app/templates/courses/course-details.jade +++ b/app/templates/courses/course-details.jade @@ -1,135 +1,87 @@ -extends /templates/base +extends /templates/base-flat block content - if me.isTeacher() - .alert.alert-danger.text-center - // DNT: Temporary - h3 ATTENTION TEACHERS: - p We are transitioning to a new classroom management system; this page will soon be student-only. - a(href="/teachers/classes") Go to teachers area. + .container.m-t-3 + p + a(href="/courses", data-i18n="courses.back_courses") - if view.teacherMode - a(href="/teachers/classes", data-i18n="courses.back_classrooms") - else - a(href="/courses", data-i18n="courses.back_courses") - br - br - - p - // TODO: format this text all good and stuff - strong - if view.courseInstance.get('name') - span= view.courseInstance.get('name') - else if view.classroom.get('name') - span= view.classroom.get('name') - else - span(data-i18n='courses.unnamed_class') - - if !view.owner.isNew() && view.getOwnerName() && view.courseInstance.get('name') != 'Single Player' - span.spl - - span.spl(data-i18n='courses.teacher') - span.spr : - //a(href="/user/#{view.owner.id}") // Don't link to profiles until we improve them - span - strong= view.getOwnerName() - - h1 - | #{view.course.get('name')} - if view.courseComplete - span.spl - - span.spl(data-i18n='courses.complete') - span ! - - p - if view.courseInstance.get('description') - each line in view.courseInstance.get('description').split('\n') - div= line - - if view.courseComplete && !view.teacherMode - .jumbotron - .row - .col-md-6 - if view.arenaLevel - a.btn.btn-lg.btn-success.btn-play-level(data-level-slug=view.arenaLevel.get('slug'), data-level-id=view.arenaLevel.get('original')) - h1 - span(data-i18n='courses.arena') - span.spr : - span= view.arenaLevel.get('name') - p= view.arenaLevel.get('description').replace(/!\[.*?\)/, '') - else - a.btn.btn-lg.btn-success.disabled - h1(data-i18n='courses.arena_soon_title') - p - span.spr(data-i18n='courses.arena_soon_description') - span= view.course.get('name') - span . - .col-md-6 - if view.nextCourseInstance && _.contains(view.nextCourseInstance.get('members'), me.id) - a.btn.btn-lg.btn-success(href="/courses/#{view.nextCourse.id}/#{view.nextCourseInstance.id}") - h1= view.nextCourse.get('name') - p= view.nextCourse.get('description') - else if view.nextCourse - a.btn.btn-lg.btn-success.disabled - h1= view.nextCourse.get('name') - p.text-uppercase - em(data-i18n='courses.not_enrolled1') - p(data-i18n='courses.not_enrolled2') - else - a.btn.btn-lg.btn-success(disabled=!view.nextCourse ? "disabled" : "") - h1(data-i18n='courses.next_course') - p.text-uppercase - em(data-i18n='courses.coming_soon1') - p(data-i18n='courses.coming_soon2') - - .available-courses-title(data-i18n='courses.available_levels') - table.table.table-striped.table-condensed - thead - tr - th - th(data-i18n="clans.status") - th(data-i18n="common.type") - th(data-i18n="resources.level") - th(data-i18n="courses.concepts") - tbody - - var previousLevelCompleted = true; - - var lastLevelCompleted = view.getLastLevelCompleted(); - - var passedLastCompletedLevel = !lastLevelCompleted; - - var levelCount = 0; - each level in view.levels.models - - var levelStatus = null; - - var levelNumber = view.classroom.getLevelNumber(level.get('original'), ++levelCount); - if view.userLevelStateMap[me.id] - - levelStatus = view.userLevelStateMap[me.id][level.get('original')] + p + strong + if view.courseInstance.get('name') + span= view.courseInstance.get('name') + else if view.classroom.get('name') + span= view.classroom.get('name') + else + span(data-i18n='courses.unnamed_class') + + if !view.owner.isNew() && view.getOwnerName() && view.courseInstance.get('name') != 'Single Player' + span.spl - + span.spl(data-i18n='courses.teacher') + span.spr : + span + strong= view.getOwnerName() + + h1 + | #{view.course.get('name')} + if view.courseComplete + span.spl - + span.spl(data-i18n='courses.complete') + span ! + + p + if view.courseInstance.get('description') + each line in view.courseInstance.get('description').split('\n') + div= line + + .available-courses-title(data-i18n='courses.available_levels') + table.table.table-striped.table-condensed + thead tr - td - if previousLevelCompleted || view.teacherMode || !passedLastCompletedLevel || levelStatus - - 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}. #{i18n(level.attributes, 'name').replace('Course: ', '')} - td - if view.levelConceptMap[level.get('original')] - each concept in view.course.get('concepts') - if view.levelConceptMap[level.get('original')][concept] - span.spr.concept(data-i18n="concepts." + concept) - if level.get('original') === lastLevelCompleted - - passedLastCompletedLevel = true - if !level.get('practice') - if view.userLevelStateMap[me.id] - - previousLevelCompleted = view.userLevelStateMap[me.id][level.get('original')] === 'complete' - else - - previousLevelCompleted = false + th + th(data-i18n="clans.status") + th(data-i18n="common.type") + th(data-i18n="resources.level") + th(data-i18n="courses.concepts") + tbody + - var previousLevelCompleted = true; + - var lastLevelCompleted = view.getLastLevelCompleted(); + - var passedLastCompletedLevel = !lastLevelCompleted; + - var levelCount = 0; + each level in view.levels.models + - var levelStatus = null; + - var levelNumber = view.classroom.getLevelNumber(level.get('original'), ++levelCount); + if view.userLevelStateMap[me.id] + - levelStatus = view.userLevelStateMap[me.id][level.get('original')] + tr + td + if previousLevelCompleted || !passedLastCompletedLevel || levelStatus + - var i18nTag = level.isType('course-ladder') ? 'play.compete' : 'home.play'; + button.btn.btn-forest.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-gold.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}. #{i18n(level.attributes, 'name').replace('Course: ', '')} + td + if view.levelConceptMap[level.get('original')] + each concept in view.course.get('concepts') + if view.levelConceptMap[level.get('original')][concept] + span.spr.concept(data-i18n="concepts." + concept) + if level.get('original') === lastLevelCompleted + - passedLastCompletedLevel = true + if !level.get('practice') + if view.userLevelStateMap[me.id] + - previousLevelCompleted = view.userLevelStateMap[me.id][level.get('original')] === 'complete' + else + - previousLevelCompleted = false diff --git a/app/templates/play/level/control_bar.jade b/app/templates/play/level/control-bar-view.jade similarity index 100% rename from app/templates/play/level/control_bar.jade rename to app/templates/play/level/control-bar-view.jade diff --git a/app/templates/play/level/playback.jade b/app/templates/play/level/level-playback-view.jade similarity index 74% rename from app/templates/play/level/playback.jade rename to app/templates/play/level/level-playback-view.jade index 5b150ba41..bf24ae01b 100644 --- a/app/templates/play/level/playback.jade +++ b/app/templates/play/level/level-playback-view.jade @@ -10,15 +10,16 @@ button.btn.btn-xs.btn-inverse.picoctf-hide#volume-button(title="Adjust volume") button.btn.btn-xs.btn-inverse.picoctf-hide#music-button(title="Toggle Music") span ♫ -.scrubber - .scrubber-inner - .progress.secret#timeProgress - .progress-bar - .scrubber-handle - .popover.fade.top.in#timePopover - .arrow - h3.popover-title - .popover-content +if !view.options.level.isType('game-dev') + .scrubber + .scrubber-inner + .progress.secret#timeProgress + .progress-bar + .scrubber-handle + .popover.fade.top.in#timePopover + .arrow + h3.popover-title + .popover-content .btn-group.dropup#playback-settings button.btn.btn-xs.btn-inverse.toggle-fullscreen(title="Toggle fullscreen") diff --git a/app/templates/play/level/modal/game-dev-victory-modal.jade b/app/templates/play/level/modal/game-dev-victory-modal.jade new file mode 100644 index 000000000..75339317a --- /dev/null +++ b/app/templates/play/level/modal/game-dev-victory-modal.jade @@ -0,0 +1,17 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + h3.text-center You beat the game! + +block modal-body-content + .text-center Share this level so your friends and family can play it: + + .share-row.text-center + input#copy-url-input.text-h4.semibold.form-control.input-lg(value=view.shareURL) + button#copy-url-btn.btn.btn-lg.btn-navy-alt + span(data-i18n='sharing.copy_url') + +block modal-footer-content + .text-center + button#replay-game-btn.btn.btn-navy.btn-lg(data-dismiss="modal") Replay Game + a#play-more-codecombat-btn.btn.btn-navy.btn-lg(href="/") Play More CodeCombat diff --git a/app/templates/play/level/play-game-dev-level-view.jade b/app/templates/play/level/play-game-dev-level-view.jade index c4b80c67e..f7d1c89eb 100644 --- a/app/templates/play/level/play-game-dev-level-view.jade +++ b/app/templates/play/level/play-game-dev-level-view.jade @@ -1,38 +1,58 @@ -.container-fluid - .row +- var ready = !(view.state.get('errorMessage') || view.state.get('loading')) + +.container-fluid.style-flat + #game-row.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') + #info-col.col-xs-3 + .panel.panel-default + .panel-body.text-center + 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')}") + if view.level.id && view.session.id + h3.m-y-1= view.level.get('name') + h4 Created by #{view.session.get('creatorName')} + hr + + 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") + if ready + h3 Goals + for goalName in view.state.get('goalNames') + p= goalName + + hr + + h3 How to play: + p Use the mouse to control the hero! + p Click anywhere on the map to move to that location. + p Click on the ogres to attack them. + + if ready + .panel-footer + - var playing = view.state.get('playing') + if playing + button#play-btn.btn.btn-lg.btn-burgandy.btn-block Restart Level + else + button#play-btn.btn.btn-lg.btn-forest.btn-block Play Level + #share-row.m-t-3 + if ready + .panel.panel-default + #share-panel-body.panel-body + div#share-text-div.text-right + b(data-i18n='sharing.share_game') + input#copy-url-input.text-h4.semibold.form-control.input-lg(value=view.state.get('shareURL')) + div#copy-url-div + button#copy-url-btn.btn.btn-lg.btn-navy-alt + span(data-i18n='sharing.copy_url') + + .panel-body + a#play-more-codecombat-btn.btn.btn-lg.btn-navy-alt.pull-right(href="/") Play More CodeCombat diff --git a/app/templates/play/level/tome/cast-button-view.jade b/app/templates/play/level/tome/cast-button-view.jade new file mode 100644 index 000000000..2857d3c6f --- /dev/null +++ b/app/templates/play/level/tome/cast-button-view.jade @@ -0,0 +1,25 @@ +if view.options.level.isType('game-dev') + button.btn.btn-lg.btn-illustrated.btn-success.game-dev-play-btn + span(data-i18n="common.play") + button.btn.btn-lg.btn-illustrated.btn-success.done-button.secret + span(data-i18n="play_level.done") + +else + + button.btn.btn-lg.btn-illustrated.cast-button(title=view.castVerbose()) + span(data-i18n="play_level.tome_run_button_ran") Ran + + if !view.observing + if view.mirror + .ladder-submission-view + else + button.btn.btn-lg.btn-illustrated.submit-button(title=view.castRealTimeVerbose()) + span(data-i18n="play_level.tome_submit_button") Submit + span.spl.secret.submit-again-time + + button.btn.btn-lg.btn-illustrated.btn-success.done-button.secret + span(data-i18n="play_level.done") Done + + if view.autoSubmitsToLadder + .hidden + .ladder-submission-view diff --git a/app/templates/play/level/tome/cast_button.jade b/app/templates/play/level/tome/cast_button.jade deleted file mode 100644 index 6edff6268..000000000 --- a/app/templates/play/level/tome/cast_button.jade +++ /dev/null @@ -1,17 +0,0 @@ -button.btn.btn-lg.btn-illustrated.cast-button(title=view.castVerbose()) - span(data-i18n="play_level.tome_run_button_ran") Ran - -if !view.observing - if view.mirror - .ladder-submission-view - else - button.btn.btn-lg.btn-illustrated.submit-button(title=view.castRealTimeVerbose()) - span(data-i18n="play_level.tome_submit_button") Submit - span.spl.secret.submit-again-time - - button.btn.btn-lg.btn-illustrated.btn-success.done-button.secret - span(data-i18n="play_level.done") Done - - if view.autoSubmitsToLadder - .hidden - .ladder-submission-view diff --git a/app/templates/play/play-level-view.jade b/app/templates/play/play-level-view.jade index 77508fcfb..ad0687b5b 100644 --- a/app/templates/play/play-level-view.jade +++ b/app/templates/play/play-level-view.jade @@ -49,7 +49,20 @@ if view.showAds() #level-dialogue-view - button.btn.btn-lg.btn-warning.banner.header-font#stop-real-time-playback-button(title="Stop real-time playback", data-i18n="play_level.skip") Skip + + button.btn.btn-lg.btn-warning.banner.header-font#stop-real-time-playback-button(title="Stop real-time playback") + if view.level && view.level.isType('game-dev') + | Back to coding + else + span(data-i18n="play_level.skip") + + #how-to-play-game-dev-panel.panel.panel-default.hide + .panel-heading + h3.panel-title How to play: + .panel-body + p Use the mouse to control the hero! + p Click anywhere on the map to move to that location. + p Click on the ogres to attack them. .hints-view.hide diff --git a/app/views/courses/CourseDetailsView.coffee b/app/views/courses/CourseDetailsView.coffee index 2f7e4c4f4..ce2dd4af9 100644 --- a/app/views/courses/CourseDetailsView.coffee +++ b/app/views/courses/CourseDetailsView.coffee @@ -14,7 +14,6 @@ storage = require 'core/storage' module.exports = class CourseDetailsView extends RootView id: 'course-details-view' template: template - teacherMode: false memberSort: 'nameAsc' events: @@ -24,7 +23,6 @@ module.exports = class CourseDetailsView extends RootView constructor: (options, @courseID, @courseInstanceID) -> super options - @ownedClassrooms = new Classrooms() @courses = new Courses() @course = new Course() @levelSessions = new LevelSessions() @@ -34,7 +32,6 @@ module.exports = class CourseDetailsView extends RootView @levels = new Levels() @courseInstances = new CourseInstances() - @supermodel.trackRequest @ownedClassrooms.fetchMine({data: {project: '_id'}}) @supermodel.trackRequest(@courses.fetch().then(=> @course = @courses.get(@courseID) )) @@ -42,8 +39,6 @@ module.exports = class CourseDetailsView extends RootView @supermodel.trackRequest(@courseInstance.fetch().then(=> return if @destroyed - @teacherMode = @courseInstance.get('ownerID') is me.id - @owner = new User({_id: @courseInstance.get('ownerID')}) @supermodel.trackRequest(@owner.fetch()) diff --git a/app/views/play/level/ControlBarView.coffee b/app/views/play/level/ControlBarView.coffee index bda779e4d..4eae85839 100644 --- a/app/views/play/level/ControlBarView.coffee +++ b/app/views/play/level/ControlBarView.coffee @@ -1,5 +1,5 @@ CocoView = require 'views/core/CocoView' -template = require 'templates/play/level/control_bar' +template = require 'templates/play/level/control-bar-view' {me} = require 'core/auth' Campaign = require 'models/Campaign' diff --git a/app/views/play/level/LevelGoalsView.coffee b/app/views/play/level/LevelGoalsView.coffee index d4749ae88..b6b74a8ba 100644 --- a/app/views/play/level/LevelGoalsView.coffee +++ b/app/views/play/level/LevelGoalsView.coffee @@ -103,6 +103,7 @@ module.exports = class LevelGoalsView extends CocoView @updatePlacement() onSurfacePlaybackEnded: -> + return if @level.isType('game-dev') @playbackEnded = true @updateHeight() @$el.addClass 'brighter' @@ -140,7 +141,7 @@ module.exports = class LevelGoalsView extends CocoView playToggleSound: (sound) => return if @destroyed - @playSound sound + @playSound sound unless @options.level.isType('game-dev') @soundTimeout = null onSetLetterbox: (e) -> diff --git a/app/views/play/level/LevelPlaybackView.coffee b/app/views/play/level/LevelPlaybackView.coffee index 811d431a1..b7be3c815 100644 --- a/app/views/play/level/LevelPlaybackView.coffee +++ b/app/views/play/level/LevelPlaybackView.coffee @@ -1,5 +1,5 @@ CocoView = require 'views/core/CocoView' -template = require 'templates/play/level/playback' +template = require 'templates/play/level/level-playback-view' {me} = require 'core/auth' module.exports = class LevelPlaybackView extends CocoView @@ -50,7 +50,7 @@ module.exports = class LevelPlaybackView extends CocoView afterRender: -> super() @$progressScrubber = $('.scrubber .progress', @$el) - @hookUpScrubber() + @hookUpScrubber() unless @options.level.isType('game-dev') @updateMusicButton() $(window).on('resize', @onWindowResize) ua = navigator.userAgent.toLowerCase() @@ -154,7 +154,7 @@ module.exports = class LevelPlaybackView extends CocoView ended = button.hasClass 'ended' changed = button.hasClass('playing') isnt @playing button.toggleClass('playing', @playing and not ended).toggleClass('paused', not @playing and not ended) - @playSound (if @playing then 'playback-play' else 'playback-pause') + @playSound (if @playing then 'playback-play' else 'playback-pause') unless @options.level.isType('game-dev') return # don't stripe the bar bar = @$el.find '.scrubber .progress' bar.toggleClass('progress-striped', @playing and not ended).toggleClass('active', @playing and not ended) diff --git a/app/views/play/level/PlayGameDevLevelView.coffee b/app/views/play/level/PlayGameDevLevelView.coffee index 9de9fb440..f4c4b17bc 100644 --- a/app/views/play/level/PlayGameDevLevelView.coffee +++ b/app/views/play/level/PlayGameDevLevelView.coffee @@ -10,15 +10,24 @@ ThangType = require 'models/ThangType' Level = require 'models/Level' LevelSession = require 'models/LevelSession' State = require 'models/State' +utils = require 'core/utils' +urls = require 'core/urls' +Course = require 'models/Course' +GameDevVictoryModal = require './modal/GameDevVictoryModal' TEAM = 'humans' module.exports = class PlayGameDevLevelView extends RootView id: 'play-game-dev-level-view' template: require 'templates/play/level/play-game-dev-level-view' + + subscriptions: + 'god:new-world-created': 'onNewWorld' events: 'click #play-btn': 'onClickPlayButton' + 'click #copy-url-btn': 'onClickCopyURLButton' + 'click #play-more-codecombat-btn': 'onClickPlayMoreCodeCombatButton' initialize: (@options, @levelID, @sessionID) -> @state = new State({ @@ -32,9 +41,10 @@ module.exports = class PlayGameDevLevelView extends RootView @session = new LevelSession() @gameUIState = new GameUIState() @courseID = @getQueryVariable 'course' - @god = new God({ @gameUIState }) + @god = new God({ @gameUIState, indefiniteLength: true }) @levelLoader = new LevelLoader({ @supermodel, @levelID, @sessionID, observing: true, team: TEAM, @courseID }) - @listenTo @state, 'change', _.debounce(-> @renderSelectors('#info-col')) + @supermodel.setMaxProgress 1 # Hack, why are we setting this to 0.2 in LevelLoader? + @listenTo @state, 'change', _.debounce @renderAllButCanvas @levelLoader.loadWorldNecessities() @@ -50,6 +60,7 @@ module.exports = class PlayGameDevLevelView extends RootView @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 + @renderAllButCanvas() @supermodel.finishLoading() .then (supermodel) => @@ -61,7 +72,9 @@ module.exports = class PlayGameDevLevelView extends RootView thangTypes: @supermodel.getModels(ThangType) levelType: @level.get('type', true) @gameUIState + resizeStrategy: 'wrapper-size' }) + @listenTo @surface, 'resize', @onSurfaceResize worldBounds = @world.getBounds() bounds = [{x: worldBounds.left, y: worldBounds.top}, {x: worldBounds.right, y: worldBounds.bottom}] @surface.camera.setBounds(bounds) @@ -70,18 +83,61 @@ module.exports = class PlayGameDevLevelView extends RootView @scriptManager.initializeCamera() @renderSelectors '#info-col' @spells = @session.generateSpellsObject level: @level - @state.set('loading', false) + goalNames = (utils.i18n(goal, 'name') for goal in @goalManager.goals) + + course = if @courseID then new Course({_id: @courseID}) else null + shareURL = urls.playDevLevel({@level, @session, course}) + + @state.set({ + loading: false + goalNames + shareURL + }) + @eventProperties = { + category: 'Play GameDev Level' + @courseID + sessionID: @session.id + levelID: @level.id + levelSlug: @level.get('slug') + } + window.tracker?.trackEvent 'Play GameDev Level - Load', @eventProperties, ['Mixpanel'] + @god.createWorld(@spells, false, false, true) - .catch ({message}) => - console.error message - @state.set('errorMessage', message) + .catch (e) => + throw e if e.stack + @state.set('errorMessage', e.message) onClickPlayButton: -> @god.createWorld(@spells, false, true) Backbone.Mediator.publish('playback:real-time-playback-started', {}) Backbone.Mediator.publish('level:set-playing', {playing: true}) + action = if @state.get('playing') then 'Play GameDev Level - Restart Level' else 'Play GameDev Level - Start Level' + window.tracker?.trackEvent(action, @eventProperties, ['Mixpanel']) @state.set('playing', true) + onClickCopyURLButton: -> + @$('#copy-url-input').val(@state.get('shareURL')).select() + @tryCopy() + window.tracker?.trackEvent('Play GameDev Level - Copy URL', @eventProperties, ['Mixpanel']) + + onClickPlayMoreCodeCombatButton: -> + window.tracker?.trackEvent('Play GameDev Level - Click Play More CodeCombat', @eventProperties, ['Mixpanel']) + + onSurfaceResize: ({height}) -> + @state.set('surfaceHeight', height) + + renderAllButCanvas: -> + @renderSelectors('#info-col', '#share-row') + height = @state.get('surfaceHeight') + if height + @$el.find('#info-col').css('height', @state.get('surfaceHeight')) + + onNewWorld: (e) -> + if @goalManager.checkOverallStatus() is 'success' + modal = new GameDevVictoryModal({ shareURL: @state.get('shareURL'), @eventProperties }) + @openModalView(modal) + modal.once 'replay', @onClickPlayButton, @ + destroy: -> @levelLoader?.destroy() @surface?.destroy() diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index f491dea86..c844e720f 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -142,7 +142,12 @@ module.exports = class PlayLevelView extends RootView @listenTo @levelLoader, 'world-necessity-load-failed', @onWorldNecessityLoadFailed onLevelLoaded: (e) -> - @god = new God({@gameUIState}) unless e.level.isType('web-dev') + return if @destroyed + unless e.level.isType('web-dev') + @god = new God({ + @gameUIState + indefiniteLength: e.level.isType('game-dev') + }) @setupGod() if @waitingToSetUpGod trackLevelLoadEnd: -> @@ -206,6 +211,7 @@ module.exports = class PlayLevelView extends RootView @$el.addClass 'web-dev' # Hide some of the elements we won't be using return @world = @levelLoader.world + @$el.addClass 'game-dev' if @level.isType('game-dev') @$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 @@ -214,6 +220,7 @@ module.exports = class PlayLevelView extends RootView @worldLoadFakeResources = [] # first element (0) is 1%, last (99) is 100% for percent in [1 .. 100] @worldLoadFakeResources.push @supermodel.addSomethingResource 1 + @renderSelectors '#stop-real-time-playback-button' onWorldLoadProgressChanged: (e) -> return unless e.god is @god @@ -354,6 +361,7 @@ module.exports = class PlayLevelView extends RootView levelType: @level.get('type', true) stayVisible: @showAds() @gameUIState + @level # TODO: change from levelType to level } @surface = new Surface(@world, normalSurface, webGLSurface, surfaceOptions) worldBounds = @world.getBounds() @@ -626,10 +634,12 @@ module.exports = class PlayLevelView extends RootView # Real-time playback onRealTimePlaybackStarted: (e) -> @$el.addClass('real-time').focus() + @$('#how-to-play-game-dev-panel').removeClass('hide') if @level.isType('game-dev') @onWindowResize() onRealTimePlaybackEnded: (e) -> return unless @$el.hasClass 'real-time' + @$('#how-to-play-game-dev-panel').addClass('hide') if @level.isType('game-dev') @$el.removeClass 'real-time' @onWindowResize() if @world.frames.length is @world.totalFrames and not @surface.countdownScreen?.showing diff --git a/app/views/play/level/WebSurfaceView.coffee b/app/views/play/level/WebSurfaceView.coffee index 51b40e93e..56d2d7559 100644 --- a/app/views/play/level/WebSurfaceView.coffee +++ b/app/views/play/level/WebSurfaceView.coffee @@ -66,9 +66,9 @@ module.exports = class WebSurfaceView extends CocoView 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 } + wrappedStyles = deku.element('head', {}, styles) + wrappedScripts = deku.element('head', {}, scripts) + return { virtualDom, scripts: wrappedScripts, styles: wrappedStyles } combineNodes: (type, nodes) -> if _.any(nodes, (node) -> node.type isnt type) diff --git a/app/views/play/level/modal/CourseVictoryModal.coffee b/app/views/play/level/modal/CourseVictoryModal.coffee index 0e34d2744..1e320698f 100644 --- a/app/views/play/level/modal/CourseVictoryModal.coffee +++ b/app/views/play/level/modal/CourseVictoryModal.coffee @@ -108,9 +108,9 @@ module.exports = class CourseVictoryModal extends ModalView onDone: -> window.tracker?.trackEvent 'Play Level Victory Modal Done', category: 'Students', levelSlug: @level.get('slug'), ['Mixpanel'] if me.isSessionless() - link = "/teachers/courses" + link = '/teachers/courses' else - link = "/courses/#{@courseID}/#{@courseInstanceID}" + link = '/courses' application.router.navigate(link, {trigger: true}) onLadder: -> diff --git a/app/views/play/level/modal/GameDevVictoryModal.coffee b/app/views/play/level/modal/GameDevVictoryModal.coffee new file mode 100644 index 000000000..cb732f0a6 --- /dev/null +++ b/app/views/play/level/modal/GameDevVictoryModal.coffee @@ -0,0 +1,25 @@ +ModalView = require 'views/core/ModalView' + +category = 'Play GameDev Level' + +module.exports = class GameDevVictoryModal extends ModalView + id: 'game-dev-victory-modal' + template: require 'templates/play/level/modal/game-dev-victory-modal' + + events: + 'click #replay-game-btn': 'onClickReplayButton' + 'click #copy-url-btn': 'onClickCopyURLButton' + 'click #play-more-codecombat-btn': 'onClickPlayMoreCodeCombatButton' + + initialize: ({@shareURL, @eventProperties}) -> + + onClickReplayButton: -> + @trigger 'replay' + + onClickCopyURLButton: -> + @$('#copy-url-input').val(@shareURL).select() + @tryCopy() + window.tracker?.trackEvent('Play GameDev Victory Modal - Copy URL', @eventProperties, ['Mixpanel']) + + onClickPlayMoreCodeCombatButton: -> + window.tracker?.trackEvent('Play GameDev Victory Modal - Click Play More CodeCombat', @eventProperties, ['Mixpanel']) diff --git a/app/views/play/level/modal/ProgressView.coffee b/app/views/play/level/modal/ProgressView.coffee index 0c35c61da..cfc5bb0be 100644 --- a/app/views/play/level/modal/ProgressView.coffee +++ b/app/views/play/level/modal/ProgressView.coffee @@ -1,5 +1,6 @@ CocoView = require 'views/core/CocoView' utils = require 'core/utils' +urls = require 'core/urls' module.exports = class ProgressView extends CocoView @@ -25,8 +26,7 @@ module.exports = class ProgressView extends CocoView @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 + @shareURL = urls.playDevLevel({@level, @session, @course}) onClickDoneButton: -> @trigger 'done' @@ -38,5 +38,19 @@ module.exports = class ProgressView extends CocoView @trigger 'ladder' onClickShareLevelButton: -> + if _.string.startsWith(@course.get('slug'), 'game-dev') + name = 'Student Game Dev - Copy URL' + category = 'GameDev' + else + name = 'Student Web Dev - Copy URL' + category = 'WebDev' + eventProperties = { + levelID: @level.id + levelSlug: @level.get('slug') + classroomID: @classroom.id + courseID: @course.id + category + } + window.tracker?.trackEvent name, eventProperties, ['MixPanel'] @$('#share-level-input').val(@shareURL).select() @tryCopy() diff --git a/app/views/play/level/tome/CastButtonView.coffee b/app/views/play/level/tome/CastButtonView.coffee index f29520726..2d1427142 100644 --- a/app/views/play/level/tome/CastButtonView.coffee +++ b/app/views/play/level/tome/CastButtonView.coffee @@ -1,5 +1,5 @@ CocoView = require 'views/core/CocoView' -template = require 'templates/play/level/tome/cast_button' +template = require 'templates/play/level/tome/cast-button-view' {me} = require 'core/auth' LadderSubmissionView = require 'views/play/common/LadderSubmissionView' LevelSession = require 'models/LevelSession' @@ -12,6 +12,7 @@ module.exports = class CastButtonView extends CocoView 'click .cast-button': 'onCastButtonClick' 'click .submit-button': 'onCastRealTimeButtonClick' 'click .done-button': 'onDoneButtonClick' + 'click .game-dev-play-btn': 'onClickGameDevPlayButton' subscriptions: 'tome:spell-changed': 'onSpellChanged' @@ -74,6 +75,9 @@ module.exports = class CastButtonView extends CocoView Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} @updateReplayability() + onClickGameDevPlayButton: -> + Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} + 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 @@ -86,7 +90,7 @@ module.exports = class CastButtonView extends CocoView return if e.preload @casting = true if @hasStartedCastingOnce # Don't play this sound the first time - @playSound 'cast', 0.5 + @playSound 'cast', 0.5 unless @options.level.isType('game-dev') @hasStartedCastingOnce = true @updateCastButton() @@ -98,7 +102,7 @@ module.exports = class CastButtonView extends CocoView onNewWorld: (e) -> @casting = false if @hasCastOnce # Don't play this sound the first time - @playSound 'cast-end', 0.5 + @playSound 'cast-end', 0.5 unless @options.level.isType('game-dev') # Worked great for live beginner tournaments, but probably annoying for asynchronous tournament mode. myHeroID = if me.team is 'ogres' then 'Hero Placeholder 1' else 'Hero Placeholder' if @autoSubmitsToLadder and not e.world.thangMap[myHeroID]?.errorsOut and not me.get('anonymous') @@ -113,7 +117,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') or @options.level.isType('web-dev') + if @options.level.get('hidesRealTimePlayback') or @options.level.isType('web-dev', 'game-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. diff --git a/app/views/play/level/tome/DocFormatter.coffee b/app/views/play/level/tome/DocFormatter.coffee index b09d7092b..e8eb4b693 100644 --- a/app/views/play/level/tome/DocFormatter.coffee +++ b/app/views/play/level/tome/DocFormatter.coffee @@ -58,6 +58,7 @@ module.exports = class DocFormatter when 'java' then 'hero' when 'coffeescript' then '@' else (if @options.useHero then 'hero' else 'this') + ownerName = 'game' if @options.level.isType('game-dev') if @doc.type is 'function' [docName, args] = @getDocNameAndArguments() argNames = args.join ', ' diff --git a/app/views/play/level/tome/SpellPaletteEntryView.coffee b/app/views/play/level/tome/SpellPaletteEntryView.coffee index 1b400b673..07bd109a7 100644 --- a/app/views/play/level/tome/SpellPaletteEntryView.coffee +++ b/app/views/play/level/tome/SpellPaletteEntryView.coffee @@ -85,9 +85,8 @@ module.exports = class SpellPaletteEntryView extends CocoView Backbone.Mediator.publish 'tome:palette-pin-toggled', entry: @, pinned: @popoverPinned onClick: (e) => - 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 + if @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder') + # Jiggle instead of pin for hero/course levels jigglyPopover = $('.spell-palette-popover.popover') jigglyPopover.addClass 'jiggling' pauseJiggle = => diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee index 8a950d77b..26cdbf2ef 100644 --- a/app/views/play/level/tome/SpellView.coffee +++ b/app/views/play/level/tome/SpellView.coffee @@ -69,6 +69,7 @@ module.exports = class SpellView extends CocoView @highlightCurrentLine = _.throttle @highlightCurrentLine, 100 $(window).on 'resize', @onWindowResize @observing = @session.get('creator') isnt me.id + afterRender: -> super() @createACE() @@ -93,6 +94,7 @@ module.exports = class SpellView extends CocoView @aceSession.setUseWrapMode true @aceSession.setNewLineMode 'unix' @aceSession.setUseSoftTabs true + @aceSession.on 'changeAnnotation', @onChangeAnnotation @ace.setTheme 'ace/theme/textmate' @ace.setDisplayIndentGuides false @ace.setShowPrintMargin false @@ -125,7 +127,7 @@ module.exports = class SpellView extends CocoView addCommand name: 'run-code' bindKey: {win: 'Shift-Enter|Ctrl-Enter', mac: 'Shift-Enter|Command-Enter|Ctrl-Enter'} - exec: -> Backbone.Mediator.publish 'tome:manual-cast', {} + exec: => Backbone.Mediator.publish 'tome:manual-cast', {realTime: @options.level.isType('game-dev')} unless @observing addCommand name: 'run-code-real-time' @@ -583,8 +585,8 @@ module.exports = class SpellView extends CocoView # @addZatannaSnippets() @highlightCurrentLine() - cast: (preload=false, realTime=false) -> - Backbone.Mediator.publish 'tome:cast-spell', spell: @spell, thang: @thang, preload: preload, realTime: realTime + cast: (preload=false, realTime=false, justBegin=false) -> + Backbone.Mediator.publish 'tome:cast-spell', { @spell, @thang, preload, realTime, justBegin } notifySpellChanged: => return if @destroyed @@ -710,7 +712,7 @@ module.exports = class SpellView extends CocoView @aceDoc.removeListener 'change', @onCodeChangeMetaHandler if @onCodeChangeMetaHandler onSignificantChange = [] onAnyChange = [ - _.debounce @updateAether, 500 + _.debounce @updateAether, if @options.level.isType('game-dev') then 10 else 500 _.debounce @notifyEditingEnded, 1000 _.throttle @notifyEditingBegan, 250 _.throttle @notifySpellChanged, 300 @@ -794,6 +796,18 @@ module.exports = class SpellView extends CocoView else finishUpdatingAether(aether) + # NOTE! Because this alone causes the doctype annotation to flicker, + # all info annotations have been hidden with CSS in spell.sass + # If we ever want info annotations back, we need to remove that. + # + # This function itself removes the unwanted annotations on a later tick. + onChangeAnnotation: (event, session) -> + unfilteredAnnotations = session.getAnnotations() + filteredAnnotations = _.remove unfilteredAnnotations, (annotation) -> + annotation.text is 'Start tag seen without seeing a doctype first. Expected e.g. .' + if filteredAnnotations.length < unfilteredAnnotations.length + session.setAnnotations(filteredAnnotations) + # Clear annotations and highlights generated by Aether, but not by the ACE worker clearAetherDisplay: -> problem.destroy() for problem in @problems @@ -873,13 +887,18 @@ module.exports = class SpellView extends CocoView # - Go after specified delay if a) and not b) or c) guessWhetherFinished: (aether) -> valid = not aether.getAllProblems().length + return unless valid cursorPosition = @ace.getCursorPosition() currentLine = _.string.rtrim(@aceDoc.$lines[cursorPosition.row].replace(@singleLineCommentRegex(), '')) # trim // unless inside " endOfLine = cursorPosition.column >= currentLine.length # just typed a semicolon or brace, for example beginningOfLine = not currentLine.substr(0, cursorPosition.column).trim().length # uncommenting code, for example incompleteThis = /^(s|se|sel|self|t|th|thi|this)$/.test currentLine.trim() #console.log "finished=#{valid and (endOfLine or beginningOfLine) and not incompleteThis}", valid, endOfLine, beginningOfLine, incompleteThis, cursorPosition, currentLine.length, aether, new Date() - 0, currentLine - if valid and (endOfLine or beginningOfLine) and not incompleteThis + if not incompleteThis and @options.level.isType('game-dev') + # TODO: Improve gamedev autocast speed + @spell.transpile @getSource() + @cast(false, false, true) + else if (endOfLine or beginningOfLine) and not incompleteThis @preload() singleLineCommentRegex: -> @@ -1270,3 +1289,4 @@ commentStarts = coffeescript: '#' lua: '--' java: '//' + html: '