codecombat/app/views/play/menu/InventoryModal.coffee

604 lines
23 KiB
CoffeeScript

ModalView = require 'views/core/ModalView'
template = require 'templates/play/menu/inventory-modal'
buyGemsPromptTemplate = require 'templates/play/modal/buy-gems-prompt'
{me} = require 'core/auth'
ThangType = require 'models/ThangType'
CocoCollection = require 'collections/CocoCollection'
ItemView = require './ItemView'
SpriteBuilder = require 'lib/sprites/SpriteBuilder'
ItemDetailsView = require 'views/play/modal/ItemDetailsView'
Purchase = require 'models/Purchase'
LevelOptions = require 'lib/LevelOptions'
BuyGemsModal = require 'views/play/modal/BuyGemsModal'
hasGoneFullScreenOnce = false
module.exports = class InventoryModal extends ModalView
id: 'inventory-modal'
className: 'modal fade play-modal'
template: template
slots: ['head', 'eyes', 'neck', 'torso', 'wrists', 'gloves', 'left-ring', 'right-ring', 'right-hand', 'left-hand', 'waist', 'feet', 'programming-book', 'pet', 'minion', 'flag'] #, 'misc-0', 'misc-1'] # TODO: bring in misc slot(s) again when we have space
closesOnClickOutside: false # because draggable somehow triggers hide when you don't drag onto a draggable
events:
'click .item-slot': 'onItemSlotClick'
'click #unequipped .item': 'onUnequippedItemClick'
'doubletap #unequipped .item': 'onUnequippedItemDoubleClick'
'doubletap .item-slot .item': 'onEquippedItemDoubleClick'
'click button.equip-item': 'onClickEquipItemButton'
'shown.bs.modal': 'onShown'
'click #choose-hero-button': 'onClickChooseHero'
'click #play-level-button': 'onClickPlayLevel'
'click .unlock-button': 'onUnlockButtonClicked'
'click #equip-item-viewed': 'onClickEquipItemViewed'
'click #unequip-item-viewed': 'onClickUnequipItemViewed'
'click #close-modal': 'hide'
'click .buy-gems-prompt-button': 'onBuyGemsPromptButtonClicked'
'click': 'onClickedSomewhere'
shortcuts:
'esc': 'clearSelection'
'enter': 'onClickPlayLevel'
#- Setup
initialize: (options) ->
super(arguments...)
@items = new CocoCollection([], {model: ThangType})
# TODO: switch to item store loading system?
@items.url = '/db/thang.type?view=items'
@items.setProjection [
'name'
'slug'
'components'
'original'
'rasterIcon'
'dollImages'
'gems'
'tier'
'description'
'heroClass'
'i18n'
]
@supermodel.loadCollection(@items, 'items')
@equipment = {} # Assign for real when we have loaded the session and items.
onItemsLoaded: ->
item.notInLevel = true for item in @items.models
@equipment = @options.equipment or @options.session?.get('heroConfig')?.inventory or me.get('heroConfig')?.inventory or {}
@equipment = $.extend true, {}, @equipment
@requireLevelEquipment()
@itemGroups = {}
@itemGroups.availableItems = new Backbone.Collection()
@itemGroups.restrictedItems = new Backbone.Collection()
@itemGroups.lockedItems = new Backbone.Collection()
itemGroup.comparator = ((m) -> m.get('tier') ? m.get('gems')) for itemGroup in _.values @itemGroups
equipped = _.values(@equipment)
@sortItem(item, equipped) for item in @items.models
sortItem: (item, equipped) ->
equipped ?= _.values(@equipment)
# general starting classes
item.classes = _.clone(item.getAllowedSlots())
for heroClass in item.getAllowedHeroClasses()
item.classes.push heroClass
item.classes.push 'equipped' if item.get('original') in equipped
# sort into one of the four groups
locked = not (item.get('original') in me.items())
#locked = false if me.get('slug') is 'nick'
if not item.getFrontFacingStats().props.length and not _.size(item.getFrontFacingStats().stats) and locked # Temp: while there are placeholder items
null # Don't put into a collection
else if locked and item.get('slug') isnt 'simple-boots'
item.classes.push 'locked'
if item.isSilhouettedItem() or not item.get('gems')
# Don't even load/show these--don't add to a collection. (Bandwidth optimization.)
null
else
@itemGroups.lockedItems.add(item)
else if item.get('slug') in _.values(LevelOptions[@options.levelID]?.restrictedGear ? {})
@itemGroups.restrictedItems.add(item)
item.classes.push 'restricted'
else
@itemGroups.availableItems.add(item)
# level to unlock
item.level = item.levelRequiredForItem() if item.get('tier')?
onLoaded: ->
# Both items and session have been loaded.
@onItemsLoaded()
super()
getRenderData: (context={}) ->
context = super(context)
context.equipped = _.values(@equipment)
context.items = @items.models
context.itemGroups = @itemGroups
context.slots = @slots
context.selectedHero = @selectedHero
context.equipment = _.clone @equipment
context.equipment[slot] = @items.findWhere {original: itemOriginal} for slot, itemOriginal of context.equipment
context.gems = me.gems()
context
afterRender: ->
super()
@$el.find('#play-level-button').css('visibility', 'hidden')
return unless @supermodel.finished()
@$el.find('#play-level-button').css('visibility', 'visible')
@setUpDraggableEventsForAvailableEquipment()
@setUpDraggableEventsForEquippedArea()
@delegateEvents()
@itemDetailsView = new ItemDetailsView()
@insertSubView(@itemDetailsView)
@requireLevelEquipment()
@$el.find('.nano').nanoScroller({alwaysVisible: true})
@onSelectionChanged()
@onEquipmentChanged()
afterInsert: ->
super()
@canvasWidth = @$el.find('canvas').innerWidth()
@canvasHeight = @$el.find('canvas').innerHeight()
@inserted = true
@requireLevelEquipment()
#- Draggable logic
setUpDraggableEventsForAvailableEquipment: ->
for availableItemEl in @$el.find('#unequipped .item')
availableItemEl = $(availableItemEl)
continue if availableItemEl.hasClass('locked') or availableItemEl.hasClass('restricted')
dragHelper = availableItemEl.clone().addClass('draggable-item')
do (dragHelper, availableItemEl) =>
availableItemEl.draggable
revert: 'invalid'
appendTo: @$el
cursorAt: {left: 35.5, top: 35.5}
helper: -> dragHelper
revertDuration: 200
distance: 10
scroll: false
zIndex: 1100
availableItemEl.on 'dragstart', => @selectUnequippedItem(availableItemEl)
setUpDraggableEventsForEquippedArea: ->
for itemSlot in @$el.find '.item-slot'
slot = $(itemSlot).data 'slot'
do (slot, itemSlot) =>
$(itemSlot).droppable
drop: (e, ui) => @equipSelectedItem()
accept: (el) -> $(el).parent().hasClass slot
activeClass: 'droppable'
hoverClass: 'droppable-hover'
tolerance: 'touch'
@makeEquippedSlotDraggable $(itemSlot)
@$el.find('#equipped').droppable
drop: (e, ui) => @equipSelectedItem()
accept: (el) -> true
activeClass: 'droppable'
hoverClass: 'droppable-hover'
tolerance: 'pointer'
makeEquippedSlotDraggable: (slot) ->
unequip = =>
@unequipItemFromSlot slot
@requireLevelEquipment()
shouldStayEquippedWhenDropped = (isValidDrop) ->
pos = $(@).position()
revert = Math.abs(pos.left) < $(@).outerWidth() and Math.abs(pos.top) < $(@).outerHeight()
unequip() if not revert
revert
# TODO: figure out how to make this actually above the available items list (the .ui-draggable-helper img is still inside .item-view and so underlaps...)
$(slot).find('img').draggable
revert: shouldStayEquippedWhenDropped
appendTo: @$el
cursorAt: {left: 35.5, top: 35.5}
revertDuration: 200
distance: 10
scroll: false
zIndex: 100
slot.on 'dragstart', => @selectItemSlot(slot)
#- Select/equip event handlers
onItemSlotClick: (e) ->
return if @remainingRequiredEquipment?.length # Don't let them select a slot if we need them to first equip some require gear.
@playSound 'menu-button-click'
@selectItemSlot($(e.target).closest('.item-slot'))
onUnequippedItemClick: (e) ->
return if @justDoubleClicked
itemEl = $(e.target).closest('.item')
@playSound 'menu-button-click'
@selectUnequippedItem(itemEl)
onUnequippedItemDoubleClick: (e) ->
itemEl = $(e.target).closest('.item')
return if itemEl.hasClass('locked') or itemEl.hasClass('restricted')
@equipSelectedItem()
@justDoubleClicked = true
_.defer => @justDoubleClicked = false
onEquippedItemDoubleClick: -> @unequipSelectedItem()
onClickEquipItemViewed: -> @equipSelectedItem()
onClickUnequipItemViewed: -> @unequipSelectedItem()
onClickEquipItemButton: (e) ->
@playSound 'menu-button-click'
itemEl = $(e.target).closest('.item')
@selectUnequippedItem(itemEl)
@equipSelectedItem()
#- Select/equip higher-level, all encompassing methods the callbacks all use
selectItemSlot: (slotEl) ->
@clearSelection()
slotEl.addClass('selected')
selectedSlotItemID = slotEl.find('.item').data('item-id')
item = @items.get(selectedSlotItemID)
if item then @showItemDetails(item, 'unequip')
@onSelectionChanged()
selectUnequippedItem: (itemEl) ->
@clearSelection()
itemEl.addClass('active')
showExtra = if itemEl.hasClass('restricted') then 'restricted' else if not itemEl.hasClass('locked') then 'equip' else ''
@showItemDetails(@items.get(itemEl.data('item-id')), showExtra)
@onSelectionChanged()
equipSelectedItem: ->
selectedItemEl = @getSelectedUnequippedItem()
selectedItem = @items.get(selectedItemEl.data('item-id'))
return unless selectedItem
allowedSlots = selectedItem.getAllowedSlots()
firstSlot = unequippedSlot = null
for allowedSlot in allowedSlots
slotEl = @$el.find(".item-slot[data-slot='#{allowedSlot}']")
firstSlot ?= slotEl
unequippedSlot ?= slotEl unless slotEl.find('img').length
slotEl = unequippedSlot ? firstSlot
selectedItemEl.effect('transfer', to: slotEl, duration: 500, easing: 'easeOutCubic')
unequipped = @unequipItemFromSlot(slotEl)
selectedItemEl.addClass('equipped')
slotEl.append(selectedItemEl.find('img').clone().addClass('item').data('item-id', selectedItem.id))
@clearSelection()
@showItemDetails(selectedItem, 'unequip')
slotEl.addClass('selected')
selectedItem.classes.push 'equipped'
@makeEquippedSlotDraggable slotEl
@requireLevelEquipment()
@onSelectionChanged()
@onEquipmentChanged()
unequipSelectedItem: ->
slotEl = @getSelectedSlot()
@clearSelection()
itemEl = @unequipItemFromSlot(slotEl)
return unless itemEl
itemEl.addClass('active')
slotEl.effect('transfer', to: itemEl, duration: 500, easing: 'easeOutCubic')
selectedSlotItemID = itemEl.data('item-id')
item = @items.get(selectedSlotItemID)
item.classes = _.without item.classes, 'equipped'
@showItemDetails(item, 'equip')
@requireLevelEquipment()
@onSelectionChanged()
@onEquipmentChanged()
#- Select/equip helpers
clearSelection: ->
@deselectAllSlots()
@deselectAllUnequippedItems()
@hideItemDetails()
unequipItemFromSlot: (slotEl) ->
itemEl = slotEl.find('.item')
itemIDToUnequip = itemEl.data('item-id')
return unless itemIDToUnequip
itemEl.remove()
@$el.find("#unequipped .item[data-item-id=#{itemIDToUnequip}]").removeClass('equipped')
deselectAllSlots: ->
@$el.find('#equipped .item-slot.selected').removeClass('selected')
deselectAllUnequippedItems: ->
@$el.find('#unequipped .item').removeClass('active')
getSlot: (name) ->
@$el.find(".item-slot[data-slot=#{name}]")
getSelectedSlot: ->
@$el.find('#equipped .item-slot.selected')
getSelectedUnequippedItem: ->
@$el.find('#unequipped .item.active')
onSelectionChanged: ->
heroClass = @selectedHero?.get('heroClass')
itemsCanBeEquipped = @$el.find('#unequipped .item.available:not(.equipped)').filter('.'+heroClass).length
toShow = @$el.find('#double-click-hint, #available-description')
if itemsCanBeEquipped then toShow.removeClass('secret') else toShow.addClass('secret')
@delegateEvents()
showItemDetails: (item, showExtra) ->
@itemDetailsView.setItem(item)
@$el.find('#item-details-extra > *').addClass('secret')
@$el.find("##{showExtra}-item-viewed").removeClass('secret')
hideItemDetails: ->
@itemDetailsView.setItem(null)
@$el.find('#item-details-extra > *').addClass('secret')
getCurrentEquipmentConfig: ->
config = {}
for slot in @$el.find('.item-slot')
slotName = $(slot).data('slot')
slotItemID = $(slot).find('.item').data('item-id')
continue unless slotItemID
item = _.find @items.models, {id:slotItemID}
config[slotName] = item.get('original')
config
requireLevelEquipment: ->
# This is temporary, until we have a more general way of awarding items and configuring required/restricted items per level.
requiredGear = LevelOptions[@options.levelID]?.requiredGear ? {}
restrictedGear = LevelOptions[@options.levelID]?.restrictedGear ? {}
if @inserted
if @supermodel.finished()
equipment = @getCurrentEquipmentConfig() # Make sure @equipment is updated
else
equipment = @equipment
hadRequired = @remainingRequiredEquipment?.length
@remainingRequiredEquipment = []
@$el.find('.should-equip').removeClass('should-equip')
inWorldMap = $('#world-map-view').length
if @supermodel.finished() and heroClass = @selectedHero?.get('heroClass')
for slot, item of _.clone equipment
itemModel = @items.findWhere original: item
unless itemModel and heroClass in itemModel.classes
console.log 'Unequipping', itemModel.get('heroClass'), 'item', itemModel.get('name'), 'from slot due to class restrictions.'
@unequipItemFromSlot @$el.find(".item-slot[data-slot='#{slot}']")
delete equipment[slot]
for slot, item of restrictedGear
equipped = equipment[slot]
if equipped and equipped is gear[restrictedGear[slot]]
console.log 'Unequipping restricted item', restrictedGear[slot], 'for', slot, 'before level', @options.levelID
@unequipItemFromSlot @$el.find(".item-slot[data-slot='#{slot}']")
delete equipment[slot]
if (heroClass is 'Warrior' or
(heroClass is 'Ranger' and @options.levelID in ['swift-dagger', 'shrapnel']) or
(heroClass is 'Wizard' and @options.levelID in ['touch-of-death', 'bonemender']))
# After they switch to a ranger or wizard, we stop being so finicky about gear.
for slot, item of requiredGear
continue if item is 'tarnished-bronze-breastplate' and inWorldMap and @options.levelID is 'the-raised-sword' # Don't tell them they need it until they need it in the level
equipped = equipment[slot]
continue if equipped and not (
(item is 'crude-builders-hammer' and equipped in [gear['simple-sword'], gear['long-sword'], gear['sharpened-sword'], gear['roughedge']]) or
(item in ['simple-sword', 'long-sword', 'roughedge', 'sharpened-sword'] and equipped is gear['crude-builders-hammer']) or
(item is 'leather-boots' and equipped is gear['simple-boots']) or
(item is 'simple-boots' and equipped is gear['leather-boots'])
)
itemModel = @items.findWhere {slug: item}
continue unless itemModel
availableSlotSelector = "#unequipped .item[data-item-id='#{itemModel.id}']"
@highlightElement availableSlotSelector, delay: 500, sides: ['right'], rotation: Math.PI / 2
@$el.find(availableSlotSelector).addClass 'should-equip'
@$el.find("#equipped div[data-slot='#{slot}']").addClass 'should-equip'
@remainingRequiredEquipment.push slot: slot, item: gear[item]
if hadRequired and not @remainingRequiredEquipment.length
@endHighlight()
@highlightElement '#play-level-button', duration: 5000
$('#play-level-button').prop('disabled', @remainingRequiredEquipment.length > 0)
setHero: (@selectedHero) ->
if @selectedHero.loading
@listenToOnce @selectedHero, 'sync', => @setHero? @selectedHero
return
@$el.removeClass('Warrior Ranger Wizard').addClass(@selectedHero.get('heroClass'))
@requireLevelEquipment()
@render()
@onEquipmentChanged()
onShown: ->
# Called when we switch tabs to this within the modal
@requireLevelEquipment()
onHidden: ->
# Called when the modal itself is dismissed
@endHighlight()
super()
onClickChooseHero: ->
@playSound 'menu-button-click'
@hide()
@trigger 'choose-hero-click'
onClickPlayLevel: (e) ->
return if @$el.find('#play-level-button').prop 'disabled'
@playSound 'menu-button-click'
@showLoading()
ua = navigator.userAgent.toLowerCase()
unless hasGoneFullScreenOnce or (/safari/.test(ua) and not /chrome/.test(ua)) or $(window).height() >= 658 # Min vertical resolution needed at 1366px wide
@toggleFullscreen()
hasGoneFullScreenOnce = true
@updateConfig =>
@trigger? 'play-click'
window.tracker?.trackEvent 'Inventory Play', category: 'Play Level', ['Google Analytics']
updateConfig: (callback, skipSessionSave) ->
sessionHeroConfig = @options.session.get('heroConfig') ? {}
lastHeroConfig = me.get('heroConfig') ? {}
inventory = @getCurrentEquipmentConfig()
patchSession = patchMe = false
patchSession ||= not _.isEqual inventory, sessionHeroConfig.inventory
sessionHeroConfig.inventory = inventory
if hero = @selectedHero.get('original')
patchSession ||= not _.isEqual hero, sessionHeroConfig.thangType
sessionHeroConfig.thangType = hero
patchMe ||= not _.isEqual inventory, lastHeroConfig.inventory
lastHeroConfig.inventory = inventory
if patchMe
console.log 'setting me.heroConfig to', JSON.stringify(lastHeroConfig)
me.set 'heroConfig', lastHeroConfig
me.patch()
if patchSession
console.log 'setting session.heroConfig to', JSON.stringify(sessionHeroConfig)
@options.session.set 'heroConfig', sessionHeroConfig
@options.session.patch success: callback unless skipSessionSave
else
callback?()
#- TODO: DRY this between PlayItemsModal and InventoryModal and PlayHeroesModal
onUnlockButtonClicked: (e) ->
e.stopPropagation()
button = $(e.target).closest('button')
item = @items.get(button.data('item-id'))
affordable = item.affordable
if not affordable
@playSound 'menu-button-click'
@askToBuyGems button
else if button.hasClass('confirm')
@playSound 'menu-button-unlock-end'
purchase = Purchase.makeFor(item)
purchase.save()
#- set local changes to mimic what should happen on the server...
purchased = me.get('purchased') ? {}
purchased.items ?= []
purchased.items.push(item.get('original'))
me.set('purchased', purchased)
me.set('spent', (me.get('spent') ? 0) + item.get('gems'))
#- ...then rerender key bits
@itemGroups.lockedItems.remove(item)
# Redo all item sorting to make sure that we don't clobber state changes since last render.
equipped = _.values @getCurrentEquipmentConfig()
@sortItem(item, equipped) for item in @items.models
@renderSelectors('#unequipped', '#gems-count')
@requireLevelEquipment()
@delegateEvents()
@setUpDraggableEventsForAvailableEquipment()
@itemDetailsView.setItem(item)
Backbone.Mediator.publish 'store:item-purchased', item: item, itemSlug: item.get('slug')
else
@playSound 'menu-button-unlock-start'
button.addClass('confirm').text($.i18n.t('play.confirm'))
@$el.one 'click', (e) ->
button.removeClass('confirm').text($.i18n.t('play.unlock')) if e.target isnt button[0]
askToBuyGems: (unlockButton) ->
if me.getGemPromptGroup() is 'no-prompt'
return @openModalView new BuyGemsModal()
@$el.find('.unlock-button').popover 'destroy'
popoverTemplate = buyGemsPromptTemplate {}
unlockButton.popover(
animation: true
trigger: 'manual'
placement: 'top'
content: ' ' # template has it
container: @$el
template: popoverTemplate
).popover 'show'
popover = unlockButton.data('bs.popover')
popover?.$tip?.i18n()
onBuyGemsPromptButtonClicked: (e) ->
@playSound 'menu-button-click'
@openModalView new BuyGemsModal()
onClickedSomewhere: (e) ->
return if @destroyed
@$el.find('.unlock-button').popover 'destroy'
#- Paper doll equipment updating
onEquipmentChanged: ->
heroClass = @selectedHero?.get('heroClass') ? 'Warrior'
gender = if @selectedHero?.get('slug') in heroGenders.male then 'male' else 'female'
@$el.find('#hero-image, #hero-image-hair, #hero-image-head, #hero-image-thumb').removeClass().addClass "#{gender} #{heroClass}"
equipment = @getCurrentEquipmentConfig()
return unless _.size(equipment) and @supermodel.finished()
@removeDollImages()
slotsWithImages = []
for slot, original of equipment
item = _.find @items.models, (item) -> item.get('original') is original
continue unless dollImages = item?.get('dollImages')
didAdd = @addDollImage slot, dollImages, heroClass, gender
slotsWithImages.push slot if didAdd
@$el.find('#hero-image-hair').toggle not ('head' in slotsWithImages)
@$el.find('#hero-image-thumb').toggle not ('gloves' in slotsWithImages)
@equipment = @options.equipment = equipment
removeDollImages: ->
@$el.find('.doll-image').remove()
addDollImage: (slot, dollImages, heroClass, gender) ->
heroClass = @selectedHero?.get('heroClass') ? 'Warrior'
gender = if @selectedHero?.get('slug') in heroGenders.male then 'male' else 'female'
didAdd = false
if slot is 'gloves'
if heroClass is 'Ranger'
imageKeys = ["#{gender}#{heroClass}", "#{gender}#{heroClass}Thumb"]
else
imageKeys = ["#{gender}", "#{gender}Thumb"]
else
imageKeys = [gender]
for imageKey in imageKeys
imageURL = dollImages[imageKey]
if not imageURL
console.log "Hmm, should have #{slot} #{imageKey} paper doll image, but don't have it."
else
imageEl = $('<img>').attr('src', "/file/#{imageURL}").addClass("doll-image #{slot} #{heroClass} #{gender} #{_.string.underscored(imageKey).replace(/_/g, '-')}").attr('draggable', false)
@$el.find('#equipped').append imageEl
didAdd = true
didAdd
destroy: ->
@$el.find('.unlock-button').popover 'destroy'
@stage?.removeAllChildren()
super()
heroGenders =
male: ['knight', 'samurai', 'trapper', 'potion-master']
female: ['captain', 'ninja', 'forest-archer', 'librarian', 'sorcerer']
gear =
'simple-boots': '53e237bf53457600003e3f05'
'simple-sword': '53e218d853457600003e3ebe'
'tarnished-bronze-breastplate': '53e22eac53457600003e3efc'
'leather-boots': '53e2384453457600003e3f07'
'leather-belt': '5437002a7beba4a82024a97d'
'programmaticon-i': '53e4108204c00d4607a89f78'
'programmaticon-ii': '546e25d99df4a17d0d449be1'
'crude-glasses': '53e238df53457600003e3f0b'
'crude-builders-hammer': '53f4e6e3d822c23505b74f42'
'long-sword': '544d7d1f8494308424f564a3'
'sundial-wristwatch': '53e2396a53457600003e3f0f'
'bronze-shield': '544c310ae0017993fce214bf'
'wooden-glasses': '53e2167653457600003e3eb3'
'basic-flags': '545bacb41e649a4495f887da'
'roughedge': '544d7d918494308424f564a7'
'sharpened-sword': '544d7deb8494308424f564ab'
'crude-crossbow': '544d7ffd8494308424f564c3'
'crude-dagger': '544d952b8494308424f56517'
'weak-charge': '544d957d8494308424f5651f'
'enchanted-stick': '544d87188494308424f564f1'
'unholy-tome-i': '546374bc3839c6e02811d308'
'book-of-life-i': '546375653839c6e02811d30b'