diff --git a/app/assets/javascripts/workers/aether_worker.js b/app/assets/javascripts/workers/aether_worker.js index a75469a29..a1d8e3038 100644 --- a/app/assets/javascripts/workers/aether_worker.js +++ b/app/assets/javascripts/workers/aether_worker.js @@ -2,7 +2,7 @@ var window = self; var Global = self; importScripts("/javascripts/lodash.js", "/javascripts/aether.js"); -console.log("Aether Tome worker has finished importing scripts."); +//console.log("Aether Tome worker has finished importing scripts."); var aethers = {}; var createAether = function (spellKey, options) diff --git a/app/assets/javascripts/workers/worker_world.js b/app/assets/javascripts/workers/worker_world.js index dd1142eff..c7a6ee560 100644 --- a/app/assets/javascripts/workers/worker_world.js +++ b/app/assets/javascripts/workers/worker_world.js @@ -300,7 +300,7 @@ self.setupDebugWorldToRunUntilFrame = function (args) { } Math.random = self.debugWorld.rand.randf; // so user code is predictable Aether.replaceBuiltin("Math", Math); - replacedLoDash = _.runInContext(self); + var replacedLoDash = _.runInContext(self); for(var key in replacedLoDash) _[key] = replacedLoDash[key]; } @@ -346,6 +346,7 @@ self.runWorld = function runWorld(args) { self.world.loadFromLevel(args.level, true); self.world.preloading = args.preload; self.world.headless = args.headless; + self.world.realTime = args.realTime; self.goalManager = new GoalManager(self.world); self.goalManager.setGoals(args.goals); self.goalManager.setCode(args.userCodeMap); @@ -358,17 +359,27 @@ self.runWorld = function runWorld(args) { } Math.random = self.world.rand.randf; // so user code is predictable Aether.replaceBuiltin("Math", Math); - replacedLoDash = _.runInContext(self); + var replacedLoDash = _.runInContext(self); for(var key in replacedLoDash) _[key] = replacedLoDash[key]; self.postMessage({type: 'start-load-frames'}); self.world.loadFrames(self.onWorldLoaded, self.onWorldError, self.onWorldLoadProgress); }; +self.serializeFramesSoFar = function serializeFramesSoFar() { + if(!self.world) return; // We probably got this message late, after delivering the world. + if(self.world.framesSerializedSoFar == self.world.frames.length) return; + self.onWorldLoaded(); + self.world.framesSerializedSoFar = self.world.frames.length; +}; + self.onWorldLoaded = function onWorldLoaded() { - self.goalManager.worldGenerationEnded(); + if(self.world.framesSerializedSoFar == self.world.frames.length) return; + if(self.world.ended) + self.goalManager.worldGenerationEnded(); var goalStates = self.goalManager.getGoalStates(); - self.postMessage({type: 'end-load-frames', goalStates: goalStates}); + if(self.world.ended) + self.postMessage({type: 'end-load-frames', goalStates: goalStates}); var t1 = new Date(); var diff = t1 - self.t0; if (self.world.headless) @@ -381,10 +392,12 @@ self.onWorldLoaded = function onWorldLoaded() { catch(error) { console.log("World serialization error:", error.toString() + "\n" + error.stack || error.stackTrace); } + var t2 = new Date(); //console.log("About to transfer", serialized.serializedWorld.trackedPropertiesPerThangValues, serialized.transferableObjects); + var messageType = self.world.ended ? 'new-world' : 'some-frames-serialized'; try { - var message = {type: 'new-world', serialized: serialized.serializedWorld, goalStates: goalStates}; + var message = {type: messageType, serialized: serialized.serializedWorld, goalStates: goalStates, startFrame: serialized.startFrame, endFrame: serialized.endFrame}; if(transferableSupported) self.postMessage(message, serialized.transferableObjects); else @@ -393,11 +406,14 @@ self.onWorldLoaded = function onWorldLoaded() { catch(error) { console.log("World delivery error:", error.toString() + "\n" + error.stack || error.stackTrace); } - var t3 = new Date(); - console.log("And it was so: (" + (diff / self.world.totalFrames).toFixed(3) + "ms per frame,", self.world.totalFrames, "frames)\nSimulation :", diff + "ms \nSerialization:", (t2 - t1) + "ms\nDelivery :", (t3 - t2) + "ms"); - self.world.goalManager.destroy(); - self.world.destroy(); - self.world = null; + + if(self.world.ended) { + var t3 = new Date(); + console.log("And it was so: (" + (diff / self.world.totalFrames).toFixed(3) + "ms per frame,", self.world.totalFrames, "frames)\nSimulation :", diff + "ms \nSerialization:", (t2 - t1) + "ms\nDelivery :", (t3 - t2) + "ms"); + self.world.goalManager.destroy(); + self.world.destroy(); + self.world = null; + } }; self.onWorldError = function onWorldError(error) { @@ -443,6 +459,11 @@ self.finalizePreload = function finalizePreload() { self.world.finalizePreload(self.onWorldLoaded); }; +self.addFlagEvent = function addFlagEvent(flagEvent) { + if(!self.world) return; + self.world.addFlagEvent(flagEvent); +}; + self.addEventListener('message', function(event) { self[event.data.func](event.data.args); }); diff --git a/app/lib/Angel.coffee b/app/lib/Angel.coffee index 3b5f59a75..59547ca16 100644 --- a/app/lib/Angel.coffee +++ b/app/lib/Angel.coffee @@ -12,6 +12,9 @@ module.exports = class Angel extends CocoClass infiniteLoopTimeoutDuration: 7500 # wait this long for a response when checking abortTimeoutDuration: 500 # give in-process or dying workers this long to give up + subscriptions: + 'level:flag-updated': 'onFlagEvent' + constructor: (@shared) -> super() @say 'Got my wings.' @@ -64,14 +67,6 @@ module.exports = class Angel extends CocoClass clearTimeout @condemnTimeout @beholdGoalStates event.data.goalStates # Work ends here if we're headless. - # We pay attention to certain progress indicators as the world loads. - when 'world-load-progress-changed' - Backbone.Mediator.publish 'god:world-load-progress-changed', event.data - when 'console-log' - @log event.data.args... - when 'user-code-problem' - Backbone.Mediator.publish 'god:user-code-problem', problem: event.data.problem - # We have to abort like an infinite loop if we see one of these; they're not really recoverable when 'non-user-code-problem' Backbone.Mediator.publish 'god:non-user-code-problem', problem: event.data.problem @@ -80,9 +75,7 @@ module.exports = class Angel extends CocoClass else @fireWorker() - # Either the world finished simulating successfully, or we abort the worker. - when 'new-world' - @beholdWorld event.data.serialized, event.data.goalStates + # If it didn't finish simulating successfully, or we abort the worker. when 'abort' @say 'Aborted.', event.data clearTimeout @abortTimeout @@ -91,6 +84,23 @@ module.exports = class Angel extends CocoClass _.remove @shared.busyAngels, @ @doWork() + # We pay attention to certain progress indicators as the world loads. + when 'console-log' + @log event.data.args... + when 'user-code-problem' + Backbone.Mediator.publish 'god:user-code-problem', problem: event.data.problem + when 'world-load-progress-changed' + Backbone.Mediator.publish 'god:world-load-progress-changed', event.data + unless event.data.progress is 1 or @work.preload or @work.headless or @work.synchronous or @deserializationQueue.length or @shared.firstWorld + @worker.postMessage func: 'serializeFramesSoFar' # Stream it! + + # We have some or all of the frames serialized, so let's send the (partially?) simulated world to the Surface. + when 'some-frames-serialized', 'new-world' + deserializationArgs = [event.data.serialized, event.data.goalStates, event.data.startFrame, event.data.endFrame, @streamingWorld] + @deserializationQueue.push deserializationArgs + if @deserializationQueue.length is 1 + @beholdWorld deserializationArgs... + else @log 'Received unsupported message:', event.data @@ -99,27 +109,37 @@ module.exports = class Angel extends CocoClass Backbone.Mediator.publish 'god:goals-calculated', goalStates: goalStates @finishWork() if @shared.headless - beholdWorld: (serialized, goalStates) -> + beholdWorld: (serialized, goalStates, startFrame, endFrame, streamingWorld) -> 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 - World.deserialize serialized, @shared.worldClassMap, @shared.lastSerializedWorldFrames, @finishBeholdingWorld(goalStates) + World.deserialize serialized, @shared.worldClassMap, @shared.lastSerializedWorldFrames, @finishBeholdingWorld(goalStates), startFrame, endFrame, streamingWorld window.BOX2D_ENABLED = true @shared.lastSerializedWorldFrames = serialized.frames finishBeholdingWorld: (goalStates) -> (world) => return if @aborting - world.findFirstChangedFrame @shared.world - @shared.world = world - errorCount = (t for t in @shared.world.thangs when t.errorsOut).length - Backbone.Mediator.publish 'god:new-world-created', world: world, firstWorld: @shared.firstWorld, errorCount: errorCount, goalStates: goalStates, team: me.team - for scriptNote in @shared.world.scriptNotes - Backbone.Mediator.publish scriptNote.channel, scriptNote.event - @shared.goalManager?.world = world - @finishWork() + @streamingWorld = world + finished = world.frames.length is world.totalFrames + firstChangedFrame = world.findFirstChangedFrame @shared.world + eventType = if finished then 'god:new-world-created' else 'god:streaming-world-updated' + if finished + @shared.world = world + Backbone.Mediator.publish eventType, world: world, firstWorld: @shared.firstWorld, goalStates: goalStates, team: me.team, firstChangedFrame: firstChangedFrame + if finished + for scriptNote in @shared.world.scriptNotes + Backbone.Mediator.publish scriptNote.channel, scriptNote.event + @shared.goalManager?.world = world + @finishWork() + else + @deserializationQueue.shift() # Finished with this deserialization. + if deserializationArgs = @deserializationQueue[0] # Start another? + @beholdWorld deserializationArgs... finishWork: -> + @streamingWorld = null @shared.firstWorld = false + @deserializationQueue = [] @running = false _.remove @shared.busyAngels, @ @doWork() @@ -127,6 +147,7 @@ module.exports = class Angel extends CocoClass finalizePreload: -> @say 'Finalize preload.' @worker.postMessage func: 'finalizePreload' + @work.preload = false infinitelyLooped: => @say 'On infinitely looped! Aborting?', @aborting @@ -145,6 +166,7 @@ module.exports = class Angel extends CocoClass @say 'Running world...' @running = true @shared.busyAngels.push @ + @deserializationQueue = [] @worker.postMessage func: 'runWorld', args: @work clearTimeout @purgatoryTimer @say 'Infinite loop timer started at interval of', @infiniteLoopIntervalDuration @@ -158,6 +180,8 @@ module.exports = class Angel extends CocoClass @say 'Aborting...' @running = false @work = null + @streamingWorld = null + @deserializationQueue = null _.remove @shared.busyAngels, @ @abortTimeout = _.delay @fireWorker, @abortTimeoutDuration @aborting = true @@ -184,6 +208,10 @@ module.exports = class Angel extends CocoClass @worker.addEventListener 'message', @onWorkerMessage @worker.creationTime = new Date() + onFlagEvent: (e) -> + return unless @running and @work.realTime + @worker.postMessage func: 'addFlagEvent', args: e + #### Synchronous code for running worlds on main thread (profiling / IE9) #### simulateSync: (work) => diff --git a/app/lib/God.coffee b/app/lib/God.coffee index ffe2ad726..de1f6b685 100644 --- a/app/lib/God.coffee +++ b/app/lib/God.coffee @@ -53,9 +53,9 @@ module.exports = class God extends CocoClass setWorldClassMap: (worldClassMap) -> @angelsShare.worldClassMap = worldClassMap onTomeCast: (e) -> - @createWorld e.spells, e.preload + @createWorld e.spells, e.preload, e.realTime - createWorld: (spells, preload=false) -> + createWorld: (spells, preload=false, realTime=false) -> console.log "#{@nick}: Let there be light upon #{@level.name}! (preload: #{preload})" userCodeMap = @getUserCodeMap spells @@ -84,12 +84,14 @@ module.exports = class God extends CocoClass headless: @angelsShare.headless preload: preload synchronous: not Worker? # Profiling world simulation is easier on main thread, or we are IE9. + realTime: realTime angel.workIfIdle() for angel in @angelsShare.angels 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 diff --git a/app/lib/LevelBus.coffee b/app/lib/LevelBus.coffee index 7ea0837c1..d79451500 100644 --- a/app/lib/LevelBus.coffee +++ b/app/lib/LevelBus.coffee @@ -196,7 +196,9 @@ module.exports = class LevelBus extends Bus onNewGoalStates: ({goalStates})-> state = @session.get 'state' - unless utils.kindaEqual state.goalStates, goalStates # Only save when goals really change + unless utils.kindaEqual state.goalStates, goalStates # Only save when goals really change + # TODO: this log doesn't capture when null-status goals are being set during world streaming. Where can they be coming from? + return console.error("Somehow trying to save null goal states!", goalStates) if _.find(goalStates, (gs) -> not gs.status) state.goalStates = goalStates @session.set 'state', state @changedSessionProperties.state = true diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee index 48a980831..cc9a6dd12 100644 --- a/app/lib/LevelLoader.coffee +++ b/app/lib/LevelLoader.coffee @@ -70,14 +70,18 @@ module.exports = class LevelLoader extends CocoClass @listenToOnce @session, 'sync', -> @session.url = -> '/db/level.session/' + @id @loadDependenciesForSession(@session) - + if @opponentSessionID opponentSession = new LevelSession().setURL "/db/level_session/#{@opponentSessionID}" @opponentSessionResource = @supermodel.loadModel(opponentSession, 'opponent_session') @opponentSession = @opponentSessionResource.model @listenToOnce @opponentSession, 'sync', @loadDependenciesForSession - + loadDependenciesForSession: (session) -> + return if @levelID is 'sky-span' # TODO + # TODO: I think this runs afoul of https://github.com/codecombat/codecombat/issues/1108 + # TODO: this shouldn't happen when it's not a hero level, but we don't have level loaded yet, + # and the sessions are being created with default hero config regardless of whether it's a hero level. if heroConfig = session.get('heroConfig') url = "/db/thang.type/#{heroConfig.thangType}/version?project=name,components,original" @worldNecessities.push @maybeLoadURL(url, ThangType, 'thang') @@ -107,7 +111,8 @@ module.exports = class LevelLoader extends CocoClass systemVersions = [] articleVersions = [] - for thang in @level.get('thangs') or [] + flagThang = thangType: '53fa25f25bc220000052c2be', id: 'Placeholder Flag', components: [] + for thang in (@level.get('thangs') or []).concat [flagThang] thangIDs.push thang.thangType @loadItemThangsEquippedByLevelThang(thang) for comp in thang.components or [] diff --git a/app/lib/LinkedInHandler.coffee b/app/lib/LinkedInHandler.coffee index ed322f2c2..2a6be9a9e 100644 --- a/app/lib/LinkedInHandler.coffee +++ b/app/lib/LinkedInHandler.coffee @@ -14,7 +14,7 @@ module.exports = LinkedInHandler = class LinkedInHandler extends CocoClass onLinkedInLoaded: (e) -> IN.Event.on IN, 'auth', @onLinkedInAuth - onLinkedInAuth: (e) => console.log 'Authorized with LinkedIn' + onLinkedInAuth: (e) => #console.log 'Authorized with LinkedIn' constructEmployerAgreementObject: (cb) => IN.API.Profile('me') diff --git a/app/lib/scripts/ScriptManager.coffee b/app/lib/scripts/ScriptManager.coffee index cea2b14ee..6983ac199 100644 --- a/app/lib/scripts/ScriptManager.coffee +++ b/app/lib/scripts/ScriptManager.coffee @@ -269,6 +269,7 @@ module.exports = ScriptManager = class ScriptManager extends CocoClass @publishNote(note) publishNote: (note) -> + Backbone.Mediator.publish 'playback:real-time-playback-ended', {} Backbone.Mediator.publish(note.channel, note.event) # ENDING NOTES diff --git a/app/lib/services/linkedin.coffee b/app/lib/services/linkedin.coffee index 9a6f0b37f..9134d77bb 100644 --- a/app/lib/services/linkedin.coffee +++ b/app/lib/services/linkedin.coffee @@ -1,6 +1,6 @@ module.exports = initializeLinkedIn = -> window.linkedInAsyncInit = -> - console.log 'Linkedin async init success!' + #console.log 'Linkedin async init success!' Backbone.Mediator.publish 'linkedin-loaded' linkedInSnippet = diff --git a/app/lib/simulator/Simulator.coffee b/app/lib/simulator/Simulator.coffee index 184d7c7dc..0e5a6631e 100644 --- a/app/lib/simulator/Simulator.coffee +++ b/app/lib/simulator/Simulator.coffee @@ -184,7 +184,7 @@ module.exports = class Simulator extends CocoClass try @commenceSimulationAndSetupCallback() catch err - console.error 'There was an error in simulation:', err, "-- trying again in #{@retryDelayInSeconds} seconds" + console.error 'There was an error in simulation:', err, err.stack, "-- trying again in #{@retryDelayInSeconds} seconds" @simulateAnotherTaskAfterDelay() assignWorldAndLevelFromLevelLoaderAndDestroyIt: -> diff --git a/app/lib/surface/CastingScreen.coffee b/app/lib/surface/CastingScreen.coffee deleted file mode 100644 index 63eea858e..000000000 --- a/app/lib/surface/CastingScreen.coffee +++ /dev/null @@ -1,81 +0,0 @@ -CocoClass = require 'lib/CocoClass' - -module.exports = class CastingScreen extends CocoClass - subscriptions: - 'tome:cast-spells': 'onCastingBegins' - 'god:new-world-created': 'onCastingEnds' - 'god:world-load-progress-changed': 'onWorldLoadProgressChanged' - - constructor: (options) -> - super() - options ?= {} - @camera = options.camera - @layer = options.layer - 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: -> '<CastingScreen>' - - build: -> - @dimLayer = new createjs.Container() - @dimLayer.mouseEnabled = @dimLayer.mouseChildren = false - @dimLayer.layerIndex = -11 - @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 - @layer.addChild @dimLayer - @dimLayer.addChild @makeProgressBar() - @dimLayer.addChild @makeCastingText() - - onWorldLoadProgressChanged: (e) -> - if new Date().getTime() - @t0 > 500 - createjs.Tween.removeTweens @progressBar - createjs.Tween.get(@progressBar).to({scaleX: e.progress}, 200) - - makeProgressBar: -> - BAR_PIXEL_HEIGHT = 3 - BAR_PCT_WIDTH = .75 - pixelWidth = parseInt(@camera.canvasWidth * BAR_PCT_WIDTH) - pixelMargin = (@camera.canvasWidth - (@camera.canvasWidth * BAR_PCT_WIDTH)) / 2 - barY = 3 * (@camera.canvasHeight / 5) - - g = new createjs.Graphics() - g.beginFill(createjs.Graphics.getRGB(255, 255, 255)) - g.drawRoundRect(0, 0, pixelWidth, BAR_PIXEL_HEIGHT, 3) - @progressBar = new createjs.Shape(g) - @progressBar.x = pixelMargin - @progressBar.y = barY - @progressBar.scaleX = 0 - @dimLayer.addChild(@progressBar) - - makeCastingText: -> - size = @camera.canvasHeight / 15 - text = new createjs.Text('Casting', "#{size}px cursive", '#aaaaaa') - text.regX = text.getMeasuredWidth() / 2 - text.regY = text.getMeasuredHeight() / 2 - text.x = @camera.canvasWidth / 2 - text.y = @camera.canvasHeight / 2 - @text = text - return text - - show: -> - return if @showing - @showing = true - @t0 = new Date().getTime() - - @progressBar.scaleX = 0 - @dimLayer.alpha = 0 - createjs.Tween.removeTweens @dimLayer - createjs.Tween.get(@dimLayer).to({alpha: 1}, 500) - - hide: -> - return unless @showing - @showing = false - - createjs.Tween.removeTweens @progressBar - createjs.Tween.removeTweens @dimLayer - createjs.Tween.get(@dimLayer).to({alpha: 0}, 500) diff --git a/app/lib/surface/CocoSprite.coffee b/app/lib/surface/CocoSprite.coffee index 7f4db7220..a3dbc4130 100644 --- a/app/lib/surface/CocoSprite.coffee +++ b/app/lib/surface/CocoSprite.coffee @@ -320,6 +320,9 @@ module.exports = CocoSprite = class CocoSprite extends CocoClass [@imageObject.x, @imageObject.y] = [sup.x, sup.y] @lastPos = p1.copy?() or _.clone(p1) @hasMoved = true + if @thangType.get('name') is 'Flag' and not @notOfThisWorld + # Let the pending flags know we're here (but not this call stack, they need to delete themselves, and we may be iterating sprites). + _.defer => Backbone.Mediator.publish 'surface:flag-appeared', sprite: @ updateBaseScale: -> scale = 1 @@ -519,7 +522,7 @@ module.exports = CocoSprite = class CocoSprite extends CocoClass @letterboxOn = e.on onMouseEvent: (e, ourEventName) -> - return if @letterboxOn + return if @letterboxOn or not @imageObject p = @imageObject p = p.parent while p.parent newEvent = sprite: @, thang: @thang, originalEvent: e, canvas:p.canvas diff --git a/app/lib/surface/CoordinateDisplay.coffee b/app/lib/surface/CoordinateDisplay.coffee index f650e09b1..155f7e7ac 100644 --- a/app/lib/surface/CoordinateDisplay.coffee +++ b/app/lib/surface/CoordinateDisplay.coffee @@ -35,10 +35,6 @@ module.exports = class CoordinateDisplay extends createjs.Container onMouseOut: (e) -> @mouseInBounds = false onMouseMove: (e) -> - if @mouseInBounds and key.shift - $('#surface').addClass('flag-cursor') unless $('#surface').hasClass('flag-cursor') - else if @mouseInBounds - $('#surface').removeClass('flag-cursor') if $('#surface').hasClass('flag-cursor') wop = @camera.screenToWorld x: e.x, y: e.y wop.x = Math.round(wop.x) wop.y = Math.round(wop.y) diff --git a/app/lib/surface/FlagSprite.coffee b/app/lib/surface/FlagSprite.coffee new file mode 100644 index 000000000..0c20b7ebf --- /dev/null +++ b/app/lib/surface/FlagSprite.coffee @@ -0,0 +1,34 @@ +IndieSprite = require 'lib/surface/IndieSprite' +{me} = require 'lib/auth' + +module.exports = class FlagSprite extends IndieSprite + subscriptions: + 'surface:mouse-moved': 'onMouseMoved' + + #shortcuts: + + defaultPos: -> x: 20, y: 20, z: 1 + + constructor: (thangType, options) -> + super thangType, options + @toggleCursor options.isCursor + + makeIndieThang: (thangType, options) -> + thang = super thangType, options + thang.width = thang.height = thang.depth = 2 + thang.pos.z = 1 + thang.isSelectable = false + thang.color = options.color + thang.team = options.team + thang + + onMouseMoved: (e) -> + return unless @options.isCursor + wop = @options.camera.screenToWorld x: e.x, y: e.y + @thang.pos.x = wop.x + @thang.pos.y = wop.y + + toggleCursor: (to) -> + @options.isCursor = to + @thang.alpha = if to then 0.33 else 0.67 # 1.0 is for flags that have been placed + @updateAlpha() diff --git a/app/lib/surface/IndieSprite.coffee b/app/lib/surface/IndieSprite.coffee index e66b90e16..a297b7508 100644 --- a/app/lib/surface/IndieSprite.coffee +++ b/app/lib/surface/IndieSprite.coffee @@ -1,8 +1,5 @@ -{me} = require('lib/auth') Thang = require 'lib/world/thang' -Vector = require 'lib/world/vector' CocoSprite = require 'lib/surface/CocoSprite' -Camera = require './Camera' module.exports = IndieSprite = class IndieSprite extends CocoSprite notOfThisWorld: true @@ -11,16 +8,16 @@ module.exports = IndieSprite = class IndieSprite extends CocoSprite 'note-group-ended': 'onNoteGroupEnded' constructor: (thangType, options) -> - options.thang = @makeIndieThang thangType, options.thangID, options.pos + options.thang = @makeIndieThang thangType, options super thangType, options @shadow = @thang - makeIndieThang: (thangType, thangID, pos) -> - @thang = thang = new Thang null, thangType.get('name'), thangID + makeIndieThang: (thangType, options) -> + @thang = thang = new Thang null, thangType.get('name'), options.thangID # Build needed results of what used to be Exists, Physical, Acts, and Selectable Components thang.exists = true thang.width = thang.height = thang.depth = 4 - thang.pos = pos ? @defaultPos() + thang.pos = options.pos ? @defaultPos() thang.pos.z = thang.depth / 2 thang.shape = 'ellipsoid' thang.rotation = 0 diff --git a/app/lib/surface/Label.coffee b/app/lib/surface/Label.coffee index 959c92c13..30829d372 100644 --- a/app/lib/surface/Label.coffee +++ b/app/lib/surface/Label.coffee @@ -37,6 +37,8 @@ module.exports = class Label extends CocoClass build: -> @layer.removeChild @background if @background @layer.removeChild @label if @label + @label = null + @background = null return unless @text # null or '' should both be skipped o = @buildLabelOptions() @layer.addChild @label = @buildLabel o @@ -53,6 +55,17 @@ module.exports = class Label extends CocoClass @label.y = @background.y = @sprite.imageObject.y + offset.y null + show: -> + return unless @label + @layer.addChild @label + @layer.addChild @background + @layer.updateLayerOrder() + + hide: -> + return unless @label + @layer.removeChild @background + @layer.removeChild @label + buildLabelOptions: -> o = {} st = {dialogue: 'D', say: 'S', name: 'N'}[@style] diff --git a/app/lib/surface/SpriteBoss.coffee b/app/lib/surface/SpriteBoss.coffee index c5fd17c19..7771462be 100644 --- a/app/lib/surface/SpriteBoss.coffee +++ b/app/lib/surface/SpriteBoss.coffee @@ -3,6 +3,7 @@ CocoClass = require 'lib/CocoClass' Layer = require './Layer' IndieSprite = require 'lib/surface/IndieSprite' WizardSprite = require 'lib/surface/WizardSprite' +FlagSprite = require 'lib/surface/FlagSprite' CocoSprite = require 'lib/surface/CocoSprite' Mark = require './Mark' Grid = require 'lib/world/Grid' @@ -19,8 +20,13 @@ module.exports = class SpriteBoss extends CocoClass 'level-lock-select': 'onSetLockSelect' 'level:restarted': 'onLevelRestarted' 'god:new-world-created': 'onNewWorld' + 'god:streaming-world-updated': 'onNewWorld' 'camera:dragged': 'onCameraDragged' 'sprite:loaded': -> @update(true) + 'level:flag-color-selected': 'onFlagColorSelected' + 'level:flag-updated': 'onFlagUpdated' + 'surface:flag-appeared': 'onFlagAppeared' + 'surface:remove-selected-flag': 'onRemoveSelectedFlag' constructor: (@options) -> super() @@ -36,6 +42,7 @@ module.exports = class SpriteBoss extends CocoClass @selfWizardSprite = null @createLayers() @spriteSheetCache = {} + @pendingFlags = [] destroy: -> @removeSprite sprite for thangID, sprite of @sprites @@ -112,7 +119,7 @@ module.exports = class SpriteBoss extends CocoClass sprite = @createWizardSprite thangID: opponent.id, name: opponent.name, codeLanguage: opponent.codeLanguage if not opponent.levelSlug or opponent.levelSlug is 'brawlwood' sprite.targetPos = if opponent.team is 'ogres' then {x: 52, y: 52} else {x: 28, y: 28} - else if opponent.levelSlug is 'dungeon-arena' + else if opponent.levelSlug in ['dungeon-arena', 'sky-span'] sprite.targetPos = if opponent.team is 'ogres' then {x: 72, y: 39} else {x: 9, y: 39} else if opponent.levelSlug is 'criss-cross' sprite.targetPos = if opponent.team is 'ogres' then {x: 50, y: 12} else {x: 0, y: 40} @@ -315,6 +322,39 @@ module.exports = class SpriteBoss extends CocoClass instance.addEventListener 'complete', -> Backbone.Mediator.publish 'thang-finished-talking', thang: sprite?.thang + onFlagColorSelected: (e) -> + @removeSprite @flagCursorSprite if @flagCursorSprite + @flagCursorSprite = null + return unless e.color + @flagCursorSprite = new FlagSprite @thangTypeFor('Flag'), @createSpriteOptions(thangID: 'Flag Cursor', color: e.color, team: me.team, isCursor: true, pos: e.pos) + @addSprite @flagCursorSprite, @flagCursorSprite.thang.id, @spriteLayers['Floating'] + + onFlagUpdated: (e) -> + return unless e.active + pendingFlag = new FlagSprite @thangTypeFor('Flag'), @createSpriteOptions(thangID: 'Pending Flag ' + Math.random(), color: e.color, team: me.team, isCursor: false, pos: e.pos) + @addSprite pendingFlag, pendingFlag.thang.id, @spriteLayers['Floating'] + @pendingFlags.push pendingFlag + + onFlagAppeared: (e) -> + # Remove the pending flag that matches this one's color/team/position, and any color/team matches placed earlier. + t1 = e.sprite.thang + pending = (@pendingFlags ? []).slice() + foundExactMatch = false + for i in [pending.length - 1 .. 0] by -1 + pendingFlag = pending[i] + t2 = pendingFlag.thang + matchedType = t1.color is t2.color and t1.team is t2.team + matched = matchedType and (foundExactMatch or Math.abs(t1.pos.x - t2.pos.x) < 0.00001 and Math.abs(t1.pos.y - t2.pos.y) < 0.00001) + if matched + foundExactMatch = true + @pendingFlags.splice(i, 1) + @removeSprite pendingFlag + null + + onRemoveSelectedFlag: (e) -> + return unless @selectedSprite and @selectedSprite.thangType.get('name') is 'Flag' and @selectedSprite.thang.team is me.team + Backbone.Mediator.publish 'surface:remove-flag', color: @selectedSprite.thang.color + # Marks updateSelection: -> diff --git a/app/lib/surface/Surface.coffee b/app/lib/surface/Surface.coffee index bd6cf7df1..92715bd5a 100644 --- a/app/lib/surface/Surface.coffee +++ b/app/lib/surface/Surface.coffee @@ -8,7 +8,6 @@ CameraBorder = require './CameraBorder' Layer = require './Layer' Letterbox = require './Letterbox' Dimmer = require './Dimmer' -CastingScreen = require './CastingScreen' PlaybackOverScreen = require './PlaybackOverScreen' DebugDisplay = require './DebugDisplay' CoordinateDisplay = require './CoordinateDisplay' @@ -63,10 +62,15 @@ module.exports = Surface = class Surface extends CocoClass 'level-set-surface-camera': 'onSetCamera' 'level:restarted': 'onLevelRestarted' 'god:new-world-created': 'onNewWorld' + 'god:streaming-world-updated': 'onNewWorld' 'tome:cast-spells': 'onCastSpells' 'level-set-letterbox': 'onSetLetterbox' 'application:idle-changed': 'onIdleChanged' 'camera:zoom-updated': 'onZoomUpdated' + 'playback:real-time-playback-started': 'onRealTimePlaybackStarted' + 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' + 'level:flag-color-selected': 'onFlagColorSelected' + #'god:world-load-progress-changed': -> console.log 'it is actually', @world.age shortcuts: 'ctrl+\\, ⌘+\\': 'onToggleDebug' @@ -82,7 +86,7 @@ module.exports = Surface = class Surface extends CocoClass @options = _.extend(@options, givenOptions) if givenOptions @initEasel() @initAudio() - @onResize = _.debounce @onResize, 500 + @onResize = _.debounce @onResize, 250 $(window).on 'resize', @onResize if @world.ended _.defer => @setWorld @world @@ -96,7 +100,6 @@ module.exports = Surface = class Surface extends CocoClass @spriteBoss.destroy() @chooser?.destroy() @dimmer?.destroy() - @castingScreen?.destroy() @playbackOverScreen?.destroy() @stage.clear() @musicPlayer?.destroy() @@ -115,14 +118,14 @@ module.exports = Surface = class Surface extends CocoClass setWorld: (@world) -> @worldLoaded = true - lastFrame = Math.min(@getCurrentFrame(), @world.totalFrames - 1) + lastFrame = Math.min(@getCurrentFrame(), @world.frames.length - 1) @world.getFrame(lastFrame).restoreState() unless @options.choosing @spriteBoss.world = @world @showLevel() @updateState true if @loaded # TODO: synchronize both ways of choosing whether to show coords (@world via UI System or @options via World Select modal) - if @world.showCoordinates and @options.coords + if @world.showCoordinates and @options.coords and not @coordinateDisplay @coordinateDisplay = new CoordinateDisplay camera: @camera @surfaceTextLayer.addChild @coordinateDisplay @onFrameChanged() @@ -190,19 +193,19 @@ module.exports = Surface = class Surface extends CocoClass setProgress: (progress, scrubDuration=500) -> progress = Math.max(Math.min(progress, 1), 0.0) + @fastForwardingToFrame = null @scrubbing = true onTweenEnd = => @scrubbingTo = null @scrubbing = false @scrubbingPlaybackSpeed = null - @fastForwarding = false if @scrubbingTo? # cut to the chase for existing tween createjs.Tween.removeTweens(@) @currentFrame = @scrubbingTo - @scrubbingTo = Math.min(Math.round(progress * @world.totalFrames), @world.totalFrames) + @scrubbingTo = Math.min(Math.round(progress * @world.frames.length), @world.frames.length) @scrubbingPlaybackSpeed = Math.sqrt(Math.abs(@scrubbingTo - @currentFrame) * @world.dt / (scrubDuration or 0.5)) if scrubDuration t = createjs.Tween @@ -244,7 +247,7 @@ module.exports = Surface = class Surface extends CocoClass getCurrentFrame: -> return Math.max(0, Math.min(Math.floor(@currentFrame), @world.frames.length - 1)) - getProgress: -> @currentFrame / @world.totalFrames + getProgress: -> @currentFrame / @world.frames.length onLevelRestarted: (e) -> @setProgress 0, 0 @@ -286,26 +289,26 @@ module.exports = Surface = class Surface extends CocoClass @setPlayingCalled = true if @playing and @currentFrame >= (@world.totalFrames - 5) @currentFrame = 0 - if @fastForwarding and not @playing - @setProgress @currentFrame / @world.totalFrames + if @fastForwardingToFrame and not @playing + @fastForwardingToFrame = null onSetTime: (e) -> toFrame = @currentFrame if e.time? - @worldLifespan = @world.totalFrames / @world.frameRate + @worldLifespan = @world.frames.length / @world.frameRate e.ratio = e.time / @worldLifespan if e.ratio? - toFrame = @world.totalFrames * e.ratio + toFrame = @world.frames.length * e.ratio if e.frameOffset toFrame += e.frameOffset if e.ratioOffset - toFrame += @world.totalFrames * e.ratioOffset + toFrame += @world.frames.length * e.ratioOffset unless _.isNumber(toFrame) and not _.isNaN(toFrame) return console.error('set-time event', e, 'produced invalid target frame', toFrame) - @setProgress(toFrame / @world.totalFrames, e.scrubDuration) + @setProgress(toFrame / @world.frames.length, e.scrubDuration) onFrameChanged: (force) -> - @currentFrame = Math.min(@currentFrame, @world.totalFrames) + @currentFrame = Math.min(@currentFrame, @world.frames.length) @debugDisplay?.updateFrame @currentFrame return if @currentFrame is @lastFrame and not force progress = @getProgress() @@ -317,7 +320,7 @@ module.exports = Surface = class Surface extends CocoClass world: @world ) - if @lastFrame < @world.totalFrames and @currentFrame >= @world.totalFrames - 1 + if @lastFrame < @world.frames.length and @currentFrame >= @world.totalFrames - 1 @ended = true @setPaused true Backbone.Mediator.publish 'surface:playback-ended' @@ -353,48 +356,34 @@ module.exports = Surface = class Surface extends CocoClass return if e.preload @setPaused false if @ended @casting = true - @wasPlayingWhenCastingBegan = @playing - Backbone.Mediator.publish 'level-set-playing', {playing: false} - @setPlayingCalled = false # don't overwrite playing settings if they changed by, say, scripts - - if @coordinateDisplay? - @surfaceTextLayer.removeChild @coordinateDisplay - @coordinateDisplay.destroy() - - createjs.Tween.removeTweens(@surfaceLayer) - createjs.Tween.get(@surfaceLayer).to({alpha: 0.9}, 1000, createjs.Ease.getPowOut(4.0)) + @setPlayingCalled = false # Don't overwrite playing settings if they changed by, say, scripts. + @frameBeforeCast = @currentFrame + @setProgress 0 onNewWorld: (event) -> return unless event.world.name is @world.name @casting = false - if @ended and not @wasPlayingWhenCastingBegan - @setPaused true - else - @spriteBoss.play() + @spriteBoss.play() # This has a tendency to break scripts that are waiting for playback to change when the level is loaded # so only run it after the first world is created. - Backbone.Mediator.publish 'level-set-playing', {playing: @wasPlayingWhenCastingBegan} unless event.firstWorld or @setPlayingCalled + Backbone.Mediator.publish 'level-set-playing', {playing: true} unless event.firstWorld or @setPlayingCalled - fastForwardTo = null - if @playing - fastForwardTo = Math.min event.world.firstChangedFrame, @currentFrame - @currentFrame = 0 - - createjs.Tween.removeTweens(@surfaceLayer) - f = => - @setWorld event.world - @onFrameChanged(true) - if fastForwardTo and @playing - fastForwardToRatio = fastForwardTo / @world.totalFrames - fastForwardToTime = fastForwardTo * @world.dt - fastForwardSpeed = Math.max 4, fastForwardToTime / 3 - @setProgress fastForwardToRatio, 1000 * fastForwardToTime / fastForwardSpeed - @fastForwarding = true - createjs.Tween.get(@surfaceLayer) - .to({alpha: 0.0}, 50) - .call(f) - .to({alpha: 1.0}, 2000, createjs.Ease.getPowOut(2.0)) + @setWorld event.world + @onFrameChanged(true) + fastForwardBuffer = 2 + if @playing and not @realTime and (ffToFrame = Math.min(event.firstChangedFrame, @frameBeforeCast, @world.frames.length)) and ffToFrame > @currentFrame + fastForwardBuffer * @world.frameRate + @fastForwardingToFrame = ffToFrame + @fastForwardingSpeed = Math.max 4, 4 * 90 / (@world.maxTotalFrames * @world.dt) + else if @realTime + lag = (@world.frames.length - 1) * @world.dt - @world.age + intendedLag = @world.realTimeBufferMax + @world.dt + if lag > intendedLag * 1.2 + @fastForwardingToFrame = @world.frames.length - @world.realTimeBufferMax * @world.frameRate + @fastForwardingSpeed = lag / intendedLag + else + @fastForwardingToFrame = @fastForwardingSpeed = null + #console.log "on new world, lag", lag, "intended lag", intendedLag, "fastForwardingToFrame", @fastForwardingToFrame, "speed", @fastForwardingSpeed, "cause we are at", @world.age, "of", @world.frames.length * @world.dt # initialization @@ -413,7 +402,6 @@ module.exports = Surface = class Surface extends CocoClass @surfaceLayer.addChild @cameraBorder = new CameraBorder bounds: @camera.bounds @screenLayer.addChild new Letterbox canvasWidth: canvasWidth, canvasHeight: canvasHeight @spriteBoss = new SpriteBoss camera: @camera, surfaceLayer: @surfaceLayer, surfaceTextLayer: @surfaceTextLayer, world: @world, thangTypes: @options.thangTypes, choosing: @options.choosing, navigateToSelection: @options.navigateToSelection, showInvisible: @options.showInvisible - @castingScreen ?= new CastingScreen camera: @camera, layer: @screenLayer @playbackOverScreen ?= new PlaybackOverScreen camera: @camera, layer: @screenLayer @stage.enableMouseOver(10) @stage.addEventListener 'stagemousemove', @onMouseMove @@ -428,8 +416,17 @@ module.exports = Surface = class Surface extends CocoClass onResize: (e) => oldWidth = parseInt @canvas.attr('width'), 10 oldHeight = parseInt @canvas.attr('height'), 10 - newWidth = @canvas.width() - newHeight = @canvas.height() + aspectRatio = oldWidth / oldHeight + pageWidth = $('#page-container').width() - 17 # 17px nano scroll bar + if @realTime + pageHeight = $('#page-container').height() - $('#control-bar-view').outerHeight() - $('#playback-view').outerHeight() + newWidth = Math.min pageWidth, pageHeight * aspectRatio + newHeight = newWidth / aspectRatio + else + newWidth = 0.55 * pageWidth + newHeight = newWidth / aspectRatio + @canvas.width newWidth + @canvas.height newHeight return unless newWidth > 0 and newHeight > 0 #if InstallTrigger? # Firefox rendering performance goes down as canvas size goes up # newWidth = Math.min 924, newWidth @@ -540,7 +537,9 @@ module.exports = Surface = class Surface extends CocoClass onMouseDown: (e) => return if @disabled onBackground = not @stage.hitTest e.stageX, e.stageY - Backbone.Mediator.publish 'surface:stage-mouse-down', onBackground: onBackground, x: e.stageX, y: e.stageY, originalEvent: e + worldPos = @camera.screenToWorld x: e.stageX, y: e.stageY + event = onBackground: onBackground, x: e.stageX, y: e.stageY, originalEvent: e, worldPos: worldPos + Backbone.Mediator.publish 'surface:stage-mouse-down', event onMouseUp: (e) => return if @disabled @@ -569,14 +568,19 @@ module.exports = Surface = class Surface extends CocoClass # seems to be a bug where only one object can register with the Ticker... oldFrame = @currentFrame oldWorldFrame = Math.floor oldFrame - lastFrame = @world.totalFrames - 1 + lastFrame = @world.frames.length - 1 while true Dropper.tick() @trailmaster.tick() if @trailmaster # Skip some frame updates unless we're playing and not at end (or we haven't drawn much yet) frameAdvanced = (@playing and @currentFrame < lastFrame) or @totalFramesDrawn < 2 if frameAdvanced and @playing - @currentFrame += @world.frameRate / @options.frameRate + advanceBy = @world.frameRate / @options.frameRate + if @fastForwardingToFrame and @currentFrame < @fastForwardingToFrame - advanceBy + advanceBy = Math.min(@currentFrame + advanceBy * @fastForwardingSpeed, @fastForwardingToFrame) - @currentFrame + else if @fastForwardingToFrame + @fastForwardingToFrame = @fastForwardingSpeed = null + @currentFrame += advanceBy @currentFrame = Math.min @currentFrame, lastFrame newWorldFrame = Math.floor @currentFrame worldFrameAdvanced = newWorldFrame isnt oldWorldFrame @@ -603,8 +607,8 @@ module.exports = Surface = class Surface extends CocoClass restoreWorldState: -> frame = @world.getFrame(@getCurrentFrame()) frame.restoreState() - current = Math.max(0, Math.min(@currentFrame, @world.totalFrames - 1)) - if current - Math.floor(current) > 0.01 + 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 next = Math.ceil current ratio = current % 1 @world.frames[next].restorePartialState ratio if next > 1 @@ -614,13 +618,29 @@ module.exports = Surface = class Surface extends CocoClass updateState: (frameChanged) -> # world state must have been restored in @restoreWorldState @camera.updateZoom() - @spriteBoss.update frameChanged unless @casting + @spriteBoss.update frameChanged @dimmer?.setSprites @spriteBoss.sprites drawCurrentFrame: (e) -> ++@totalFramesDrawn @stage.update e + # Real-time playback + onRealTimePlaybackStarted: (e) -> + @realTime = true + @onResize() + @spriteBoss.selfWizardSprite?.toggle false + + onRealTimePlaybackEnded: (e) -> + @realTime = false + @onResize() + @spriteBoss.selfWizardSprite?.toggle true + @canvas.removeClass 'flag-color-selected' + + onFlagColorSelected: (e) -> + @canvas.toggleClass 'flag-color-selected', Boolean(e.color) + e.pos = @camera.screenToWorld @mouseScreenPos if @mouseScreenPos + # paths - TODO: move to SpriteBoss? but only update on frame drawing instead of on every frame update? updatePaths: -> diff --git a/app/lib/surface/WizardSprite.coffee b/app/lib/surface/WizardSprite.coffee index 5d929147c..fc59f010d 100644 --- a/app/lib/surface/WizardSprite.coffee +++ b/app/lib/surface/WizardSprite.coffee @@ -1,5 +1,4 @@ IndieSprite = require 'lib/surface/IndieSprite' -Camera = require './Camera' {me} = require 'lib/auth' module.exports = class WizardSprite extends IndieSprite @@ -35,12 +34,11 @@ module.exports = class WizardSprite extends IndieSprite @targetPos = @thang.pos if @isSelf @setNameLabel me.displayName() - @setColorHue me.get('wizardColor1') else if options.name @setNameLabel options.name - makeIndieThang: (thangType, thangID, pos) -> - thang = super thangType, thangID, pos + makeIndieThang: (thangType, options) -> + thang = super thangType, options thang.isSelectable = false thang.bobHeight = 0.75 thang.bobTime = 2 @@ -59,6 +57,11 @@ module.exports = class WizardSprite extends IndieSprite name += " (#{@options.codeLanguage})" # TODO: move on second line, capitalize properly super name + toggle: (to) -> + @imageObject?.visible = to + label[if to then 'show' else 'hide']() for name, label of @labels + mark.mark?.visible = to for name, mark of @marks + onPlayerStatesChanged: (e) -> for playerID, state of e.states continue unless playerID is @thang.id @@ -66,7 +69,6 @@ module.exports = class WizardSprite extends IndieSprite continue if playerID is me.id # ignore changes for self wizard sprite @setNameLabel state.name continue unless state.wizard? - @setColorHue state.wizard.wizardColor1 if targetID = state.wizard.targetSprite return console.warn 'Wizard Sprite couldn\'t find target sprite', targetID unless targetID of @options.sprites @setTarget @options.sprites[targetID] @@ -91,17 +93,13 @@ module.exports = class WizardSprite extends IndieSprite @imageObject.scaleX = @imageObject.scaleY = @imageObject.alpha = 0 createjs.Tween.get(@imageObject) .to({scaleX: 1, scaleY: 1, alpha: 1}, 1000, createjs.Ease.getPowInOut(2.2)) + @labels.name?.show() animateOut: (callback) -> tween = createjs.Tween.get(@imageObject) .to({scaleX: 0, scaleY: 0, alpha: 0}, 1000, createjs.Ease.getPowInOut(2.2)) tween.call(callback) if callback - - setColorHue: (newColorHue) -> - # TODO: is this needed any more? - return if @colorHue is newColorHue - @colorHue = newColorHue - #@updateColorFilters() + @labels.name?.hide() setEditing: (@editing) -> if @editing diff --git a/app/lib/world/thang.coffee b/app/lib/world/thang.coffee index 64eb698c6..da69fdf0e 100644 --- a/app/lib/world/thang.coffee +++ b/app/lib/world/thang.coffee @@ -85,7 +85,7 @@ module.exports = class Thang throw new Error "Two types were specified for trackable property #{prop}: #{oldType} and #{type}." keepTrackedProperty: (prop) -> - # Hmm; can we do this faster? + # Wish we could do this faster, but I can't think of how. propIndex = @trackedPropertiesKeys.indexOf prop if propIndex isnt -1 @trackedPropertiesUsed[propIndex] = true @@ -147,6 +147,8 @@ module.exports = class Thang for trackedFinalProperty in @trackedFinalProperties ? [] # TODO: take some (but not all) of serialize logic from ThangState to handle other types o.finalState[trackedFinalProperty] = @[trackedFinalProperty] + # Since we might keep tracked properties later during streaming, we need to know which we think are unused. + o.unusedTrackedPropertyKeys = (@trackedPropertiesKeys[propIndex] for used, propIndex in @trackedPropertiesUsed when not used) o @deserialize: (o, world, classMap) -> @@ -154,6 +156,8 @@ module.exports = class Thang for [componentClassName, componentConfig] in o.components componentClass = classMap[componentClassName] t.addComponents [componentClass, componentConfig] + t.unusedTrackedPropertyKeys = o.unusedTrackedPropertyKeys + t.unusedTrackedPropertyValues = (t[prop] for prop in o.unusedTrackedPropertyKeys) for prop, val of o.finalState # TODO: take some (but not all) of deserialize logic from ThangState to handle other types t[prop] = val @@ -164,7 +168,16 @@ module.exports = class Thang getSpriteOptions: -> colorConfigs = @world?.getTeamColors() or {} - options = {} - if @team and colorConfigs[@team] - options.colorConfig = {team: colorConfigs[@team]} + options = {colorConfig: {}} + if @team and teamColor = colorConfigs[@team] + options.colorConfig.team = teamColor + if @color and color = @grabColorConfig @color + options.colorConfig.color = color options + + grabColorConfig: (color) -> + { + green: {hue: 0.33, saturation: 0.5, lightness: 0.5} + black: {hue: 0, saturation: 0, lightness: 0.25} + violet: {hue: 0.83, saturation: 0.5, lightness: 0.5} + }[color] diff --git a/app/lib/world/thang_state.coffee b/app/lib/world/thang_state.coffee index 611194cc3..117de1a97 100644 --- a/app/lib/world/thang_state.coffee +++ b/app/lib/world/thang_state.coffee @@ -64,7 +64,10 @@ module.exports = class ThangState # Get the property, whether we have it stored in @props or in @trackedPropertyValues. Optimize it. # Figured based on http://jsperf.com/object-vs-array-vs-native-linked-list/13 that it should be faster with small arrays to do the indexOf reads (each up to 24x faster) than to do a single object read, and then we don't have to maintain an extra @props object; just keep array propIndex = @trackedPropertyKeys.indexOf prop - return null if propIndex is -1 + if propIndex is -1 + initialPropIndex = @thang.unusedTrackedPropertyKeys.indexOf prop + return null if initialPropIndex is -1 + return @thang.unusedTrackedPropertyValues[initialPropIndex] value = @props[propIndex] return value if value isnt undefined or @hasRestored return @props[propIndex] = @getStoredProp propIndex @@ -73,22 +76,28 @@ module.exports = class ThangState # Restore trackedProperties' values to @thang, retrieving them from @trackedPropertyValues if needed. Optimize it. return @ if @thang._state is @ and not @thang.partialState unless @hasRestored # Restoring in a deserialized World for first time + for prop, propIndex in @thang.unusedTrackedPropertyKeys when @trackedPropertyKeys.indexOf(prop) is -1 + @thang[prop] = @thang.unusedTrackedPropertyValues[propIndex] props = [] for prop, propIndex in @trackedPropertyKeys type = @trackedPropertyTypes[propIndex] storage = @trackedPropertyValues[propIndex] - props.push(@thang[prop] = @getStoredProp propIndex, type, storage) + props.push @thang[prop] = @getStoredProp propIndex, type, storage #console.log @frameIndex, @thang.id, prop, propIndex, type, storage, 'got', @thang[prop] @props = props @trackedPropertyTypes = @trackedPropertyValues = @specialKeysToValues = null # leave @trackedPropertyKeys for indexing @hasRestored = true else # Restoring later times + for prop, propIndex in @thang.unusedTrackedPropertyKeys when @trackedPropertyKeys.indexOf(prop) is -1 + @thang[prop] = @thang.unusedTrackedPropertyValues[propIndex] for prop, propIndex in @trackedPropertyKeys @thang[prop] = @props[propIndex] @thang.partialState = false @ restorePartial: (ratio) -> + # Don't think we need to worry about unusedTrackedPropertyValues here. + # If it's not tracked yet, it'll very rarely partially change between frames; we can afford to miss the first one. inverse = 1 - ratio for prop, propIndex in @trackedPropertyKeys when prop is 'pos' or prop is 'rotation' if @hasRestored diff --git a/app/lib/world/world.coffee b/app/lib/world/world.coffee index de540b991..e76ab2707 100644 --- a/app/lib/world/world.coffee +++ b/app/lib/world/world.coffee @@ -10,8 +10,11 @@ WorldScriptNote = require './world_script_note' {now, consolidateThangs, typedArraySupport} = require './world_utils' Component = require 'lib/world/component' System = require 'lib/world/system' -PROGRESS_UPDATE_INTERVAL = 200 -DESERIALIZATION_INTERVAL = 20 +PROGRESS_UPDATE_INTERVAL = 100 +DESERIALIZATION_INTERVAL = 10 +REAL_TIME_BUFFER_MIN = 2 * PROGRESS_UPDATE_INTERVAL +REAL_TIME_BUFFER_MAX = 3 * PROGRESS_UPDATE_INTERVAL +REAL_TIME_BUFFERED_WAIT_INTERVAL = 0.5 * PROGRESS_UPDATE_INTERVAL ITEM_ORIGINAL = '53e12043b82921000051cdf9' module.exports = class World @@ -21,7 +24,9 @@ module.exports = class World preloading: false # Whether we are just preloading a world in case we soon cast it 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 apiProperties: ['age', 'dt'] + realTimeBufferMax: REAL_TIME_BUFFER_MAX / 1000 constructor: (@userCodeMap, classMap) -> # classMap is needed for deserializing Worlds, Thangs, and other classes @classMap = classMap ? {Vector: Vector, Rectangle: Rectangle, Thang: Thang, Ellipse: Ellipse, LineSegment: LineSegment} @@ -33,6 +38,7 @@ module.exports = class World @systems = [] @systemMap = {} @scriptNotes = [] + @flagHistory = [] @rand = new Rand 0 # Existence System may change this seed @frames = [new WorldFrame(@, 0)] @@ -65,10 +71,11 @@ module.exports = class World @thangs[i] = thang @thangMap[thang.id] = thang - thangDialogueSounds: -> - if @frames.length < @totalFrames then throw new Error('World should be over before grabbing dialogue') + thangDialogueSounds: (startFrame=0) -> + return [] unless startFrame < @frames.length [sounds, seen] = [[], {}] - for frame in @frames + for frameIndex in [startFrame ... @frames.length] + frame = @frames[frameIndex] for thangID, state of frame.thangStateMap continue unless state.thang.say and sayMessage = state.getStateForProp 'sayMessage' soundKey = state.thang.spriteName + ':' + sayMessage @@ -85,50 +92,30 @@ module.exports = class World loadFrames: (loadedCallback, errorCallback, loadProgressCallback, skipDeferredLoading, loadUntilFrame) -> return if @aborted - unless @thangs.length - console.log 'Warning: loadFrames called on empty World (no thangs).' + console.log 'Warning: loadFrames called on empty World (no thangs).' unless @thangs.length t1 = now() @t0 ?= t1 - if loadUntilFrame - frameToLoadUntil = loadUntilFrame + 1 - else - frameToLoadUntil = @totalFrames + @worldLoadStartTime ?= t1 + @lastRealTimeUpdate ?= 0 + continueLaterFn = => + @loadFrames(loadedCallback, errorCallback, loadProgressCallback, skipDeferredLoading, loadUntilFrame) unless @destroyed + frameToLoadUntil = if loadUntilFrame then loadUntilFrame + 1 else @totalFrames # Might stop early if debugging. i = @frames.length - while i < frameToLoadUntil - if @debugging - for thang in @thangs when thang.isProgrammable - userCode = @userCodeMap[thang.id] ? {} - for methodName, aether of userCode - framesToLoadFlowBefore = if methodName is 'plan' or methodName is 'makeBid' then 200 else 1 # Adjust if plan() is taking even longer - aether._shouldSkipFlow = i < loadUntilFrame - framesToLoadFlowBefore + while i < frameToLoadUntil and i < @totalFrames + return unless @shouldContinueLoading t1, loadProgressCallback, skipDeferredLoading, continueLaterFn + @adjustFlowSettings loadUntilFrame if @debugging try @getFrame(i) - ++i # increment this after we have succeeded in getting the frame, otherwise we'll have to do that frame again + ++i # Increment this after we have succeeded in getting the frame, otherwise we'll have to do that frame again catch error - # Not an Aether.errors.UserCodeError; maybe we can't recover - @addError error + @addError error # Not an Aether.errors.UserCodeError; maybe we can't recover unless @preloading or @debugging for error in (@unhandledRuntimeErrors ? []) return unless errorCallback error # errorCallback tells us whether the error is recoverable @unhandledRuntimeErrors = [] - t2 = now() - if t2 - t1 > PROGRESS_UPDATE_INTERVAL - loadProgressCallback? i / @totalFrames unless @preloading - t1 = t2 - if t2 - @t0 > 1000 - console.log ' Loaded', i, 'of', @totalFrames, '(+' + (t2 - @t0).toFixed(0) + 'ms)' - @t0 = t2 - continueFn = => - return if @destroyed - if loadUntilFrame - @loadFrames(loadedCallback,errorCallback,loadProgressCallback, skipDeferredLoading, loadUntilFrame) - else - @loadFrames(loadedCallback, errorCallback, loadProgressCallback, skipDeferredLoading) - if skipDeferredLoading - continueFn() - else - setTimeout(continueFn, 0) - return + @finishLoadingFrames loadProgressCallback, loadedCallback + + finishLoadingFrames: (loadProgressCallback, loadedCallback) -> unless @debugging @ended = true system.finish @thangs for system in @systems @@ -136,6 +123,53 @@ module.exports = class World loadProgressCallback? 1 loadedCallback() + shouldDelayRealTimeSimulation: (t) -> + return false unless @realTime + timeSinceStart = t - @worldLoadStartTime + timeLoaded = @frames.length * @dt * 1000 + timeBuffered = timeLoaded - timeSinceStart + timeBuffered > REAL_TIME_BUFFER_MAX + + shouldUpdateRealTimePlayback: (t) -> + return false unless @realTime + return false if @frames.length * @dt is @lastRealTimeUpdate + timeLoaded = @frames.length * @dt * 1000 + timeSinceStart = t - @worldLoadStartTime + remainingBuffer = @lastRealTimeUpdate * 1000 - timeSinceStart + remainingBuffer < REAL_TIME_BUFFER_MIN + + shouldContinueLoading: (t1, loadProgressCallback, skipDeferredLoading, continueLaterFn) -> + t2 = now() + if @realTime + shouldUpdateProgress = @shouldUpdateRealTimePlayback t2 + shouldDelayRealTimeSimulation = not shouldUpdateProgress and @shouldDelayRealTimeSimulation t2 + else + shouldUpdateProgress = t2 - t1 > PROGRESS_UPDATE_INTERVAL + shouldDelayRealTimeSimulation = false + return true unless shouldUpdateProgress or shouldDelayRealTimeSimulation + # Stop loading frames for now; continue in a moment. + if shouldUpdateProgress + @lastRealTimeUpdate = @frames.length * @dt if @realTime + #console.log 'we think it is now', (t2 - @worldLoadStartTime) / 1000, 'so delivering', @lastRealTimeUpdate + loadProgressCallback? @frames.length / @totalFrames unless @preloading + t1 = t2 + if t2 - @t0 > 1000 + console.log ' Loaded', @frames.length, 'of', @totalFrames, '(+' + (t2 - @t0).toFixed(0) + 'ms)' unless @realTime + @t0 = t2 + if skipDeferredLoading + continueLaterFn() + else + delay = if shouldDelayRealTimeSimulation then REAL_TIME_BUFFERED_WAIT_INTERVAL else 0 + setTimeout continueLaterFn, delay + false + + adjustFlowSettings: (loadUntilFrame) -> + for thang in @thangs when thang.isProgrammable + userCode = @userCodeMap[thang.id] ? {} + for methodName, aether of userCode + framesToLoadFlowBefore = if methodName is 'plan' or methodName is 'makeBid' then 200 else 1 # Adjust if plan() is taking even longer + aether._shouldSkipFlow = @frames.length < loadUntilFrame - framesToLoadFlowBefore + finalizePreload: (loadedCallback) -> @preloading = false loadedCallback() if @ended @@ -143,6 +177,9 @@ module.exports = class World abort: -> @aborted = true + addFlagEvent: (flagEvent) -> + @flagHistory.push flagEvent + loadFromLevel: (level, willSimulate=true) -> @levelComponents = level.levelComponents @thangTypes = level.thangTypes @@ -289,7 +326,9 @@ module.exports = class World serialize: -> # Code hotspot; optimize it - if @frames.length < @totalFrames then throw new Error('World Should Be Over Before Serialization') + startFrame = @framesSerializedSoFar + endFrame = @frames.length + #console.log "... world serializing frames from", startFrame, "to", endFrame, "of", @totalFrames [transferableObjects, nontransferableObjects] = [0, 0] o = {totalFrames: @totalFrames, maxTotalFrames: @maxTotalFrames, frameRate: @frameRate, dt: @dt, victory: @victory, userCodeMap: {}, trackedProperties: {}} o.trackedProperties[prop] = @[prop] for prop in @trackedProperties or [] @@ -305,12 +344,14 @@ module.exports = class World o.trackedPropertiesPerThangKeys = [] o.trackedPropertiesPerThangTypes = [] trackedPropertiesPerThangValues = [] # We won't send these, just the offsets and the storage buffer - o.trackedPropertiesPerThangValuesOffsets = [] # Needed to reconstruct ArrayBufferViews on other end, since Firefox has bugs transfering those: https://bugzilla.mozilla.org/show_bug.cgi?id=841904 and https://bugzilla.mozilla.org/show_bug.cgi?id=861925 + o.trackedPropertiesPerThangValuesOffsets = [] # Needed to reconstruct ArrayBufferViews on other end, since Firefox has bugs transfering those: https://bugzilla.mozilla.org/show_bug.cgi?id=841904 and https://bugzilla.mozilla.org/show_bug.cgi?id=861925 # Actually, as of January 2014, it should be fixed. So we could try to undo the workaround. transferableStorageBytesNeeded = 0 - nFrames = @frames.length + nFrames = endFrame - startFrame + streaming = nFrames < @totalFrames for thang in @thangs # Don't serialize empty trackedProperties for stateless Thangs which haven't changed (like obstacles). - # Check both, since sometimes people mark stateless Thangs but don't change them, and those should still be tracked, and the inverse doesn't work on the other end (we'll just think it doesn't exist then). + # Check both, since sometimes people mark stateless Thangs but then change them, and those should still be tracked, and the inverse doesn't work on the other end (we'll just think it doesn't exist then). + # If streaming the world, a thang marked stateless that actually change will get messed up. I think. continue if thang.stateless and not _.some(thang.trackedPropertiesUsed, Boolean) o.trackedPropertiesThangIDs.push thang.id trackedPropertiesIndices = [] @@ -357,8 +398,8 @@ module.exports = class World t1 = now() o.frameHashes = [] - for frame, frameIndex in @frames - o.frameHashes.push frame.serialize(frameIndex, o.trackedPropertiesThangIDs, o.trackedPropertiesPerThangIndices, o.trackedPropertiesPerThangTypes, trackedPropertiesPerThangValues, o.specialValuesToKeys, o.specialKeysToValues) + for frameIndex in [startFrame ... endFrame] + o.frameHashes.push @frames[frameIndex].serialize(frameIndex - startFrame, o.trackedPropertiesThangIDs, o.trackedPropertiesPerThangIndices, o.trackedPropertiesPerThangTypes, trackedPropertiesPerThangValues, o.specialValuesToKeys, o.specialKeysToValues) t2 = now() unless typedArraySupport @@ -368,29 +409,35 @@ module.exports = class World flattened.push value o.storageBuffer = flattened - #console.log 'Allocating memory:', (t1 - t0).toFixed(0), 'ms; assigning values:', (t2 - t1).toFixed(0), 'ms, so', ((t2 - t1) / @frames.length).toFixed(3), 'ms per frame' + #console.log 'Allocating memory:', (t1 - t0).toFixed(0), 'ms; assigning values:', (t2 - t1).toFixed(0), 'ms, so', ((t2 - t1) / nFrames).toFixed(3), 'ms per frame for', nFrames, 'frames' #console.log 'Got', transferableObjects, 'transferable objects and', nontransferableObjects, 'nontransferable; stored', transferableStorageBytesNeeded, 'bytes transferably' o.thangs = (t.serialize() for t in @thangs.concat(@extraneousThangs ? [])) o.scriptNotes = (sn.serialize() for sn in @scriptNotes) if o.scriptNotes.length > 200 console.log 'Whoa, serializing a lot of WorldScriptNotes here:', o.scriptNotes.length - {serializedWorld: o, transferableObjects: [o.storageBuffer]} + {serializedWorld: o, transferableObjects: [o.storageBuffer], startFrame: startFrame, endFrame: endFrame} - @deserialize: (o, classMap, oldSerializedWorldFrames, finishedWorldCallback) -> + @deserialize: (o, classMap, oldSerializedWorldFrames, finishedWorldCallback, startFrame, endFrame, streamingWorld) -> # Code hotspot; optimize it #console.log 'Deserializing', o, 'length', JSON.stringify(o).length #console.log JSON.stringify(o) #console.log 'Got special keys and values:', o.specialValuesToKeys, o.specialKeysToValues perf = {} perf.t0 = now() - w = new World o.userCodeMap, classMap + nFrames = endFrame - startFrame + w = streamingWorld ? new World o.userCodeMap, classMap [w.totalFrames, w.maxTotalFrames, w.frameRate, w.dt, w.scriptNotes, w.victory] = [o.totalFrames, o.maxTotalFrames, o.frameRate, o.dt, o.scriptNotes ? [], o.victory] w[prop] = val for prop, val of o.trackedProperties perf.t1 = now() - w.thangs = (Thang.deserialize(thang, w, classMap) for thang in o.thangs) - w.setThang thang for thang in w.thangs + if w.thangs.length + for thangConfig in o.thangs when not w.thangMap[thangConfig.id] + w.thangs.push thang = Thang.deserialize(thangConfig, w, classMap) + w.setThang thang + else + w.thangs = (Thang.deserialize(thang, w, classMap) for thang in o.thangs) + w.setThang thang for thang in w.thangs w.scriptNotes = (WorldScriptNote.deserialize(sn, w, classMap) for sn in o.scriptNotes) perf.t2 = now() @@ -400,7 +447,7 @@ module.exports = class World o.trackedPropertiesPerThangValues.push (trackedPropertiesValues = []) trackedPropertiesValuesOffsets = o.trackedPropertiesPerThangValuesOffsets[thangIndex] for type, propIndex in trackedPropertyTypes - storage = ThangState.createArrayForType(type, o.totalFrames, o.storageBuffer, trackedPropertiesValuesOffsets[propIndex])[0] + storage = ThangState.createArrayForType(type, nFrames, o.storageBuffer, trackedPropertiesValuesOffsets[propIndex])[0] unless typedArraySupport # This could be more efficient i = trackedPropertiesValuesOffsets[propIndex] @@ -409,45 +456,52 @@ module.exports = class World perf.t3 = now() perf.batches = 0 - w.frames = [] - _.delay @deserializeSomeFrames, 1, o, w, finishedWorldCallback, perf + perf.framesCPUTime = 0 + w.frames = [] unless streamingWorld + clearTimeout @deserializationTimeout if @deserializationTimeout + @deserializationTimeout = _.delay @deserializeSomeFrames, 1, o, w, finishedWorldCallback, perf, startFrame, endFrame # Spread deserialization out across multiple calls so the interface stays responsive - @deserializeSomeFrames: (o, w, finishedWorldCallback, perf) => + @deserializeSomeFrames: (o, w, finishedWorldCallback, perf, startFrame, endFrame) => ++perf.batches startTime = now() - for frameIndex in [w.frames.length ... o.totalFrames] - w.frames.push WorldFrame.deserialize(w, frameIndex, o.trackedPropertiesThangIDs, o.trackedPropertiesThangs, o.trackedPropertiesPerThangKeys, o.trackedPropertiesPerThangTypes, o.trackedPropertiesPerThangValues, o.specialKeysToValues, o.frameHashes[frameIndex]) - if (now() - startTime) > DESERIALIZATION_INTERVAL - _.delay @deserializeSomeFrames, 1, o, w, finishedWorldCallback, perf + for frameIndex in [w.frames.length ... endFrame] + w.frames.push WorldFrame.deserialize(w, frameIndex - startFrame, o.trackedPropertiesThangIDs, o.trackedPropertiesThangs, o.trackedPropertiesPerThangKeys, o.trackedPropertiesPerThangTypes, o.trackedPropertiesPerThangValues, o.specialKeysToValues, o.frameHashes[frameIndex - startFrame], w.dt * frameIndex) + elapsed = now() - startTime + if elapsed > DESERIALIZATION_INTERVAL and frameIndex < endFrame - 1 + #console.log " Deserialization not finished, let's do it again soon. Have:", w.frames.length, ", wanted from", startFrame, "to", endFrame + perf.framesCPUTime += elapsed + @deserializationTimeout = _.delay @deserializeSomeFrames, 1, o, w, finishedWorldCallback, perf, startFrame, endFrame return - @finishDeserializing w, finishedWorldCallback, perf + @deserializationTimeout = null + perf.framesCPUTime += elapsed + @finishDeserializing w, finishedWorldCallback, perf, startFrame, endFrame - @finishDeserializing: (w, finishedWorldCallback, perf) -> + @finishDeserializing: (w, finishedWorldCallback, perf, startFrame, endFrame) -> perf.t4 = now() w.ended = true - w.getFrame(w.totalFrames - 1).restoreState() - perf.t5 = now() - console.log 'Deserialization:', (perf.t5 - perf.t0).toFixed(0) + 'ms (' + ((perf.t5 - perf.t0) / w.frames.length).toFixed(3) + 'ms per frame).', perf.batches, 'batches.' + nFrames = endFrame - startFrame + totalCPUTime = perf.t3 - perf.t0 + perf.framesCPUTime + #console.log 'Deserialization:', totalCPUTime.toFixed(0) + 'ms (' + (totalCPUTime / nFrames).toFixed(3) + 'ms per frame).', perf.batches, 'batches. Did', startFrame, 'to', endFrame, 'in', (perf.t4 - perf.t0).toFixed(0) + 'ms wall clock time.' if false console.log ' Deserializing--constructing new World:', (perf.t1 - perf.t0).toFixed(2) + 'ms' console.log ' Deserializing--Thangs and ScriptNotes:', (perf.t2 - perf.t1).toFixed(2) + 'ms' console.log ' Deserializing--reallocating memory:', (perf.t3 - perf.t2).toFixed(2) + 'ms' - console.log ' Deserializing--WorldFrames:', (perf.t4 - perf.t3).toFixed(2) + 'ms' - console.log ' Deserializing--restoring last WorldFrame:', (perf.t5 - perf.t4).toFixed(2) + 'ms' + console.log ' Deserializing--WorldFrames:', (perf.t4 - perf.t3).toFixed(2) + 'ms wall clock time,', (perf.framesCPUTime).toFixed(2) + 'ms CPU time' finishedWorldCallback w findFirstChangedFrame: (oldWorld) -> - return @firstChangedFrame = 0 unless oldWorld + return 0 unless oldWorld for newFrame, i in @frames oldFrame = oldWorld.frames[i] - break unless oldFrame and newFrame.hash is oldFrame.hash - @firstChangedFrame = i - if @frames[i] - console.log 'First changed frame is', @firstChangedFrame, 'with hash', @frames[i].hash, 'compared to', oldWorld.frames[i]?.hash - else - console.log 'No frames were changed out of all', @frames.length - @firstChangedFrame + break unless oldFrame and ((newFrame.hash is oldFrame.hash) or not newFrame.hash? or not oldFrame.hash?) # undefined gets in there when streaming at the last frame of each batch for some reason + firstChangedFrame = i + if @frames.length is @totalFrames + if @frames[i] + console.log 'First changed frame is', firstChangedFrame, 'with hash', @frames[i].hash, 'compared to', oldWorld.frames[i]?.hash + else + console.log 'No frames were changed out of all', @frames.length + firstChangedFrame pointsForThang: (thangID, frameStart=0, frameEnd=null, camera=null, resolution=4) -> # Optimized diff --git a/app/lib/world/world_frame.coffee b/app/lib/world/world_frame.coffee index 2ab99f153..d1386b718 100644 --- a/app/lib/world/world_frame.coffee +++ b/app/lib/world/world_frame.coffee @@ -60,9 +60,9 @@ module.exports = class WorldFrame thangState.serialize(frameIndex, trackedPropertiesPerThangIndices[thangIndex], trackedPropertiesPerThangTypes[thangIndex], trackedPropertiesPerThangValues[thangIndex], specialValuesToKeys, specialKeysToValues) @hash - @deserialize: (world, frameIndex, trackedPropertiesThangIDs, trackedPropertiesThangs, trackedPropertiesPerThangKeys, trackedPropertiesPerThangTypes, trackedPropertiesPerThangValues, specialKeysToValues, hash) -> + @deserialize: (world, frameIndex, trackedPropertiesThangIDs, trackedPropertiesThangs, trackedPropertiesPerThangKeys, trackedPropertiesPerThangTypes, trackedPropertiesPerThangValues, specialKeysToValues, hash, age) -> # Optimize - wf = new WorldFrame null, world.dt * frameIndex + wf = new WorldFrame null, age wf.world = world wf.hash = hash for thangID, thangIndex in trackedPropertiesThangIDs diff --git a/app/locale/en.coffee b/app/locale/en.coffee index e649a439b..27c340771 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -498,7 +498,9 @@ space: "Space" enter: "Enter" escape: "Escape" + shift: "Shift" cast_spell: "Cast current spell." + run_real_time: "Run in real time." continue_script: "Continue past current script." skip_scripts: "Skip past all skippable scripts." toggle_playback: "Toggle play/pause." diff --git a/app/models/ThangType.coffee b/app/models/ThangType.coffee index 24e191d29..8a8d990ea 100644 --- a/app/models/ThangType.coffee +++ b/app/models/ThangType.coffee @@ -63,6 +63,7 @@ module.exports = class ThangType extends CocoModel options = _.clone options options.resolutionFactor ?= SPRITE_RESOLUTION_FACTOR options.async ?= false + options.thang = null # Don't hold onto any bad Thang references. options buildSpriteSheet: (options) -> @@ -276,7 +277,7 @@ module.exports = class ThangType extends CocoModel @get('components') or [], (compRef) -> compRef.original is LevelComponent.ItemID) return itemComponentRef?.config?.slots or [] - + getFrontFacingStats: -> stats = [] for component in @get('components') or [] @@ -317,4 +318,4 @@ module.exports = class ThangType extends CocoModel snippets = config.programmableSnippets if snippets.length stats.push { name: 'Snippets', value: snippets.join(', ') } - stats \ No newline at end of file + stats diff --git a/app/schemas/subscriptions/play.coffee b/app/schemas/subscriptions/play.coffee index 4576450de..f9dce2648 100644 --- a/app/schemas/subscriptions/play.coffee +++ b/app/schemas/subscriptions/play.coffee @@ -57,6 +57,48 @@ module.exports = 'level:victory-hidden': {} # TODO schema + 'level:flag-color-selected': + type: 'object' + additionalProperties: false + properties: + color: + oneOf: [ + {type: 'null'} + {type: 'string', enum: ['green', 'black', 'violet'], description: 'The flag color to place next, or omitted/null if deselected.'} + ] + pos: + type: 'object' + additionalProperties: false + required: ['x', 'y'] + properties: + x: {type: 'number'} + y: {type: 'number'} + + 'level:flag-updated': + type: 'object' + additionalProperties: false + required: ['player', 'color', 'time', 'active'] + properties: + player: + type: 'string' + team: + type: 'string' + color: + type: 'string' + enum: ['green', 'black', 'violet'] + time: + type: 'number' + minimum: 0 + active: + type: 'boolean' + pos: + type: 'object' + additionalProperties: false + required: ['x', 'y'] + properties: + x: {type: 'number'} + y: {type: 'number'} + 'next-game-pressed': {} # TODO schema @@ -105,6 +147,14 @@ module.exports = 'playback:manually-scrubbed': {} # TODO schema + 'playback:real-time-playback-started': + type: 'object' + additionalProperties: false + + 'playback:real-time-playback-ended': + type: 'object' + additionalProperties: false + 'change:editor-config': {} # TODO schema diff --git a/app/schemas/subscriptions/surface.coffee b/app/schemas/subscriptions/surface.coffee index 0c7413248..ee3b98458 100644 --- a/app/schemas/subscriptions/surface.coffee +++ b/app/schemas/subscriptions/surface.coffee @@ -94,3 +94,15 @@ module.exports = # /app/lib/surface 'echo-all-wizard-sprites': {} # TODO schema + + 'surface:flag-appeared': + type: 'object' + additionalProperties: false + required: ['sprite'] + properties: + sprite: + type: 'object' + + 'surface:remove-selected-flag': + type: 'object' + additionalProperties: false diff --git a/app/schemas/subscriptions/tome.coffee b/app/schemas/subscriptions/tome.coffee index 42af94472..a582156e1 100644 --- a/app/schemas/subscriptions/tome.coffee +++ b/app/schemas/subscriptions/tome.coffee @@ -11,10 +11,11 @@ module.exports = type: "object" preload: type: "boolean" + realTime: + type: "boolean" required: [] additionalProperties: false - # TODO do we really need both 'cast-spell' and 'cast-spells'? "tome:cast-spells": title: "Cast Spells" $schema: "http://json-schema.org/draft-04/schema#" @@ -25,6 +26,8 @@ module.exports = type: "object" preload: type: "boolean" + realTime: + type: "boolean" required: [] additionalProperties: false @@ -33,7 +36,9 @@ module.exports = $schema: "http://json-schema.org/draft-04/schema#" description: "Published when you wish to manually recast all spells" type: "object" - properties: {} + properties: + realTime: + type: "boolean" required: [] additionalProperties: false diff --git a/app/schemas/subscriptions/world.coffee b/app/schemas/subscriptions/world.coffee index 9cb7487f0..0eb748c5e 100644 --- a/app/schemas/subscriptions/world.coffee +++ b/app/schemas/subscriptions/world.coffee @@ -11,5 +11,8 @@ module.exports = 'god:new-world-created': {} # TODO schema + 'god:streaming-world-updated': + {} # TODO schema + 'god:world-load-progress-changed': {} # TODO schema diff --git a/app/styles/base.sass b/app/styles/base.sass index 332aaa624..a00d037ee 100644 --- a/app/styles/base.sass +++ b/app/styles/base.sass @@ -225,10 +225,6 @@ table.table .ui-slider-handle border: 1px solid black !important -.flag-cursor - cursor: crosshair - - // Fonts .header-font diff --git a/app/styles/play/level.sass b/app/styles/play/level.sass index b0d52ea6f..c87d229bb 100644 --- a/app/styles/play/level.sass +++ b/app/styles/play/level.sass @@ -10,6 +10,28 @@ body.is-playing margin: 0 auto @include user-select(none) + &.real-time + // Hmm, somehow the #page-container is cutting us off by ~17px on the right, looks a bit off. + + #canvas-wrapper + width: 100% + canvas#surface + margin: 0 auto + #control-bar-view, #playback-view + width: 100% + #code-area, #thang-hud, #goals-view + display: none + visibility: hidden + #gold-view + right: 1% + #control-bar-view .title + left: 20% + width: 60% + text-align: center + + .level-content + margin: 0px auto + .level-content position: relative @@ -17,13 +39,17 @@ body.is-playing width: 55% position: relative overflow: hidden + @include transition(0.5s ease-out) canvas#surface background-color: #333 - width: 100% display: block z-index: 1 - + @include transition(0.5s ease-out) + + &.flag-color-selected + cursor: crosshair + min-width: 1024px position: relative diff --git a/app/styles/play/level/gold.sass b/app/styles/play/level/gold.sass index c1efa04bb..283f5b7f9 100644 --- a/app/styles/play/level/gold.sass +++ b/app/styles/play/level/gold.sass @@ -6,9 +6,8 @@ position: absolute right: 46% top: 42px - user-select: none - -webkit-user-select: none @include transition(box-shadow .2s linear) + @include user-select(none) padding: 4px background: transparent url(/images/level/gold_background.png) no-repeat background-size: 100% 100% diff --git a/app/styles/play/level/level-flags-view.sass b/app/styles/play/level/level-flags-view.sass new file mode 100644 index 000000000..167f9227c --- /dev/null +++ b/app/styles/play/level/level-flags-view.sass @@ -0,0 +1,29 @@ +@import "app/styles/mixins" +@import "app/styles/bootstrap/mixins" + +#level-flags-view + display: none + position: absolute + top: 42px + left: 1% + @include transition(box-shadow .2s linear) + @include user-select(none) + padding: 4px + background: transparent url(/images/level/gold_background.png) no-repeat + background-size: 100% 100% + border-radius: 4px + + &:hover + box-shadow: 2px 2px 2px black + + .flag-button + margin: 3px + font-size: 14px + + &.green-flag + color: darkgreen + &.black-flag + color: black + &.violet-flag + color: violet + diff --git a/app/styles/play/level/playback.sass b/app/styles/play/level/playback.sass index ebeff9846..b67e8ee28 100644 --- a/app/styles/play/level/playback.sass +++ b/app/styles/play/level/playback.sass @@ -97,6 +97,8 @@ background-image: none border-radius: 0 border: 0 + // Can't do this transition because handle then jitters, but would be good for streaming. + //@include transition(width .2s linear) &.disabled cursor: default diff --git a/app/styles/play/level/tome/cast_button.sass b/app/styles/play/level/tome/cast_button.sass index 87afdad55..38e71cc3d 100644 --- a/app/styles/play/level/tome/cast_button.sass +++ b/app/styles/play/level/tome/cast_button.sass @@ -82,7 +82,9 @@ height: 40px .cast-button - width: 100% + width: 80% + width: -webkit-calc(100% - 40px) + width: calc(100% - 40px) border-top-left-radius: 6px border-bottom-left-radius: 6px @@ -92,3 +94,8 @@ font-size: 14px @media screen and (max-width: 1024px) font-size: 12px + + .cast-real-time-button + width: 20% + width: -webkit-calc(40px) + width: calc(40px) diff --git a/app/styles/play/spectate.sass b/app/styles/play/spectate.sass index 43d44e320..746a6589f 100644 --- a/app/styles/play/spectate.sass +++ b/app/styles/play/spectate.sass @@ -19,11 +19,11 @@ .title position: absolute display: inline-block - margin-left: 50% - right: 0 color: #BEBEBE line-height: 15px - left: 0 + left: 20% + width: 60% + text-align: center max-width: 1920px margin: 0 auto diff --git a/app/templates/play/level.jade b/app/templates/play/level.jade index a4d9d4fd9..ac9b335f9 100644 --- a/app/templates/play/level.jade +++ b/app/templates/play/level.jade @@ -13,6 +13,8 @@ #canvas-top-gradient.gradient #goals-view.secret + #level-flags-view.secret + #gold-view.secret.expanded #level-chat-view diff --git a/app/templates/play/level/level-flags-view.jade b/app/templates/play/level/level-flags-view.jade new file mode 100644 index 000000000..036ebf6c1 --- /dev/null +++ b/app/templates/play/level/level-flags-view.jade @@ -0,0 +1,9 @@ +button.flag-button.btn.btn-xs.green-flag(title="g: Place a green flag") + span.glyphicon.glyphicon-flag + | g +button.flag-button.btn.btn-xs.black-flag(title="b: Place a black flag") + span.glyphicon.glyphicon-flag + | b +button.flag-button.btn.btn-xs.violet-flag(title="v: Place a violet flag") + span.glyphicon.glyphicon-flag + | v diff --git a/app/templates/play/level/modal/keyboard_shortcuts.jade b/app/templates/play/level/modal/keyboard_shortcuts.jade index a920a6f73..41b67c8f8 100644 --- a/app/templates/play/level/modal/keyboard_shortcuts.jade +++ b/app/templates/play/level/modal/keyboard_shortcuts.jade @@ -8,6 +8,10 @@ block modal-body-content dt(title="Shift+" + enter) code ⇧+#{enter} dd(data-i18n="keyboard_shortcuts.cast_spell") Cast current spell. + dl.dl-horizontal + dt(title=ctrlName + "+Shift+" + enter) + code #{ctrl}+⇧+#{enter} + dd(data-i18n="keyboard_shortcuts.run_real_time") Run in real time. dl.dl-horizontal dt(title="Shift+" + space) code ⇧+#{space} diff --git a/app/templates/play/level/tome/cast_button.jade b/app/templates/play/level/tome/cast_button.jade index cbaf3079b..3f3d6669f 100644 --- a/app/templates/play/level/tome/cast_button.jade +++ b/app/templates/play/level/tome/cast_button.jade @@ -1,3 +1,5 @@ -div.btn-group.btn-group-lg.cast-button-group.dropup +div.btn-group.btn-group-lg.cast-button-group .button-progress-overlay - button.btn.btn-inverse.banner.cast-button(title=castShortcutVerbose + ": Cast current spell", data-i18n="play_level.tome_cast_button_cast") Spell Cast + button.btn.btn-inverse.banner.cast-button(title=castVerbose, data-i18n="play_level.tome_cast_button_cast") Spell Cast + button.btn.btn-inverse.banner.cast-real-time-button(title=castRealTimeVerbose) + i.glyphicon.glyphicon-play \ No newline at end of file diff --git a/app/views/kinds/SearchView.coffee b/app/views/kinds/SearchView.coffee index 1e41c8ead..7d5fa8b0f 100644 --- a/app/views/kinds/SearchView.coffee +++ b/app/views/kinds/SearchView.coffee @@ -83,5 +83,5 @@ module.exports = class SearchView extends RootView newModel: (e) -> modal = new NewModelModal model: @model, modelLabel: @modelLabel - modal.once 'success', @onNewModelSaved + modal.once 'model-created', @onNewModelSaved @openModalView modal diff --git a/app/views/play/SpectateView.coffee b/app/views/play/SpectateView.coffee index bda2cce7e..8c23d779d 100644 --- a/app/views/play/SpectateView.coffee +++ b/app/views/play/SpectateView.coffee @@ -55,6 +55,7 @@ module.exports = class SpectateLevelView extends RootView 'surface:world-set-up': 'onSurfaceSetUpNewWorld' 'level:set-team': 'setTeam' 'god:new-world-created': 'loadSoundsForWorld' + 'god:streaming-world-updated': 'loadSoundsForWorld' 'next-game-pressed': 'onNextGamePressed' 'level:started': 'onLevelStarted' 'level:loading-view-unveiled': 'onLoadingViewUnveiled' diff --git a/app/views/play/ladder/LadderTabView.coffee b/app/views/play/ladder/LadderTabView.coffee index 1780fa057..27a43c4b6 100644 --- a/app/views/play/ladder/LadderTabView.coffee +++ b/app/views/play/ladder/LadderTabView.coffee @@ -197,8 +197,9 @@ module.exports = class LadderTabView extends CocoView formatCount = d3.format(',.0') - x = d3.scale.linear().domain([-3000, 6000]).range([0, width]) - + minX = Math.floor(Math.min(histogramData...) / 1000) * 1000 + maxX = Math.ceil(Math.max(histogramData...) / 1000) * 1000 + x = d3.scale.linear().domain([minX, maxX]).range([0, width]) data = d3.layout.histogram().bins(x.ticks(20))(histogramData) y = d3.scale.linear().domain([0, d3.max(data, (d) -> d.y)]).range([height, 10]) diff --git a/app/views/play/level/LevelFlagsView.coffee b/app/views/play/level/LevelFlagsView.coffee new file mode 100644 index 000000000..76d55a056 --- /dev/null +++ b/app/views/play/level/LevelFlagsView.coffee @@ -0,0 +1,76 @@ +CocoView = require 'views/kinds/CocoView' +template = require 'templates/play/level/level-flags-view' +{me} = require 'lib/auth' + +module.exports = class LevelFlagsView extends CocoView + id: 'level-flags-view' + template: template + + subscriptions: + 'playback:real-time-playback-started': 'onRealTimePlaybackStarted' + 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' + 'surface:stage-mouse-down': 'onStageMouseDown' + 'god:new-world-created': 'onNewWorld' + 'god:streaming-world-updated': 'onNewWorld' + 'surface:remove-flag': 'onRemoveFlag' + + events: + 'click .green-flag': -> @onFlagSelected color: 'green', source: 'button' + 'click .black-flag': -> @onFlagSelected color: 'black', source: 'button' + 'click .violet-flag': -> @onFlagSelected color: 'violet', source: 'button' + + shortcuts: + 'g': -> @onFlagSelected color: 'green', source: 'shortcut' + 'b': -> @onFlagSelected color: 'black', source: 'shortcut' + 'v': -> @onFlagSelected color: 'violet', source: 'shortcut' + 'esc': -> @onFlagSelected color: null, source: 'shortcut' + 'delete, del, backspace': 'onDeletePressed' + + constructor: (options) -> + super options + @world = options.world + + onRealTimePlaybackStarted: (e) -> + @realTime = true + @$el.show() + @flags = {} + @flagHistory = [] + + onRealTimePlaybackEnded: (e) -> + @onFlagSelected color: null + @realTime = false + @$el.hide() + + onFlagSelected: (e) -> + return unless @realTime + return if @flagColor is e.color and e.source is 'shortcut' + color = if e.source is 'button' and e.color is @flagColor then null else e.color + @flagColor = color + Backbone.Mediator.publish 'level:flag-color-selected', color: color + @$el.find('.flag-button').removeClass('active') + @$el.find(".#{color}-flag").addClass('active') if color + + onStageMouseDown: (e) -> + return unless @flagColor and @realTime + pos = x: e.worldPos.x, y: e.worldPos.y + flag = player: me.id, team: me.team, color: @flagColor, pos: pos, time: @world.dt * @world.frames.length, active: true + @flags[@flagColor] = flag + @flagHistory.push flag + Backbone.Mediator.publish 'level:flag-updated', flag + #console.log 'trying to place flag at', @world.age, 'and think it will happen by', flag.time + + onDeletePressed: (e) -> + return unless @realTime + Backbone.Mediator.publish 'surface:remove-selected-flag', {} + @onFlagSelected color: null, source: 'shortcut' + + onRemoveFlag: (e) -> + delete @flags[e.color] + flag = player: me.id, team: me.team, color: e.color, time: @world.dt * @world.frames.length, active: false + @flagHistory.push flag + Backbone.Mediator.publish 'level:flag-updated', flag + #console.log e.color, 'deleted at time', flag.time + + onNewWorld: (event) -> + return unless event.world.name is @world.name + @world = @options.world = event.world diff --git a/app/views/play/level/LevelHUDView.coffee b/app/views/play/level/LevelHUDView.coffee index 6daa1d8d9..a273712b2 100644 --- a/app/views/play/level/LevelHUDView.coffee +++ b/app/views/play/level/LevelHUDView.coffee @@ -70,6 +70,8 @@ module.exports = class LevelHUDView extends CocoView @thang = e.world.thangMap[@thang.id] if @thang if hadThang and not @thang @setThang null, null + else if @thang + @createActions() # Make sure it updates its actions. setThang: (thang, thangType) -> unless @speaker diff --git a/app/views/play/level/LevelPlaybackView.coffee b/app/views/play/level/LevelPlaybackView.coffee index eb619ce06..63fdc9379 100644 --- a/app/views/play/level/LevelPlaybackView.coffee +++ b/app/views/play/level/LevelPlaybackView.coffee @@ -22,8 +22,10 @@ module.exports = class LevelPlaybackView extends CocoView 'level-toggle-grid': 'onToggleGrid' 'surface:frame-changed': 'onFrameChanged' 'god:new-world-created': 'onNewWorld' + 'god:streaming-world-updated': 'onNewWorld' 'level-set-letterbox': 'onSetLetterbox' - 'tome:cast-spells': 'onCastSpells' + 'tome:cast-spells': 'onTomeCast' + 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' events: 'click #debug-toggle': 'onToggleDebug' @@ -36,7 +38,7 @@ module.exports = class LevelPlaybackView extends CocoView 'click #zoom-out-button': -> Backbone.Mediator.publish('camera-zoom-out') unless @shouldIgnore() 'click #volume-button': 'onToggleVolume' 'click #play-button': 'onTogglePlay' - 'click': -> Backbone.Mediator.publish 'tome:focus-editor' + 'click': -> Backbone.Mediator.publish 'tome:focus-editor' unless @realTime 'mouseenter #timeProgress': 'onProgressEnter' 'mouseleave #timeProgress': 'onProgressLeave' 'mousemove #timeProgress': 'onProgressHover' @@ -114,6 +116,15 @@ module.exports = class LevelPlaybackView extends CocoView ua = navigator.userAgent.toLowerCase() if /safari/.test(ua) and not /chrome/.test(ua) @$el.find('.toggle-fullscreen').hide() + @timePopup ?= new HoverPopup + t = $.i18n.t + @second = t 'units.second' + @seconds = t 'units.seconds' + @minute = t 'units.minute' + @minutes = t 'units.minutes' + @goto = t 'play_level.time_goto' + @current = t 'play_level.time_current' + @total = t 'play_level.time_total' updatePopupContent: -> @timePopup?.updateContent "<h2>#{@timeToString @newTime}</h2>#{@formatTime(@current, @currentTime)}<br/>#{@formatTime(@total, @totalTime)}" @@ -142,32 +153,34 @@ module.exports = class LevelPlaybackView extends CocoView @$el.find('#music-button').toggleClass('music-on', me.get('music')) onSetLetterbox: (e) -> - buttons = @$el.find '#play-button, .scrubber-handle' - buttons.css 'visibility', if e.on then 'hidden' else 'visible' + return if @realTime + @togglePlaybackControls !e.on @disabled = e.on + togglePlaybackControls: (to) -> + buttons = @$el.find '#play-button, .scrubber-handle' + buttons.css 'visibility', if to then 'visible' else 'hidden' + + onTomeCast: (e) -> + return unless e.realTime + @realTime = true + @togglePlaybackControls false + Backbone.Mediator.publish 'playback:real-time-playback-started', {} + onWindowResize: (s...) => @barWidth = $('.progress', @$el).width() onNewWorld: (e) -> - @totalTime = e.world.totalFrames / e.world.frameRate - pct = parseInt(100 * e.world.totalFrames / e.world.maxTotalFrames) + '%' + @updateBarWidth e.world.frames.length, e.world.maxTotalFrames, e.world.dt + + updateBarWidth: (loadedFrameCount, maxTotalFrames, dt) -> + @totalTime = loadedFrameCount * dt + pct = parseInt(100 * loadedFrameCount / maxTotalFrames) + '%' @barWidth = $('.progress', @$el).css('width', pct).show().width() - @casting = false $('.scrubber .progress', @$el).slider('enable', true) @newTime = 0 @currentTime = 0 - - @timePopup ?= new HoverPopup - - t = $.i18n.t - @second = t 'units.second' - @seconds = t 'units.seconds' - @minute = t 'units.minute' - @minutes = t 'units.minutes' - @goto = t 'play_level.time_goto' - @current = t 'play_level.time_current' - @total = t 'play_level.time_total' + @lastLoadedFrameCount = loadedFrameCount onToggleDebug: -> return if @shouldIgnore() @@ -188,11 +201,6 @@ module.exports = class LevelPlaybackView extends CocoView onViewKeyboardShortcuts: -> @openModalView new KeyboardShortcutsModal() - onCastSpells: (e) -> - return if e.preload - @casting = true - @$progressScrubber.slider('disable', true) - onDisableControls: (e) -> if not e.controls or 'playback' in e.controls @disabled = true @@ -205,6 +213,7 @@ module.exports = class LevelPlaybackView extends CocoView $('#volume-button', @$el).removeClass('disabled') onEnableControls: (e) -> + return if @realTime if not e.controls or 'playback' in e.controls @disabled = false $('button', @$el).removeClass('disabled') @@ -255,7 +264,7 @@ module.exports = class LevelPlaybackView extends CocoView # @currentTime = @totalTime if Math.abs(@totalTime - @currentTime) < 0.04 @updatePopupContent() if @timePopup?.shown - @updateProgress(e.progress) + @updateProgress(e.progress, e.world) @updatePlayButton(e.progress) @lastProgress = e.progress @@ -277,16 +286,28 @@ module.exports = class LevelPlaybackView extends CocoView if @timePopup and Math.abs(@currentTime - @newTime) < 1 and not @timePopup.shown @timePopup.show() - updateProgress: (progress) -> - $('.scrubber .progress-bar', @$el).css('width', "#{progress*100}%") + updateProgress: (progress, world) -> + if world.frames.length isnt @lastLoadedFrameCount + @updateBarWidth world.frames.length, world.maxTotalFrames, world.dt + wasLoaded = @worldCompletelyLoaded + @worldCompletelyLoaded = world.frames.length is world.totalFrames + if @realTime and @worldCompletelyLoaded and not wasLoaded + Backbone.Mediator.publish 'playback:real-time-playback-ended', {} + $('.scrubber .progress-bar', @$el).css('width', "#{progress * 100}%") updatePlayButton: (progress) -> - if progress >= 0.99 and @lastProgress < 0.99 + if @worldCompletelyLoaded and progress >= 0.99 and @lastProgress < 0.99 $('#play-button').removeClass('playing').removeClass('paused').addClass('ended') + Backbone.Mediator.publish 'playback:real-time-playback-ended', {} if @realTime if progress < 0.99 and @lastProgress >= 0.99 b = $('#play-button').removeClass('ended') if @playing then b.addClass('playing') else b.addClass('paused') + onRealTimePlaybackEnded: (e) -> + return unless @realTime + @realTime = false + @togglePlaybackControls true + onSetDebug: (e) -> flag = $('#debug-toggle i.icon-ok') flag.toggleClass 'invisible', not e.debug @@ -299,20 +320,22 @@ module.exports = class LevelPlaybackView extends CocoView hookUpScrubber: -> @sliderIncrements = 500 # max slider width before we skip pixels - @clickingSlider = false # whether the mouse has been pressed down without moving @$progressScrubber.slider( max: @sliderIncrements animate: 'slow' slide: (event, ui) => + return if @shouldIgnore() @scrubTo ui.value / @sliderIncrements @slideCount += 1 start: (event, ui) => + return if @shouldIgnore() @slideCount = 0 @wasPlaying = @playing Backbone.Mediator.publish 'level-set-playing', {playing: false} stop: (event, ui) => + return if @shouldIgnore() @actualProgress = ui.value / @sliderIncrements Backbone.Mediator.publish 'playback:manually-scrubbed', ratio: @actualProgress Backbone.Mediator.publish 'level-set-playing', {playing: @wasPlaying} @@ -329,7 +352,7 @@ module.exports = class LevelPlaybackView extends CocoView return if @shouldIgnore() Backbone.Mediator.publish 'level-set-time', ratio: ratio, scrubDuration: duration - shouldIgnore: -> return @disabled or @casting or false + shouldIgnore: -> return @disabled or @realTime onTogglePlay: (e) -> e?.preventDefault() diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index 604625705..dde56b11e 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -29,6 +29,7 @@ HUDView = require './LevelHUDView' ControlBarView = require './ControlBarView' LevelPlaybackView = require './LevelPlaybackView' GoalsView = require './LevelGoalsView' +LevelFlagsView = require './LevelFlagsView' GoldView = require './LevelGoldView' VictoryModal = require './modal/VictoryModal' InfiniteLoopModal = require './modal/InfiniteLoopModal' @@ -53,6 +54,7 @@ module.exports = class PlayLevelView extends RootView 'level-disable-controls': 'onDisableControls' 'level-enable-controls': 'onEnableControls' 'god:new-world-created': 'onNewWorld' + 'god:streaming-world-updated': 'onNewWorld' 'god:infinite-loop': 'onInfiniteLoop' 'level-reload-from-data': 'onLevelReloadFromData' 'level-reload-thang-type': 'onLevelReloadThangType' @@ -63,6 +65,8 @@ module.exports = class PlayLevelView extends RootView 'level:set-team': 'setTeam' 'level:started': 'onLevelStarted' 'level:loading-view-unveiled': 'onLoadingViewUnveiled' + 'playback:real-time-playback-started': 'onRealTimePlaybackStarted' + 'playback:real-time-playback-ended': 'onRealTimePlaybackEnded' events: 'click #level-done-button': 'onDonePressed' @@ -232,6 +236,7 @@ module.exports = class PlayLevelView extends RootView @insertSubView @tome = new TomeView levelID: @levelID, session: @session, otherSession: @otherSession, thangs: @world.thangs, supermodel: @supermodel @insertSubView new LevelPlaybackView session: @session @insertSubView new GoalsView {} + @insertSubView new LevelFlagsView world: @world @insertSubView new GoldView {} @insertSubView new HUDView {} @insertSubView new ChatView levelID: @levelID, sessionID: @session.id, session: @session @@ -507,11 +512,25 @@ module.exports = class PlayLevelView extends RootView @world = e.world @world.scripts = scripts thangTypes = @supermodel.getModels(ThangType) - for [spriteName, message] in @world.thangDialogueSounds() + startFrame = @lastWorldFramesLoaded ? 0 + if @world.frames.length is @world.totalFrames # Finished loading + @lastWorldFramesLoaded = 0 + else + @lastWorldFramesLoaded = @world.frames.length + for [spriteName, message] in @world.thangDialogueSounds startFrame continue unless thangType = _.find thangTypes, (m) -> m.get('name') is spriteName continue unless sound = AudioPlayer.soundForDialogue message, thangType.get('soundTriggers') AudioPlayer.preloadSoundReference sound + # Real-time playback + onRealTimePlaybackStarted: (e) -> + @$el.addClass('real-time').focus() + @onWindowResize() + + onRealTimePlaybackEnded: (e) -> + @$el.removeClass 'real-time' + @onWindowResize() + destroy: -> @levelLoader?.destroy() @surface?.destroy() diff --git a/app/views/play/level/ThangAvatarView.coffee b/app/views/play/level/ThangAvatarView.coffee index 90ea72be1..3c65bc848 100644 --- a/app/views/play/level/ThangAvatarView.coffee +++ b/app/views/play/level/ThangAvatarView.coffee @@ -70,7 +70,7 @@ module.exports = class ThangAvatarView extends CocoView @setProblems myProblems.length, worstLevel onNewWorld: (e) -> - @options.thang = @thang = e.world.thangMap[@thang.id] if @thang + @options.thang = @thang = e.world.thangMap[@thang.id] if @thang and e.world.thangMap[@thang.id] destroy: -> super() diff --git a/app/views/play/level/tome/CastButtonView.coffee b/app/views/play/level/tome/CastButtonView.coffee index fe82e4c28..f28500fdf 100644 --- a/app/views/play/level/tome/CastButtonView.coffee +++ b/app/views/play/level/tome/CastButtonView.coffee @@ -8,7 +8,7 @@ module.exports = class CastButtonView extends CocoView events: 'click .cast-button': 'onCastButtonClick' - 'click .autocast-delays a': 'onCastOptionsClick' + 'click .cast-real-time-button': 'onCastRealTimeButtonClick' subscriptions: 'tome:spell-changed': 'onSpellChanged' @@ -21,11 +21,15 @@ module.exports = class CastButtonView extends CocoView @spells = options.spells @levelID = options.levelID @castShortcut = '⇧↵' - @castShortcutVerbose = 'Shift+Enter' getRenderData: (context={}) -> context = super context - context.castShortcutVerbose = @castShortcutVerbose + shift = $.i18n.t 'keyboard_shortcuts.shift' + enter = $.i18n.t 'keyboard_shortcuts.enter' + castShortcutVerbose = "#{shift}+#{enter}" + castRealTimeShortcutVerbose = (if @isMac() then 'Cmd' else 'Ctrl') + '+' + castShortcutVerbose + context.castVerbose = castShortcutVerbose + ': ' + $.i18n.t('keyboard_shortcuts.cast_spell') + context.castRealTimeVerbose = castRealTimeShortcutVerbose + ': ' + $.i18n.t('keyboard_shortcuts.run_real_time') context afterRender: -> @@ -34,9 +38,7 @@ module.exports = class CastButtonView extends CocoView @castButtonGroup = $('.cast-button-group', @$el) @castOptions = $('.autocast-delays', @$el) delay = me.get('autocastDelay') - delay ?= 5000 - unless @levelID in ['rescue-mission', 'grab-the-mushroom', 'drink-me', 'its-a-trap', 'break-the-prison', 'taunt', 'cowardly-taunt', 'commanding-followers', 'mobile-artillery'] - delay = 90019001 + delay ?= 90019001 @setAutocastDelay delay attachTo: (spellView) -> @@ -45,6 +47,9 @@ module.exports = class CastButtonView extends CocoView onCastButtonClick: (e) -> Backbone.Mediator.publish 'tome:manual-cast', {} + onCastRealTimeButtonClick: (e) -> + Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} + onCastOptionsClick: (e) => Backbone.Mediator.publish 'tome:focus-editor' @castButtonGroup.removeClass 'open' @@ -98,6 +103,6 @@ module.exports = class CastButtonView extends CocoView @autocastDelay = delay = parseInt delay me.set('autocastDelay', delay) me.patch() - spell.view.setAutocastDelay delay for spellKey, spell of @spells + spell.view?.setAutocastDelay delay for spellKey, spell of @spells @castOptions.find('a').each -> $(@).toggleClass('selected', parseInt($(@).attr('data-delay')) is delay) diff --git a/app/views/play/level/tome/Spell.coffee b/app/views/play/level/tome/Spell.coffee index af6628eac..ff2dc39e6 100644 --- a/app/views/play/level/tome/Spell.coffee +++ b/app/views/play/level/tome/Spell.coffee @@ -40,16 +40,17 @@ module.exports = class Spell if @permissions.readwrite.length and sessionSource = @session.getSourceFor(@spellKey) @source = sessionSource @thangs = {} - @view = new SpellView {spell: @, session: @session, worker: @worker} - @view.render() # Get it ready and code loaded in advance - @tabView = new SpellListTabEntryView spell: @, supermodel: @supermodel, language: @language - @tabView.render() + if @canRead() # We can avoid creating these views if we'll never use them. + @view = new SpellView {spell: @, session: @session, worker: @worker} + @view.render() # Get it ready and code loaded in advance + @tabView = new SpellListTabEntryView spell: @, supermodel: @supermodel, language: @language + @tabView.render() @team = @permissions.readwrite[0] ? 'common' Backbone.Mediator.publish 'tome:spell-created', spell: @ destroy: -> - @view.destroy() - @tabView.destroy() + @view?.destroy() + @tabView?.destroy() @thangs = null @worker = null @@ -72,7 +73,7 @@ module.exports = class Spell (team ? me.team) in @permissions.readwrite getSource: -> - @view.getSource() + @view?.getSource() ? @source transpile: (source) -> if source diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee index 024b60ee0..80c86296f 100644 --- a/app/views/play/level/tome/SpellView.coffee +++ b/app/views/play/level/tome/SpellView.coffee @@ -114,6 +114,10 @@ module.exports = class SpellView extends CocoView name: 'run-code' bindKey: {win: 'Shift-Enter|Ctrl-Enter', mac: 'Shift-Enter|Command-Enter|Ctrl-Enter'} exec: -> Backbone.Mediator.publish 'tome:manual-cast', {} + addCommand + name: 'run-code-real-time' + bindKey: {win: 'Ctrl-Shift-Enter', mac: 'Command-Shift-Enter|Ctrl-Shift-Enter'} + exec: -> Backbone.Mediator.publish 'tome:manual-cast', {realTime: true} addCommand name: 'no-op' bindKey: {win: 'Ctrl-S', mac: 'Command-S|Ctrl-S'} @@ -128,10 +132,10 @@ module.exports = class SpellView extends CocoView # passEvent: true # https://github.com/ajaxorg/ace/blob/master/lib/ace/keyboard/keybinding.js#L114 # No easy way to selectively cancel shift+space, since we don't get access to the event. # Maybe we could temporarily set ourselves to read-only if we somehow know that a script is active? - exec: => + exec: => if @scriptRunning Backbone.Mediator.publish 'level:shift-space-pressed' - else + else @ace.insert ' ' addCommand @@ -269,8 +273,8 @@ module.exports = class SpellView extends CocoView # @addZatannaSnippets() @highlightCurrentLine() - cast: (preload=false) -> - Backbone.Mediator.publish 'tome:cast-spell', spell: @spell, thang: @thang, preload: preload + cast: (preload=false, realTime=false) -> + Backbone.Mediator.publish 'tome:cast-spell', spell: @spell, thang: @thang, preload: preload, realTime: realTime notifySpellChanged: => Backbone.Mediator.publish 'tome:spell-changed', spell: @spell @@ -285,7 +289,7 @@ module.exports = class SpellView extends CocoView onManualCast: (e) -> cast = @$el.parent().length - @recompile cast + @recompile cast, e.realTime @focus() if cast onCodeReload: (e) -> @@ -299,13 +303,16 @@ module.exports = class SpellView extends CocoView recompileIfNeeded: => @recompile() if @recompileNeeded - recompile: (cast=true) -> + recompile: (cast=true, realTime=false) -> @setRecompileNeeded false - return if @spell.source is @getSource() - @spell.transpile @getSource() - @updateAether true, false - @cast() if cast - @notifySpellChanged() + hasChanged = @spell.source isnt @getSource() + if hasChanged + @spell.transpile @getSource() + @updateAether true, false + if cast and (hasChanged or realTime) + @cast(false, realTime) + if hasChanged + @notifySpellChanged() updateACEText: (source) -> @eventsSuppressed = true @@ -472,7 +479,7 @@ module.exports = class SpellView extends CocoView onSessionWillSave: (e) -> return unless @spellHasChanged setTimeout(=> - unless @spellHasChanged + unless @destroyed or @spellHasChanged @$el.find('.save-status').finish().show().fadeOut(2000) , 1000) @spellHasChanged = false @@ -510,7 +517,7 @@ module.exports = class SpellView extends CocoView 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 - @spell.transpile() + @spell.transpile() # TODO: is there any way we can avoid doing this if it hasn't changed? Causes a slight hang. @updateAether false, false # -------------------------------------------------------------------------------------------------- diff --git a/app/views/play/level/tome/ThangListView.coffee b/app/views/play/level/tome/ThangListView.coffee index 03ab371c1..2b09f44e8 100644 --- a/app/views/play/level/tome/ThangListView.coffee +++ b/app/views/play/level/tome/ThangListView.coffee @@ -29,6 +29,8 @@ module.exports = class ThangListView extends CocoView false ), @sortScoreForThang @muggleThangs = _.sortBy _.without(@thangs, @readwriteThangs..., @readThangs...), @sortScoreForThang + if @muggleThangs.length > 15 + @muggleThangs = [] # Don't render a zillion of these. Slow, too long, maybe not useful. sortScoreForThang: (t) => # Sort by my team, then most spells and fewest shared Thangs per spell, @@ -73,6 +75,12 @@ module.exports = class ThangListView extends CocoView null adjustThangs: (spells, thangs) -> + # TODO: it would be nice to not have to do this any more, like if we migrate to the hero levels. + # Recreating all the ThangListEntryViews and their ThangAvatarViews is pretty slow. + # So they aren't even kept up-to-date during world streaming. + # Updating the existing subviews? Would be kind of complicated to get all the new thangs and spells propagated. + # I would do it, if I didn't think we were perhaps soon to not do the ThangList any more. + # Will temporary reduce the number of muggle thangs we're willing to draw. @spells = @options.spells = spells for entry in @entries entry.$el.remove() diff --git a/app/views/play/level/tome/TomeView.coffee b/app/views/play/level/tome/TomeView.coffee index fa20c5c45..246fae8d3 100644 --- a/app/views/play/level/tome/TomeView.coffee +++ b/app/views/play/level/tome/TomeView.coffee @@ -142,6 +142,9 @@ module.exports = class TomeView extends CocoView else delete @thangSpells[thangID] spell.removeThangID thangID for spell in @spells + for spellKey, spell of @spells when not spell.canRead() # Make sure these get transpiled (they have no views). + spell.transpile() + spell.loaded = true null onSpellLoaded: (e) -> @@ -152,10 +155,10 @@ module.exports = class TomeView extends CocoView onCastSpell: (e) -> # A single spell is cast. # Hmm; do we need to make sure other spells are all cast here? - @cast e?.preload + @cast e?.preload, e?.realTime - cast: (preload=false) -> - Backbone.Mediator.publish 'tome:cast-spells', spells: @spells, preload: preload + cast: (preload=false, realTime=false) -> + Backbone.Mediator.publish 'tome:cast-spells', spells: @spells, preload: preload, realTime: realTime onToggleSpellList: (e) -> @spellList.rerenderEntries() @@ -209,6 +212,7 @@ module.exports = class TomeView extends CocoView spellFor: (thang, spellName) -> return null unless thang?.isProgrammable + return unless @thangSpells[thang.id] # Probably in streaming mode, where we don't update until it's done. selectedThangSpells = (@spells[spellKey] for spellKey in @thangSpells[thang.id]) if spellName spell = _.find selectedThangSpells, {name: spellName}