From fe1598cab274e16fe6e4c85ed3755d5d758ebed2 Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Fri, 24 Jun 2016 11:07:38 -0700 Subject: [PATCH] Implement multi-select, remove click-to-navigate from level editor --- app/lib/surface/Lank.coffee | 4 + app/lib/surface/LankBoss.coffee | 20 +- app/models/GameUIState.coffee | 26 ++- .../editor/level/thangs/ThangsTabView.coffee | 188 +++++++++++------- ...{camera.spec.coffee => Camera.spec.coffee} | 18 -- test/app/lib/surface/LankBoss.spec.coffee | 2 + 6 files changed, 158 insertions(+), 100 deletions(-) rename test/app/lib/surface/{camera.spec.coffee => Camera.spec.coffee} (92%) diff --git a/app/lib/surface/Lank.coffee b/app/lib/surface/Lank.coffee index eebd24a17..42dd9a150 100644 --- a/app/lib/surface/Lank.coffee +++ b/app/lib/surface/Lank.coffee @@ -648,6 +648,10 @@ module.exports = Lank = class Lank extends CocoClass addMark: (name, layer, thangType=null) -> @marks[name] ?= new Mark name: name, lank: @, camera: @options.camera, layer: layer ? @options.groundLayer, thangType: thangType @marks[name] + + removeMark: (name) -> + @marks[name].destroy() + delete @marks[name] notifySpeechUpdated: (e) -> e = _.clone(e) diff --git a/app/lib/surface/LankBoss.coffee b/app/lib/surface/LankBoss.coffee index 5185dc206..51296843f 100644 --- a/app/lib/surface/LankBoss.coffee +++ b/app/lib/surface/LankBoss.coffee @@ -282,11 +282,21 @@ module.exports = class LankBoss extends CocoClass @selectLank e if e.onBackground onChangeSelected: (gameUIState, selected) -> - if selected - @selectThang(selected.thang.id, selected.spellName) - else - @selectedLank?.selected = false - @selectedLank = null + oldLanks = (s.sprite for s in gameUIState.previousAttributes().selected or []) + newLanks = (s.sprite for s in selected or []) + addedLanks = _.difference(newLanks, oldLanks) + removedLanks = _.difference(oldLanks, newLanks) + + for lank in addedLanks + layer = if lank.sprite.parent isnt @layerAdapters.Default.container then @layerAdapters.Default else @layerAdapters.Ground + mark = new Mark name: 'selection', camera: @camera, layer: layer, thangType: 'selection' + mark.toggle true + mark.setLank(lank) + mark.update() + lank.marks.selection = mark # TODO: Figure out how to non-hackily assign lank this mark + + for lank in removedLanks + lank.removeMark?('selection') selectThang: (thangID, spellName=null, treemaThangSelected = null) -> return @willSelectThang = [thangID, spellName] unless @lanks[thangID] diff --git a/app/models/GameUIState.coffee b/app/models/GameUIState.coffee index 666b00f48..6d86e8379 100644 --- a/app/models/GameUIState.coffee +++ b/app/models/GameUIState.coffee @@ -2,4 +2,28 @@ CocoModel = require './CocoModel' module.exports = class GameUIState extends CocoModel @className: 'GameUIState' - + @schema: { + type: 'object' + properties: { + + canDragCamera: { + type: 'boolean' + description: 'Serves as a lock to enable or disable camera movement.' + } + + selected: { + # TODO: Turn this into a collection which can be listened to? With Thang models. + type: 'object' + description: 'Array of selected thangs' + properties: { + sprite: { description: 'Lank instance' } + thang: { description: 'Thang object generated by the world' } + } + } + } + } + + defaults: -> { + selected: [] + canDragCamera: true + } diff --git a/app/views/editor/level/thangs/ThangsTabView.coffee b/app/views/editor/level/thangs/ThangsTabView.coffee index 6d66e16dc..4dd28b836 100644 --- a/app/views/editor/level/thangs/ThangsTabView.coffee +++ b/app/views/editor/level/thangs/ThangsTabView.coffee @@ -81,13 +81,13 @@ module.exports = class ThangsTabView extends CocoView @listenTo(@gameUIState, 'sprite:mouse-down', @onSpriteMouseDown) @listenTo(@gameUIState, 'surface:stage-mouse-move', @onStageMouseMove) @listenTo(@gameUIState, 'change:selected', @onChangeSelected) - @willRepositionCamera =true # should load depended-on Components, too @thangTypes = @supermodel.loadCollection(new ThangTypeSearchCollection(), 'thangs').model # just loading all Components for now: https://github.com/codecombat/codecombat/issues/405 @componentCollection = @supermodel.loadCollection(new ComponentsCollection(), 'components').load() @level = options.level + @onThangsChanged = _.debounce(@onThangsChanged) $(document).bind 'contextmenu', @preventDefaultContextMenu @@ -247,8 +247,9 @@ module.exports = class ThangsTabView extends CocoView @surface?.lankBoss?.selectLank null, null onStageMouseDown: (e) -> - @dragged = false - @willRepositionCamera = true + # initial values for a mouse click lifecycle + @dragged = 0 + @willUnselectSprite = false @gameUIState.set('canDragCamera', true) if @addThangLank?.thangType.get('kind') is 'Wall' @@ -258,48 +259,66 @@ module.exports = class ThangsTabView extends CocoView else if @addThangLank # We clicked on the background when we had an add Thang selected, so add it @addThang @addThangType, @addThangLank.thang.pos - @willRepositionCamera = false else if e.onBackground - @gameUIState.set('selected', null) + @gameUIState.set('selected', []) onStageMouseMove: (e) -> - @willRepositionCamera = false + @dragged += 1 onStageMouseUp: (e) -> - if @willRepositionCamera - worldPos = @surface.camera.screenToWorld {x: e.originalEvent.rawX, y: e.originalEvent.rawY} - @surface.camera.zoomTo(@surface.camera.worldToSurface(worldPos), @surface.camera.zoom, 1000) - @paintingWalls = false $('#contextmenu').hide() onSpriteMouseDown: (e) -> + nativeEvent = e.originalEvent.nativeEvent # update selection - # TODO: Handle key.shift, lankBoss.dragged property - selected = null - if e.thang?.isSelectable - selected = { thang: e.thang, sprite: e.sprite, spellName: e.spellName } - if selected and (key.alt or key.meta) + selected = [] + if nativeEvent.metaKey or nativeEvent.ctrlKey + selected = _.clone(@gameUIState.get('selected')) + if e.thang?.isSelectable + alreadySelected = _.find(selected, (s) -> s.thang is e.thang) + if alreadySelected + # move to end (make it the last selected) and maybe unselect it + @willUnselectSprite = true + selected = _.without(selected, alreadySelected) + selected.push({ thang: e.thang, sprite: e.sprite, spellName: e.spellName }) + if _.any(selected) and key.alt # Clone selected thang instead of selecting it - @willRepositionCamera = false - @selectAddThangType selected.thang.spriteName, selected.thang - selected = null + lastSelected = _.last(selected) + @selectAddThangType lastSelected.thang.spriteName, lastSelected.thang + selected = [] @gameUIState.set('selected', selected) - if selected - @willRepositionCamera = false + if _.any(selected) @gameUIState.set('canDragCamera', false) onSpriteDragged: (e) -> selected = @gameUIState.get('selected') - return unless selected and e.thang?.id is selected.thang.id - @dragged = true - @willRepositionCamera = false + return unless _.any(selected) and @dragged > 10 + @willUnselectSprite = false {stageX, stageY} = e.originalEvent + + # move the one under the mouse + lastSelected = _.last(selected) cap = @surface.camera.screenToCanvas x: stageX, y: stageY wop = @surface.camera.canvasToWorld cap - wop.z = selected.thang.depth / 2 - @adjustThangPos selected.sprite, selected.thang, wop + wop.z = lastSelected.thang.depth / 2 + posBefore = _.clone(lastSelected.thang.pos) + @adjustThangPos lastSelected.sprite, lastSelected.thang, wop + posAfter = lastSelected.thang.pos + + # move any others selected, proportionally to how the 'main' sprite moved + xDiff = posAfter.x - posBefore.x + yDiff = posAfter.y - posBefore.y + if xDiff or yDiff + for singleSelected in selected.slice(0, selected.length - 1) + newPos = { + x: singleSelected.thang.pos.x + xDiff + y: singleSelected.thang.pos.y + yDiff + } + @adjustThangPos singleSelected.sprite, singleSelected.thang, newPos + + # move the camera if we're on the edge of the screen [w, h] = [@surface.camera.canvasWidth, @surface.camera.canvasHeight] sidebarWidths = ((if @$el.find(id).hasClass('hide') then 0 else (@$el.find(id).outerWidth() / @surface.camera.canvasScaleFactorX)) for id in ['#all-thangs', '#add-thangs-view']) w -= sidebarWidth for sidebarWidth in sidebarWidths @@ -308,21 +327,28 @@ module.exports = class ThangsTabView extends CocoView onSpriteMouseUp: (e) -> selected = @gameUIState.get('selected') - if e.originalEvent.nativeEvent.button == 2 and selected + if e.originalEvent.nativeEvent.button == 2 and _.any(selected) @onSpriteContextMenu e clearInterval(@movementInterval) if @movementInterval? @movementInterval = null - return unless selected and e.thang?.id is selected.thang.id - pos = selected.thang.pos - - thang = _.find(@level.get('thangs') ? [], {id: selected.thang.id}) - path = "#{@pathForThang(thang)}/components/original=#{LevelComponent.PhysicalID}" - physical = @thangsTreema.get path - return if not physical or (physical.config.pos.x is pos.x and physical.config.pos.y is pos.y) - @thangsTreema.set path + '/config/pos', x: pos.x, y: pos.y, z: pos.z + return unless _.any(selected) + + for singleSelected in selected + pos = singleSelected.thang.pos + + thang = _.find(@level.get('thangs') ? [], {id: singleSelected.thang.id}) + path = "#{@pathForThang(thang)}/components/original=#{LevelComponent.PhysicalID}" + physical = @thangsTreema.get path + continue if not physical or (physical.config.pos.x is pos.x and physical.config.pos.y is pos.y) + @thangsTreema.set path + '/config/pos', x: pos.x, y: pos.y, z: pos.z + + if @willUnselectSprite + clickedSprite = _.find(selected, {sprite: e.sprite}) + @gameUIState.set('selected', _.without(selected, clickedSprite)) onSpriteDoubleClicked: (e) -> + return if @dragged > 10 return unless e.thang @editThang thangID: e.thang.id @@ -443,7 +469,6 @@ module.exports = class ThangsTabView extends CocoView @surface.lankBoss.update true # Make sure Obstacle layer resets cache onSurfaceMouseMoved: (e) -> - @dragged = true return unless @addThangLank wop = @surface.camera.screenToWorld x: e.x, y: e.y wop.z = 0.5 @@ -497,11 +522,13 @@ module.exports = class ThangsTabView extends CocoView deleteSelectedExtantThang: (e) => return if $(e.target).hasClass 'treema-node' selected = @gameUIState.get('selected') - return unless selected - thang = @getThangByID(selected.thang.id) - @thangsTreema.delete(@pathForThang(thang)) - @deleteEmptyTreema(thang) - Thang.resetThangIDs() # TODO: find some way to do this when we delete from treema, too + return unless _.any(selected) + + for singleSelected in selected + thang = @getThangByID(singleSelected.thang.id) + @thangsTreema.delete(@pathForThang(thang)) + @deleteEmptyTreema(thang) + Thang.resetThangIDs() # TODO: find some way to do this when we delete from treema, too deleteEmptyTreema: (thang)-> thangType = @supermodel.getModelByOriginal ThangType, thang.thangType @@ -580,21 +607,25 @@ module.exports = class ThangsTabView extends CocoView # update selection, since the thangs have been remade selected = @gameUIState.get('selected') - if selected - sprite = @surface.lankBoss.lanks[selected.thang.id] - if sprite - thang = sprite.thang - @gameUIState.set('selected', _.extend({}, selected, { sprite, thang })) - else - @gameUIState.set('selected', null) + if _.any(selected) + for singleSelected in selected + sprite = @surface.lankBoss.lanks[singleSelected.thang.id] + if sprite + sprite.updateMarks() + singleSelected.sprite = sprite + singleSelected.thang = sprite.thang Backbone.Mediator.publish 'editor:thangs-edited', thangs: @world.thangs onTreemaThangSelected: (e, selectedTreemas) => - selectedThangID = _.last(selectedTreemas)?.data.id - if selectedThangID isnt @gameUIState.get('selected')?.thang.id - @surface.lankBoss.selectThang selectedThangID, null, true + selectedThangTreemas = _.filter(selectedTreemas, (t) -> t instanceof ThangNode) + thangIDs = (node.data.id for node in selectedThangTreemas) + lanks = (@surface.lankBoss.lanks[thangID] for thangID in thangIDs when thangID) + selected = ({ thang: lank.thang, sprite: lank } for lank in lanks when lank) + @gameUIState.set('selected', selected) onTreemaThangDoubleClicked: (e, treema) => + nativeEvent = e.originalEvent.nativeEvent + return if nativeEvent and (nativeEvent.ctrlKey or nativeEvent.metaKey) id = treema?.data?.id @editThang thangID: id if id @@ -675,7 +706,7 @@ module.exports = class ThangsTabView extends CocoView onDuplicateClicked: (e) -> $('#contextmenu').hide() - selected = @gameUIState.get('selected') + selected = _.last(@gameUIState.get('selected')) @selectAddThangType(selected.thang.spriteName, selected.thang) onClickRotationButton: (e) -> @@ -704,39 +735,44 @@ module.exports = class ThangsTabView extends CocoView lank.setDebug true rotateSelectedThangTo: (radians) -> - selectedThang = @gameUIState.get('selected')?.thang - @modifySelectedThangComponentConfig selectedThang, LevelComponent.PhysicalID, (component) => - component.config.rotation = radians - selectedThang.rotation = component.config.rotation + for singleSelected in @gameUIState.get('selected') + selectedThang = singleSelected.thang + @modifySelectedThangComponentConfig selectedThang, LevelComponent.PhysicalID, (component) => + component.config.rotation = radians + selectedThang.rotation = component.config.rotation rotateSelectedThangBy: (radians) -> - selectedThang = @gameUIState.get('selected')?.thang - @modifySelectedThangComponentConfig selectedThang, LevelComponent.PhysicalID, (component) => - component.config.rotation = ((component.config.rotation ? 0) + radians) % (2 * Math.PI) - selectedThang.rotation = component.config.rotation + for singleSelected in @gameUIState.get('selected') + selectedThang = singleSelected.thang + @modifySelectedThangComponentConfig selectedThang, LevelComponent.PhysicalID, (component) => + component.config.rotation = ((component.config.rotation ? 0) + radians) % (2 * Math.PI) + selectedThang.rotation = component.config.rotation moveSelectedThangBy: (xDir, yDir) -> - selectedThang = @gameUIState.get('selected')?.thang - @modifySelectedThangComponentConfig selectedThang, LevelComponent.PhysicalID, (component) => - component.config.pos.x += 0.5 * xDir - component.config.pos.y += 0.5 * yDir - selectedThang.pos.x = component.config.pos.x - selectedThang.pos.y = component.config.pos.y + for singleSelected in @gameUIState.get('selected') + selectedThang = singleSelected.thang + @modifySelectedThangComponentConfig selectedThang, LevelComponent.PhysicalID, (component) => + component.config.pos.x += 0.5 * xDir + component.config.pos.y += 0.5 * yDir + selectedThang.pos.x = component.config.pos.x + selectedThang.pos.y = component.config.pos.y resizeSelectedThangBy: (xDir, yDir) -> - selectedThang = @gameUIState.get('selected')?.thang - @modifySelectedThangComponentConfig selectedThang, LevelComponent.PhysicalID, (component) => - component.config.width = (component.config.width ? 4) + 0.5 * xDir - component.config.height = (component.config.height ? 4) + 0.5 * yDir - selectedThang.width = component.config.width - selectedThang.height = component.config.height + for singleSelected in @gameUIState.get('selected') + selectedThang = singleSelected.thang + @modifySelectedThangComponentConfig selectedThang, LevelComponent.PhysicalID, (component) => + component.config.width = (component.config.width ? 4) + 0.5 * xDir + component.config.height = (component.config.height ? 4) + 0.5 * yDir + selectedThang.width = component.config.width + selectedThang.height = component.config.height toggleSelectedThangCollision: -> - selectedThang = @gameUIState.get('selected')?.thang - @modifySelectedThangComponentConfig selectedThang, LevelComponent.CollidesID, (component) => - component.config ?= {} - component.config.collisionCategory = if component.config.collisionCategory is 'none' then 'ground' else 'none' - selectedThang.collisionCategory = component.config.collisionCategory + for singleSelected in @gameUIState.get('selected') + selectedThang = singleSelected.thang + @modifySelectedThangComponentConfig selectedThang, LevelComponent.CollidesID, (component) => + component.config ?= {} + component.config.collisionCategory = if component.config.collisionCategory is 'none' then 'ground' else 'none' + selectedThang.collisionCategory = component.config.collisionCategory toggleThangsContainer: (e) -> $('#all-thangs').toggleClass('hide') diff --git a/test/app/lib/surface/camera.spec.coffee b/test/app/lib/surface/Camera.spec.coffee similarity index 92% rename from test/app/lib/surface/camera.spec.coffee rename to test/app/lib/surface/Camera.spec.coffee index 9383ce89a..2ece688ac 100644 --- a/test/app/lib/surface/camera.spec.coffee +++ b/test/app/lib/surface/Camera.spec.coffee @@ -96,24 +96,6 @@ describe 'Camera (Surface point of view)', -> checkConversionsFromWorldPos wop, cam checkCameraPos cam, wop - it 'works at 90 degrees', -> - cam = new Camera {attr: (attr) -> 100}, Math.PI / 2 - expect(cam.x2y).toBeCloseTo 1 - expect(cam.x2z).toBeGreaterThan 9001 - expect(cam.z2y).toBeCloseTo 0 - - it 'works at 0 degrees', -> - cam = new Camera {attr: (attr) -> 100}, 0 - expect(cam.x2y).toBeGreaterThan 9001 - expect(cam.x2z).toBeCloseTo 1 - expect(cam.z2y).toBeGreaterThan 9001 - - it 'works at 45 degrees', -> - cam = new Camera {attr: (attr) -> 100}, Math.PI / 4 - expect(cam.x2y).toBeCloseTo Math.sqrt(2) - expect(cam.x2z).toBeCloseTo Math.sqrt(2) - expect(cam.z2y).toBeCloseTo 1 - it 'works at default angle of asin(0.75) ~= 48.9 degrees', -> cam = new Camera {attr: (attr) -> 100}, null angle = Math.asin(3 / 4) diff --git a/test/app/lib/surface/LankBoss.spec.coffee b/test/app/lib/surface/LankBoss.spec.coffee index 10629c5d8..fb10d79bf 100644 --- a/test/app/lib/surface/LankBoss.spec.coffee +++ b/test/app/lib/surface/LankBoss.spec.coffee @@ -2,6 +2,7 @@ LankBoss = require 'lib/surface/LankBoss' Camera = require 'lib/surface/Camera' World = require 'lib/world/world' ThangType = require 'models/ThangType' +GameUIState = require 'models/GameUIState' treeData = require 'test/app/fixtures/tree1.thang.type' munchkinData = require 'test/app/fixtures/ogre-munchkin-m.thang.type' @@ -53,6 +54,7 @@ describe 'LankBoss', -> surfaceTextLayer: new createjs.Container() world: world thangTypes: thangTypes + gameUIState: new GameUIState() } window.lankBoss = lankBoss = new LankBoss(options)