RootView = require 'views/core/RootView' template = require 'templates/play/campaign-view' LevelSession = require 'models/LevelSession' EarnedAchievement = require 'models/EarnedAchievement' CocoCollection = require 'collections/CocoCollection' Campaign = require 'models/Campaign' AudioPlayer = require 'lib/AudioPlayer' LevelSetupManager = require 'lib/LevelSetupManager' ThangType = require 'models/ThangType' MusicPlayer = require 'lib/surface/MusicPlayer' storage = require 'core/storage' AuthModal = require 'views/core/AuthModal' SubscribeModal = require 'views/core/SubscribeModal' Level = require 'models/Level' utils = require 'core/utils' trackedHourOfCode = false class LevelSessionsCollection extends CocoCollection url: '' model: LevelSession constructor: (model) -> super() @url = "/db/user/#{me.id}/level.sessions?project=state.complete,levelID" module.exports = class WorldMapView extends RootView id: 'campaign-view' template: template subscriptions: 'subscribe-modal:subscribed': 'onSubscribed' events: 'click .map-background': 'onClickMap' 'click .level a': 'onClickLevel' 'click .level-info-container .start-level': 'onClickStartLevel' 'mouseenter .level a': 'onMouseEnterLevel' 'mouseleave .level a': 'onMouseLeaveLevel' 'mousemove .map': 'onMouseMoveMap' 'click #volume-button': 'onToggleVolume' constructor: (options, @terrain='dungeon') -> if options and application.isIPAdApp # TODO: later only clear the SuperModel if it has received a memory warning (not in app store yet) options.supermodel = null super options options ?= {} @campaign = new Campaign({_id:@terrain}) @campaign = @supermodel.loadModel(@campaign, 'campaign').model @editorMode = options.editorMode @nextLevel = @getQueryVariable 'next' @levelStatusMap = {} @levelPlayCountMap = {} @sessions = @supermodel.loadCollection(new LevelSessionsCollection(), 'your_sessions', null, 0).model # Temporary attempt to make sure all earned rewards are accounted for. Figure out a better solution... @earnedAchievements = new CocoCollection([], {url: '/db/earned_achievement', model:EarnedAchievement, project: ['earnedRewards']}) @listenToOnce @earnedAchievements, 'sync', -> earned = me.get('earned') for m in @earnedAchievements.models continue unless loadedEarned = m.get('earnedRewards') for group in ['heroes', 'levels', 'items'] continue unless loadedEarned[group] for reward in loadedEarned[group] if reward not in earned[group] console.warn 'Filling in a gap for reward', group, reward earned[group].push(reward) @supermodel.loadCollection(@earnedAchievements, 'achievements') @listenToOnce @sessions, 'sync', @onSessionsLoaded @getLevelPlayCounts() $(window).on 'resize', @onWindowResize @playAmbientSound() @probablyCachedMusic = storage.load("loaded-menu-music") musicDelay = if @probablyCachedMusic then 1000 else 10000 @playMusicTimeout = _.delay (=> @playMusic() unless @destroyed), musicDelay @hadEverChosenHero = me.get('heroConfig')?.thangType @listenTo me, 'change:purchased', -> @renderSelectors('#gems-count') @listenTo me, 'change:spent', -> @renderSelectors('#gems-count') @listenTo me, 'change:heroConfig', -> @updateHero() window.tracker?.trackEvent 'Loaded World Map', category: 'World Map', ['Google Analytics'] # If it's a new player who didn't appear to come from Hour of Code, we register her here without setting the hourOfCode property. elapsed = (new Date() - new Date(me.get('dateCreated'))) if not trackedHourOfCode and not me.get('hourOfCode') and elapsed < 5 * 60 * 1000 $('body').append($('')) trackedHourOfCode = true @requiresSubscription = not me.isPremium() destroy: -> @setupManager?.destroy() @$el.find('.ui-draggable').draggable 'destroy' $(window).off 'resize', @onWindowResize if ambientSound = @ambientSound # Doesn't seem to work; stops immediately. createjs.Tween.get(ambientSound).to({volume: 0.0}, 1500).call -> ambientSound.stop() @musicPlayer?.destroy() clearTimeout @playMusicTimeout super() getLevelPlayCounts: -> return # TODO: Either use the campaign object instead of hardcoded data or get the data some other way return unless me.isAdmin() success = (levelPlayCounts) => return if @destroyed for level in levelPlayCounts @levelPlayCountMap[level._id] = playtime: level.playtime, sessions: level.sessions @render() if @fullyRendered levelIDs = [] for campaign in campaigns for level in campaign.levels levelIDs.push level.id levelPlayCountsRequest = @supermodel.addRequestResource 'play_counts', { url: '/db/level/-/play_counts' data: {ids: levelIDs} method: 'POST' success: success }, 0 levelPlayCountsRequest.load() onLoaded: -> return if @fullyRendered @fullyRendered = true @render() @preloadTopHeroes() unless me.get('heroConfig')?.thangType onSubscribed: -> @requiresSubscription = false @render() getRenderData: (context={}) -> context = super(context) context.campaign = @campaign context.levels = _.values($.extend true, {}, @campaign.get('levels')) for level in context.levels level.position ?= { x: 10, y: 10 } level.locked = not me.ownsLevel level.original window.levelUnlocksNotWorking = true if level.locked and level.id is @nextLevel # Temporary level.locked = false if window.levelUnlocksNotWorking # Temporary; also possible in HeroVictoryModal level.locked = false if @levelStatusMap[level.id] in ['started', 'complete'] level.locked = false if me.get('slug') is 'nick' level.locked = false if @editorMode level.disabled = false if @levelStatusMap[level.id] in ['started', 'complete'] level.color = 'rgb(255, 80, 60)' if level.requiresSubscription level.color = 'rgb(80, 130, 200)' if level.unlocksHero level.unlockedHero = level.unlocksHero.originalID in (me.get('earned')?.heroes or []) level.hidden = level.locked or level.disabled # put lower levels in last, so in the world map they layer over one another properly. context.campaign.levels = (_.sortBy context.campaign.levels, (l) -> l.position.y).reverse() context.levelStatusMap = @levelStatusMap context.levelPlayCountMap = @levelPlayCountMap context.isIPadApp = application.isIPadApp context.mapType = _.string.slugify @terrain context.nextLevel = @nextLevel context.forestIsAvailable = Level.levels['defense-of-plainswood'] in (me.get('earned')?.levels or []) context.desertIsAvailable = Level.levels['the-mighty-sand-yak'] in (me.get('earned')?.levels or []) context.requiresSubscription = @requiresSubscription context.editorMode = @editorMode context.adjacentCampaigns = _.filter _.values(_.cloneDeep(@campaign.get('adjacentCampaigns') or {})), (ac) -> return false if ac.showIfUnlocked and ac.showIfUnlocked not in (me.get('unlocked')?.levels or []) ac.name = utils.i18n ac, 'name' ac.description = utils.i18n ac, 'description' styles = [] styles.push "color: #{ac.color}" if ac.color styles.push "transform: rotate(#{ac.rotation}deg)" if ac.rotation ac.position ?= { x: 10, y: 10 } styles.push "left: #{ac.position.x}%" styles.push "top: #{ac.position.y}%" ac.style = styles.join('; ') return true context afterRender: -> super() @onWindowResize() unless application.isIPadApp _.defer => @$el?.find('.game-controls .btn').tooltip() # Have to defer or i18n doesn't take effect. view = @ @$el.find('.level, .campaign-switch').tooltip().each -> return unless me.isAdmin() $(@).draggable().on 'dragstop', -> bg = $('.map-background') x = ($(@).offset().left - bg.offset().left + $(@).outerWidth() / 2) / bg.width() y = 1 - ($(@).offset().top - bg.offset().top + $(@).outerHeight() / 2) / bg.height() e = { position: { x: (100 * x), y: (100 * y) }, levelOriginal: $(@).data('level-id'), campaignID: $(@).data('campaign-id') } view.trigger 'level-moved', e if e.levelOriginal view.trigger 'adjacent-campaign-moved', e if e.campaignID @$el.addClass _.string.slugify @terrain @updateVolume() @updateHero() unless window.currentModal or not @fullyRendered @highlightElement '.level.next', delay: 500, duration: 60000, rotation: 0, sides: ['top'] if levelID = @$el.find('.level.next').data('level-id') @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() unless @editorMode pos = @$el.find('.level.next').offset() @adjustLevelInfoPosition pageX: pos.left, pageY: pos.top @manuallyPositionedLevelInfoID = levelID afterInsert: -> super() return unless @getQueryVariable 'signup' return if me.get('email') @endHighlight() authModal = new AuthModal supermodel: @supermodel authModal.mode = 'signup' @openModalView authModal onSessionsLoaded: (e) -> return if @editorMode for session in @sessions.models @levelStatusMap[session.get('levelID')] = if session.get('state')?.complete then 'complete' else 'started' if @nextLevel and @levelStatusMap[@nextLevel] is 'complete' @nextLevel = null @render() onClickMap: (e) -> @$levelInfo?.hide() # Easy-ish way of figuring out coordinates for placing level dots. x = e.offsetX / @$el.find('.map-background').width() y = (1 - e.offsetY / @$el.find('.map-background').height()) console.log " x: #{(100 * x).toFixed(2)}\n y: #{(100 * y).toFixed(2)}\n" onClickLevel: (e) -> e.preventDefault() e.stopPropagation() @$levelInfo?.hide() levelElement = $(e.target).parents('.level') levelID = levelElement.data('level-id') level = _.find _.values(campaign.get('levels')), id: levelID if application.isIPadApp @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() @adjustLevelInfoPosition e @endHighlight() else if level.requiresSubscription and @requiresSubscription and not @levelStatusMap[level.id] and not level.adventurer @openModalView new SubscribeModal() window.tracker?.trackEvent 'Show subscription modal', category: 'Subscription', label: 'map level clicked', level: levelID else if $(e.target).attr('disabled') Backbone.Mediator.publish 'router:navigate', route: '/contribute/adventurer' return else if $(e.target).parent().hasClass 'locked' return else @startLevel levelElement window.tracker?.trackEvent 'Clicked Level', category: 'World Map', levelID: levelID, ['Google Analytics'] onClickStartLevel: (e) -> levelElement = $(e.target).parents('.level-info-container') @startLevel levelElement window.tracker?.trackEvent 'Clicked Start Level', category: 'World Map', levelID: levelElement.data('level-id'), ['Google Analytics'] startLevel: (levelElement) -> @setupManager?.destroy() @setupManager = new LevelSetupManager supermodel: @supermodel, levelID: levelElement.data('level-id'), levelPath: levelElement.data('level-path'), levelName: levelElement.data('level-name'), hadEverChosenHero: @hadEverChosenHero, parent: @ @setupManager.open() @$levelInfo?.hide() onMouseEnterLevel: (e) -> return if application.isIPadApp return if @editorMode levelID = $(e.target).parents('.level').data('level-id') return if @manuallyPositionedLevelInfoID and levelID isnt @manuallyPositionedLevelInfoID @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() @adjustLevelInfoPosition e @endHighlight() @manuallyPositionedLevelInfoID = false onMouseLeaveLevel: (e) -> return if application.isIPadApp levelID = $(e.target).parents('.level').data('level-id') return if @manuallyPositionedLevelInfoID and levelID isnt @manuallyPositionedLevelInfoID @$el.find(".level-info-container[data-level-id='#{levelID}']").hide() @manuallyPositionedLevelInfoID = null @$levelInfo = null onMouseMoveMap: (e) -> return if application.isIPadApp @adjustLevelInfoPosition e unless @manuallyPositionedLevelInfoID adjustLevelInfoPosition: (e) -> return unless @$levelInfo @$map ?= @$el.find('.map') mapOffset = @$map.offset() mapX = e.pageX - mapOffset.left mapY = e.pageY - mapOffset.top margin = 20 width = @$levelInfo.outerWidth() @$levelInfo.css('left', Math.min(Math.max(margin, mapX - width / 2), @$map.width() - width - margin)) height = @$levelInfo.outerHeight() top = mapY - @$levelInfo.outerHeight() - 60 if top < 20 top = mapY + 60 @$levelInfo.css('top', top) onWindowResize: (e) => mapHeight = iPadHeight = 1536 mapWidth = {dungeon: 2350, forest: 2500, desert: 2350}[@terrain] or 2350 aspectRatio = mapWidth / mapHeight pageWidth = @$el.width() pageHeight = @$el.height() widthRatio = pageWidth / mapWidth heightRatio = pageHeight / mapHeight # Make sure we can see the whole map, fading to background in one dimension. if heightRatio <= widthRatio # Left and right margin resultingHeight = pageHeight resultingWidth = resultingHeight * aspectRatio else # Top and bottom margin resultingWidth = pageWidth resultingHeight = resultingWidth / aspectRatio resultingMarginX = (pageWidth - resultingWidth) / 2 resultingMarginY = (pageHeight - resultingHeight) / 2 @$el.find('.map').css(width: resultingWidth, height: resultingHeight, 'margin-left': resultingMarginX, 'margin-top': resultingMarginY) playAmbientSound: -> return if @ambientSound return unless file = {dungeon: 'ambient-dungeon', forest: 'ambient-map-grass', desert: 'ambient-desert'}[@terrain] src = "/file/interface/#{file}#{AudioPlayer.ext}" unless AudioPlayer.getStatus(src)?.loaded AudioPlayer.preloadSound src Backbone.Mediator.subscribeOnce 'audio-player:loaded', @playAmbientSound, @ return @ambientSound = createjs.Sound.play src, loop: -1, volume: 0.1 createjs.Tween.get(@ambientSound).to({volume: 0.5}, 1000) playMusic: -> @musicPlayer = new MusicPlayer() musicFile = '/music/music-menu' Backbone.Mediator.publish 'music-player:play-music', play: true, file: musicFile storage.save("loaded-menu-music", true) unless @probablyCachedMusic preloadTopHeroes: -> for heroID in ['captain', 'knight'] url = "/db/thang.type/#{ThangType.heroes[heroID]}/version" continue if @supermodel.getModel url fullHero = new ThangType() fullHero.setURL url @supermodel.loadModel fullHero, 'thang' updateVolume: (volume) -> volume ?= me.get('volume') ? 1.0 classes = ['vol-off', 'vol-down', 'vol-up'] button = $('#volume-button', @$el) button.toggleClass 'vol-off', volume <= 0.0 button.toggleClass 'vol-down', 0.0 < volume < 1.0 button.toggleClass 'vol-up', volume >= 1.0 createjs.Sound.setVolume(if volume is 1 then 0.6 else volume) # Quieter for now until individual sound FX controls work again. if volume isnt me.get 'volume' me.set 'volume', volume me.patch() onToggleVolume: (e) -> button = $(e.target).closest('#volume-button') classes = ['vol-off', 'vol-down', 'vol-up'] volumes = [0, 0.4, 1.0] for oldClass, i in classes if button.hasClass oldClass newI = (i + 1) % classes.length break else if i is classes.length - 1 # no oldClass newI = 2 @updateVolume volumes[newI] updateHero: -> return unless hero = me.get('heroConfig')?.thangType for slug, original of ThangType.heroes when original is hero @$el.find('.player-hero-icon').removeClass().addClass('player-hero-icon ' + slug) return console.error "WorldMapView hero update couldn't find hero slug for original:", hero