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()