From 870ae9a8a1b40139ba30c952fe8e9c75476a8a86 Mon Sep 17 00:00:00 2001 From: phoenixeliot Date: Wed, 25 May 2016 15:24:51 -0700 Subject: [PATCH] Add hero selector for courses mode Use selected hero in Course mode play Show selected hero on Courses (in progress) Add hero select modal Use short names, only show warriors Use box-shadow instead of borders Add tests for HeroSelectModal Refactor modal opening test Address code review feedback --- app/collections/ThangTypes.coffee | 7 +++- app/lib/LevelLoader.coffee | 7 ++++ app/locale/en.coffee | 3 ++ app/models/Level.coffee | 5 +++ app/models/ThangType.coffee | 19 +++++++++ app/styles/courses/courses-view.sass | 19 +++++++++ app/styles/courses/hero-select-modal.sass | 40 ++++++++++++++++++ app/templates/courses/courses-view.jade | 12 ++++++ app/templates/courses/hero-select-modal.jade | 27 ++++++++++++ app/views/courses/CoursesView.coffee | 25 ++++++++++- app/views/courses/HeroSelectModal.coffee | 42 +++++++++++++++++++ .../app/views/courses/CoursesView.spec.coffee | 32 ++++++++++++++ .../views/courses/HeroSelectModal.spec.coffee | 38 +++++++++++++++++ 13 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 app/styles/courses/hero-select-modal.sass create mode 100644 app/templates/courses/hero-select-modal.jade create mode 100644 app/views/courses/HeroSelectModal.coffee create mode 100644 test/app/views/courses/CoursesView.spec.coffee create mode 100644 test/app/views/courses/HeroSelectModal.spec.coffee diff --git a/app/collections/ThangTypes.coffee b/app/collections/ThangTypes.coffee index 8dd041307..0b030bf66 100644 --- a/app/collections/ThangTypes.coffee +++ b/app/collections/ThangTypes.coffee @@ -3,4 +3,9 @@ ThangType = require 'models/ThangType' module.exports = class ThangTypeCollection extends CocoCollection url: '/db/thang.type' - model: ThangType \ No newline at end of file + model: ThangType + + fetchHeroes: -> + @fetch { + url: '/db/thang.type?view=heroes' + } diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee index 29a201235..34548f6dc 100644 --- a/app/lib/LevelLoader.coffee +++ b/app/lib/LevelLoader.coffee @@ -168,6 +168,13 @@ module.exports = class LevelLoader extends CocoClass @consolidateFlagHistory() if @opponentSession?.loaded else if session is @opponentSession @consolidateFlagHistory() if @session.loaded + if @level.get('type', true) in ['course'] # course-ladder is hard to handle because there's 2 sessions + heroConfig = me.get('heroConfig') + return if not heroConfig + url = "/db/thang.type/#{heroConfig.thangType}/version" + if heroResource = @maybeLoadURL(url, ThangType, 'thang') + @worldNecessities.push heroResource + return return unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] heroConfig = session.get('heroConfig') heroConfig ?= me.get('heroConfig') if session is @session and not @headless diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 15b00dba5..3e409016e 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -1314,6 +1314,9 @@ sent_verification: "We've sent a verification email to:" you_can_edit: "You can edit your email address in " account_settings: "Account Settings" + select_your_hero: "Select Your Hero" + select_your_hero_description: "You can always change your hero by going to your Courses page and clicking \"Select Hero\"" + select_this_hero: "Select this Hero" teacher: teacher_dashboard: "Teacher Dashboard" # Navbar diff --git a/app/models/Level.coffee b/app/models/Level.coffee index 975e34309..166b7b11d 100644 --- a/app/models/Level.coffee +++ b/app/models/Level.coffee @@ -145,6 +145,11 @@ module.exports = class Level extends CocoModel for original, placeholderComponent of placeholders when not placeholdersUsed[original] levelThang.components.push placeholderComponent + # Load the user's chosen hero AFTER getting stats from default char + if /Hero Placeholder/.test(levelThang.id) and @get('type', true) in ['course', 'course-ladder'] + heroThangType = me.get('heroConfig')?.thangType + levelThang.thangType = heroThangType if heroThangType + sortSystems: (levelSystems, systemModels) -> [sorted, originalsSeen] = [[], {}] visit = (system) -> diff --git a/app/models/ThangType.coffee b/app/models/ThangType.coffee index fe9ec9b1d..5e1dd3a6f 100644 --- a/app/models/ThangType.coffee +++ b/app/models/ThangType.coffee @@ -239,6 +239,25 @@ module.exports = class ThangType extends CocoModel portraitOnly = !!options.portraitOnly "#{@get('name')} - #{options.resolutionFactor} - #{colorConfigs} - #{portraitOnly}" + getHeroShortName: -> + map = { + "Assassin": "Ritic" + "Captain": "Anya" + "Forest Archer": "Naria" + "Goliath": "Okar" + "Guardian": "Illia" + "Knight": "Tharin" + "Librarian": "Hushbaum" + "Necromancer": "Nalfar" + "Ninja": "Amara" + "Potion Master": "Omarn" + "Raider": "Arryn" + "Samurai": "Hattori" + "Sorcerer": "Pender" + "Trapper": "Senick" + } + map[@get('name')] + getPortraitImage: (spriteOptionsOrKey, size=100) -> src = @getPortraitSource(spriteOptionsOrKey, size) return null unless src diff --git a/app/styles/courses/courses-view.sass b/app/styles/courses/courses-view.sass index ce74620d9..0189b033d 100644 --- a/app/styles/courses/courses-view.sass +++ b/app/styles/courses/courses-view.sass @@ -41,3 +41,22 @@ #join-class-form .alert, .progress margin-top: 20px + + // Hero display + .current-hero-container + display: flex + justify-content: center + + .current-hero-text + font-size: 16pt + + .hero-avatar + background-color: #f8f8f8 + box-shadow: 0 0 0 1px gray + margin-right: 25px + + .current-hero-right-col + display: flex + flex-direction: column + justify-content: space-between + align-items: flex-start diff --git a/app/styles/courses/hero-select-modal.sass b/app/styles/courses/hero-select-modal.sass new file mode 100644 index 000000000..de1e84f09 --- /dev/null +++ b/app/styles/courses/hero-select-modal.sass @@ -0,0 +1,40 @@ +@import "app/styles/style-flat-variables" + +#hero-select-modal + .modal-dialog + width: auto + max-width: 900px + + .modal-header, .modal-body:not(.secret), .modal-footer + display: flex + flex-direction: column + align-items: center + + .modal-footer + margin: 30px + + h4 + max-width: 500px + + .hero-list + display: flex + flex-wrap: wrap + justify-content: center + margin-bottom: -50px + + .hero-option + display: flex + flex-direction: column + align-items: center + margin: 0 50px 50px + + .hero-avatar + margin: 6px + background-color: #f8f8f8 + box-shadow: 0 0 0 1px gray + + .current .hero-avatar + box-shadow: 0 0 0 6px gray + + .selected .hero-avatar + box-shadow: 0 0 0 6px $gold diff --git a/app/templates/courses/courses-view.jade b/app/templates/courses/courses-view.jade index 0200ae6b6..11a03df1e 100644 --- a/app/templates/courses/courses-view.jade +++ b/app/templates/courses/courses-view.jade @@ -39,6 +39,18 @@ block content .text-center h1(data-i18n="courses.welcome_to_page") Welcome to your Courses page! + + .current-hero-container.text-center.row + .hero-avatar + img(src=view.hero.getPortraitURL()) + .current-hero-right-col + .semibold.current-hero-text + span.spr(data-i18n="TODO") + | Current Hero: + span.current-hero-name= view.hero.getHeroShortName() + button.change-hero-btn.btn.btn-lg.btn-forest + span(data-i18n="TODO") + | Change Hero if view.classrooms.size() h3.text-uppercase(data-i18n="courses.my_classes") diff --git a/app/templates/courses/hero-select-modal.jade b/app/templates/courses/hero-select-modal.jade new file mode 100644 index 000000000..9d918bb41 --- /dev/null +++ b/app/templates/courses/hero-select-modal.jade @@ -0,0 +1,27 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + .text-center + h3(data-i18n="courses.select_your_hero") + h4(data-i18n="courses.select_your_hero_description") + +block modal-body-content + .hero-list + if view.heroes.loaded + each hero in view.heroes.models + if hero.get('heroClass') === 'Warrior' + +heroOption(hero) + +mixin heroOption(hero) + - var heroID = hero.id + - var selectedState = (state.get('selectedHeroID') === heroID ? 'selected' : (state.get('currentHeroID') === heroID ? 'current' : '')) + .hero-option(data-hero-id=heroID class=selectedState) + .hero-avatar + img(src=hero.getPortraitURL()) + .text-h5.hero-name + span= hero.getHeroShortName() + +block modal-footer-content + .select-hero-btn.btn.btn-lg.btn-forest + span(data-i18n="courses.select_this_hero") + diff --git a/app/views/courses/CoursesView.coffee b/app/views/courses/CoursesView.coffee index d255beb9d..0cf3d2d90 100644 --- a/app/views/courses/CoursesView.coffee +++ b/app/views/courses/CoursesView.coffee @@ -4,6 +4,7 @@ template = require 'templates/courses/courses-view' AuthModal = require 'views/core/AuthModal' CreateAccountModal = require 'views/core/CreateAccountModal' ChangeCourseLanguageModal = require 'views/courses/ChangeCourseLanguageModal' +HeroSelectModal = require 'views/courses/HeroSelectModal' ChooseLanguageModal = require 'views/courses/ChooseLanguageModal' JoinClassModal = require 'views/courses/JoinClassModal' CourseInstance = require 'models/CourseInstance' @@ -13,6 +14,7 @@ Classroom = require 'models/Classroom' Classrooms = require 'collections/Classrooms' LevelSession = require 'models/LevelSession' Campaign = require 'models/Campaign' +ThangType = require 'models/ThangType' utils = require 'core/utils' # TODO: Test everything @@ -24,6 +26,7 @@ module.exports = class CoursesView extends RootView events: 'click #log-in-btn': 'onClickLogInButton' 'click #start-new-game-btn': 'openSignUpModal' + 'click .change-hero-btn': 'onClickChangeHeroButton' 'click #join-class-btn': 'onClickJoinClassButton' 'submit #join-class-form': 'onSubmitJoinClassForm' 'click #change-language-link': 'onClickChangeLanguageLink' @@ -43,6 +46,16 @@ module.exports = class CoursesView extends RootView @courses = new CocoCollection([], { url: "/db/course", model: Course}) @supermodel.loadCollection(@courses) + # TODO: Trim this section for only what's necessary + @hero = new ThangType + defaultHeroOriginal = ThangType.heroes.captain + heroOriginal = me.get('heroConfig')?.thangType or defaultHeroOriginal + @hero.url = "/db/thang.type/#{heroOriginal}/version" + # @hero.setProjection ['name','slug','soundTriggers','featureImages','gems','heroClass','description','components','extendedName','unlockLevelName','i18n'] + @supermodel.loadModel(@hero, 'hero') + @listenTo @hero, 'all', -> + @render() + onCourseInstancesLoaded: -> map = {} for courseInstance in @courseInstances.models @@ -76,6 +89,16 @@ module.exports = class CoursesView extends RootView @openModalView(modal) application.tracker?.trackEvent 'Started Student Signup', category: 'Courses' + onClickChangeHeroButton: -> + modal = new HeroSelectModal({ currentHeroID: @hero.id }) + @openModalView(modal) + @listenTo modal, 'hero-select:success', (newHero) => + # @hero.url = "/db/thang.type/#{me.get('heroConfig').thangType}/version" + # @hero.fetch() + @hero.set(newHero.attributes) + @listenTo modal, 'hide', -> + @stopListening modal + onSubmitJoinClassForm: (e) -> e.preventDefault() @joinClass() @@ -136,7 +159,7 @@ module.exports = class CoursesView extends RootView classroomCourseInstances.fetch({ data: {classroomID: newClassroom.id} }) @listenToOnce classroomCourseInstances, 'sync', -> # TODO: Smoother system for joining a classroom and course instances, without requiring page reload, - # and showing which class was just joined. + # and showing which class was just joined. document.location.search = '' # Using document.location.reload() causes an infinite loop of reloading onClickChangeLanguageLink: -> diff --git a/app/views/courses/HeroSelectModal.coffee b/app/views/courses/HeroSelectModal.coffee new file mode 100644 index 000000000..c658b6941 --- /dev/null +++ b/app/views/courses/HeroSelectModal.coffee @@ -0,0 +1,42 @@ +ModalView = require 'views/core/ModalView' +template = require 'templates/courses/hero-select-modal' +Classroom = require 'models/Classroom' +ThangTypes = require 'collections/ThangTypes' +State = require 'models/State' +ThangType = require 'models/ThangType' +User = require 'models/User' + +module.exports = class HeroSelectModal extends ModalView + id: 'hero-select-modal' + template: template + + events: + 'click .select-hero-btn': 'onClickSelectHeroButton' + 'click .hero-option': 'onClickHeroOption' + + initialize: ({ currentHeroID }) -> + @debouncedRender = _.debounce @render, 0 + + @state = new State({ + currentHeroID + selectedHeroID: currentHeroID + }) + + @heroes = new ThangTypes({}, { project: ['original', 'name', 'heroClass'] }) + @supermodel.trackRequest @heroes.fetchHeroes() + + @listenTo @state, 'all', -> @debouncedRender() + @listenTo @heroes, 'all', -> @debouncedRender() + + onClickHeroOption: (e) -> + heroID = $(e.currentTarget).data('hero-id') + @state.set selectedHeroID: heroID + hero = @heroes.get(heroID) + me.set(heroConfig: {}) unless me.get('heroConfig') + heroConfig = _.assign me.get('heroConfig'), { thangType: hero.get('original') } + me.set({ heroConfig }) + me.save().then => + @trigger 'hero-select:success', hero + + onClickSelectHeroButton: () -> + @hide() diff --git a/test/app/views/courses/CoursesView.spec.coffee b/test/app/views/courses/CoursesView.spec.coffee new file mode 100644 index 000000000..195111232 --- /dev/null +++ b/test/app/views/courses/CoursesView.spec.coffee @@ -0,0 +1,32 @@ +CoursesView = require 'views/courses/CoursesView' +HeroSelectModal = require 'views/courses/HeroSelectModal' +Classrooms = require 'collections/Classrooms' +CourseInstances = require 'collections/CourseInstances' +Courses = require 'collections/Courses' +auth = require 'core/auth' +factories = require 'test/app/factories' + +describe 'CoursesView', -> + + modal = null + view = null + + describe 'Change Hero button', -> + beforeEach (done) -> + view = new CoursesView() + classrooms = new Classrooms([factories.makeClassroom()]) + courseInstances = new CourseInstances([factories.makeCourseInstance()]) + courses = new Courses([factories.makeCourse()]) + view.classrooms.fakeRequests[0].respondWith({ status: 200, responseText: classrooms.stringify() }) + view.ownedClassrooms.fakeRequests[0].respondWith({ status: 200, responseText: classrooms.stringify() }) + view.courseInstances.fakeRequests[0].respondWith({ status: 200, responseText: courseInstances.stringify() }) + view.render() + jasmine.demoEl(view.$el) + done() + + it 'opens the modal when you click Change Hero', -> + spyOn(view, 'openModalView') + view.$('.change-hero-btn').click() + expect(view.openModalView).toHaveBeenCalled() + args = view.openModalView.calls.argsFor(0) + expect(args[0] instanceof HeroSelectModal).toBe(true) diff --git a/test/app/views/courses/HeroSelectModal.spec.coffee b/test/app/views/courses/HeroSelectModal.spec.coffee new file mode 100644 index 000000000..b38861f4e --- /dev/null +++ b/test/app/views/courses/HeroSelectModal.spec.coffee @@ -0,0 +1,38 @@ +HeroSelectModal = require 'views/courses/HeroSelectModal' +auth = require 'core/auth' +factories = require 'test/app/factories' + +describe 'HeroSelectModal', -> + + modal = null + coursesView = null + user = null + + hero1 = factories.makeThangType({ original: "hero1original", _id: "hero1id", heroClass: "Warrior", name: "Hero 1" }) + hero2 = factories.makeThangType({ original: "hero2original", _id: "hero2id", heroClass: "Warrior", name: "Hero 2" }) + heroesResponse = JSON.stringify([hero1, hero2]) + + beforeEach (done) -> + window.me = user = factories.makeUser({ heroConfig: { thangType: hero1.get('original') } }) + auth.loginUser(user.attributes) + modal = new HeroSelectModal({ currentHeroID: hero1.id }) + modal.heroes.fakeRequests[0].respondWith({ status: 200, responseText: heroesResponse }) + jasmine.demoModal(modal) + _.defer -> + modal.render() + done() + + afterEach -> + modal.stopListening() + + it 'highlights the current hero', -> + expect(modal.$(".hero-option[data-hero-id='#{hero1.id}']")[0].className.split(" ")).toContain('selected') + + it 'saves when you change heroes', (done) -> + modal.$(".hero-option[data-hero-id='#{hero2.id}']").click() + _.defer -> + expect(user.fakeRequests.length).toBe(1) + request = user.fakeRequests[0] + expect(request.method).toBe("PUT") + expect(JSON.parse(request.params).heroConfig?.thangType).toBe(hero2.get('original')) + done()