Add per-level tips and tricks, available during gameplay to help unstick players.

Closes #3736
This commit is contained in:
Scott Erickson 2016-05-26 17:46:49 -07:00 committed by Matt Lott
parent c8e7b79e5d
commit 86fc4a3846
20 changed files with 347 additions and 104 deletions

View file

@ -440,6 +440,8 @@
tome_available_spells: "Available Spells"
tome_your_skills: "Your Skills"
tome_current_method: "Current Method"
hints: "Hints"
hints_title: "Hint {{number}}"
code_saved: "Code Saved"
skip_tutorial: "Skip (esc)"
keyboard_shortcuts: "Key Shortcuts"

View file

@ -179,6 +179,18 @@ module.exports = class User extends CocoModel
application.tracker.identify fourthLevelGroup: @fourthLevelGroup unless me.isAdmin()
@fourthLevelGroup
getHintsGroup: ->
# A/B testing two styles of hints
return @hintsGroup if @hintsGroup
group = me.get('testGroupNumber') % 3
@hintsGroup = switch group
when 0 then 'no-hints'
when 1 then 'hints'
when 2 then 'hintsB'
@hintsGroup = 'hints' if me.isAdmin()
application.tracker.identify hintsGroup: @hintsGroup unless me.isAdmin()
@hintsGroup
getVideoTutorialStylesIndex: (numVideos=0)->
# A/B Testing video tutorial styles
# Not a constant number of videos available (e.g. could be 0, 1, 3, or 4 currently)

View file

@ -0,0 +1,41 @@
@import "app/styles/mixins"
.hints-view
position: relative
width: 500px // TODO: should be in sync with surface min-width
padding: 10px 20px
border-style: solid
border-image: url(/images/level/popover_border_background.png) 16 12 fill round
border-width: 16px 12px
@include box-shadow(0 0 0 #000)
.close-hint-btn
position: absolute
right: 5px
top: 5px
.glyphicon-remove
position: relative
top: 4px
h1
margin-bottom: 30px
.btn-area
margin-top: 20px
.hint-title
font-size: 18px
text-transform: uppercase
.hint-body
height: 390px
overflow-y: auto
img
width: 100%
.hint-pagination
font-size: 18px
margin-top: 0px
text-transform: uppercase

View file

@ -78,9 +78,6 @@
@include flex-column()
@include flex-align-content-start()
&.no-help
margin-top: 3%
.property-entry-item-group
display: inline-block
min-height: 38px

View file

@ -85,27 +85,18 @@
.glyphicon-fullscreen
display: none
.hints-button
float: right
border-style: solid
border-image: url(/images/common/button-background-primary-active.png) 14 20 20 20 fill round
border-width: 7px 10px 10px 10px
color: white
&:hover, &:active
border-image: url(/images/common/button-background-primary-pressed.png) 14 20 20 20 fill round
.thang-avatar-wrapper
border-width: 0
.method-name-area
margin-left: 10px
margin-top: 10px
text-transform: uppercase
display: inline-block
font-family: $headings-font-family
font-weight: bold
.method-label
font-size: 12px
color: rgb(243, 211, 59)
margin-bottom: -5px
.method-signature
color: white
font-size: 18px
padding: 0
.spell-list-entry-view:not(.spell-tab)
cursor: pointer
@include opacity(0.90)

View file

@ -264,6 +264,13 @@ $level-resize-transition-time: 0.5s
width: 100%
height: 90px
text-align: center
.hints-view
position: absolute
top: 10px
bottom: 10px
right: 45%
z-index: 1000000
html.fullscreen-editor
#level-view

View file

@ -0,0 +1,21 @@
button.close-hint-btn.btn.btn-illustrated.btn-danger
span.glyphicon.glyphicon-remove
h1.text-center.hint-title
span= view.state.get('hintsTitle')
.hint-body
!= view.getProcessedHint()
.row.btn-area
.col-md-4
if view.state.get('hintIndex') > 0
button.previous-btn.btn.btn-illustrated.pull-left(data-i18n="about.previous")
.col-md-4
h2.text-center.hint-pagination #{view.state.get('hintIndex')+1} / #{view.hintsState.get('total')}
.col-md-4
if view.state.get('hintIndex') < view.hintsState.get('total') - 1
button.next-btn.btn.btn-illustrated.pull-right(data-i18n="about.next")
.clearfix

View file

@ -0,0 +1,36 @@
div
span.code-palette-background
if view.entryGroupSlugs
// Non-hero; group by entry groups, or maybe nothing.
ul(class="nav nav-pills" + (tabbed ? ' multiple-tabs' : ''))
each slug, group in view.entryGroupSlugs
li(class=group == "this" || slug == "available-spells" ? "active" : "")
a(data-toggle="pill", data-target='#palette-tab-' + slug)
h4= view.entryGroupNames[group]
.tab-content
each slug, group in view.entryGroupSlugs
div(id="palette-tab-" + slug, class="tab-pane nano" + (group == "this" || slug == view.defaultGroupSlug ? " active" : ""))
div(class="properties properties-" + slug + " nano-content")
else if view.tabs
// Hero; group by items, but also include tabs
ul(class="nav nav-pills multiple-tabs")
li.active
a(data-toggle="pill", data-target="#palette-tab-this")
h4= view.thisName
each entries, tab in view.tabs
li
a(data-toggle="pill", data-target='#palette-tab-' + _.string.slugify(tab))
h4= tab
.tab-content
div#palette-tab-this.tab-pane.active
.properties.properties-this
each entries, tab in tabs
div(id="palette-tab-" + _.string.slugify(tab), class="tab-pane")
div(class="properties properties-" + _.string.slugify(tab))
else
// Hero; group by items, no tabs.
br
.properties.properties-this

View file

@ -9,22 +9,22 @@ if includeSpellList
.thang-avatar-placeholder
.method-name-area
.method-label(data-i18n="play_level.tome_current_method") Current Method
.method-signature #{methodSignature}
.spell-tool-buttons
.btn.btn-small.btn-illustrated.btn-warning.reload-code(data-i18n="[title]play_level.tome_reload_method", title="Reload original code for this method")
.glyphicon.glyphicon-repeat
span.spl(data-i18n="play_level.reload") Reload
if me.level() >= 15
.btn.btn-small.btn-illustrated.fullscreen-code(title=maximizeShortcutVerbose)
.glyphicon.glyphicon-fullscreen
.glyphicon.glyphicon-resize-small
if codeLanguage === 'javascript' && me.level() >= 15
.btn.btn-small.btn-illustrated.beautify-code(title=beautifyShortcutVerbose)
.glyphicon.glyphicon-magnet
.clearfix
if view.hintsState && view.hintsState.get('total') > 0
.btn.btn-small.btn-illustrated.hints-button
span(data-i18n="play_level.hints")
.clearfix

View file

@ -1,37 +0,0 @@
span.code-palette-background
if entryGroupSlugs
// Non-hero; group by entry groups, or maybe nothing.
ul(class="nav nav-pills" + (tabbed ? ' multiple-tabs' : ''))
each slug, group in entryGroupSlugs
li(class=group == "this" || slug == "available-spells" ? "active" : "")
a(data-toggle="pill", data-target='#palette-tab-' + slug)
h4= entryGroupNames[group]
.tab-content
each slug, group in entryGroupSlugs
div(id="palette-tab-" + slug, class="tab-pane nano" + (group == "this" || slug == defaultGroupSlug ? " active" : ""))
div(class="properties properties-" + slug + " nano-content")
else if tabs
// Hero; group by items, but also include tabs
ul(class="nav nav-pills multiple-tabs")
li.active
a(data-toggle="pill", data-target="#palette-tab-this")
h4= thisName
each entries, tab in tabs
li
a(data-toggle="pill", data-target='#palette-tab-' + _.string.slugify(tab))
h4= tab
.tab-content
div#palette-tab-this.tab-pane.active
.properties.properties-this
each entries, tab in tabs
div(id="palette-tab-" + _.string.slugify(tab), class="tab-pane")
div(class="properties properties-" + _.string.slugify(tab))
else
// Hero; group by items, no tabs.
if showsHelp
button.btn.btn-sm.btn-info.banner#spell-palette-help-button(data-i18n="common.help")
.properties.properties-this
else
.properties.properties-this.no-help

View file

@ -49,6 +49,8 @@ if view.showAds()
button.btn.btn-lg.btn-warning.banner.header-font#stop-real-time-playback-button(title="Stop real-time playback", data-i18n="play_level.skip") Skip
.hints-view.hide
#level-footer-shadow
#level-footer-background

View file

@ -0,0 +1,30 @@
module.exports = class HintsState extends Backbone.Model
initialize: (attributes, options) ->
{ @level, @session } = options
@listenTo(@level, 'change:documentation', @update)
@update()
getHint: (index) ->
@get('hints')?[index]
update: ->
hints = switch me.getHintsGroup()
when 'hints' then @level.get('documentation')?.hints or []
when 'hintsB' then @level.get('documentation')?.hintsB or []
else []
haveIntro = false
haveOverview = false
for article in @level.get('documentation')?.specificArticles ? []
if not haveIntro and article.name is 'Intro'
hints.unshift(article)
haveIntro = true
if not haveOverview and article.name is 'Overview'
hints.push(article)
haveOverview = true
break if haveIntro and haveOverview
total = _.size(hints)
@set({
hints: hints
total
})

View file

@ -0,0 +1,94 @@
CocoView = require 'views/core/CocoView'
State = require 'models/State'
utils = require 'core/utils'
module.exports = class HintsView extends CocoView
template: require('templates/play/level/hints-view')
className: 'hints-view'
hintUsedThresholdSeconds: 10
events:
'click .next-btn': 'onClickNextButton'
'click .previous-btn': 'onClickPreviousButton'
'click .close-hint-btn': 'hideView'
subscriptions:
'level:show-victory': 'hideView'
'tome:manual-cast': 'hideView'
initialize: (options) ->
{@level, @session, @hintsState} = options
@state = new State({
hintIndex: 0
hintsViewTime: {}
hintsUsed: {}
})
@updateHint()
debouncedRender = _.debounce(@render)
@listenTo(@state, 'change', debouncedRender)
@listenTo(@hintsState, 'change', debouncedRender)
@listenTo(@state, 'change:hintIndex', @updateHint)
@listenTo(@hintsState, 'change:hidden', @visibilityChanged)
destroy: ->
clearInterval(@timerIntervalID)
super()
afterRender: ->
@$el.toggleClass('hide', @hintsState.get('hidden'))
super()
getProcessedHint: ->
language = @session.get('codeLanguage')
hint = @state.get('hint')
return unless hint
# process
translated = utils.i18n(hint, 'body')
filtered = utils.filterMarkdownCodeLanguages(translated, language)
markedUp = marked(filtered)
return markedUp
updateHint: ->
index = @state.get('hintIndex')
hintsTitle = $.i18n.t('play_level.hints_title').replace('{{number}}', index + 1)
@state.set({ hintsTitle, hint: @hintsState.getHint(index) })
onClickNextButton: ->
window.tracker?.trackEvent 'Hints Next Clicked', category: 'Students', levelSlug: @level.get('slug'), hintCount: @hintsState.get('hints')?.length ? 0, hintCurrent: @state.get('hintIndex'), ['Mixpanel']
max = @hintsState.get('total') - 1
@state.set('hintIndex', Math.min(@state.get('hintIndex') + 1, max))
@playSound 'menu-button-click'
@updateHintTimer()
onClickPreviousButton: ->
window.tracker?.trackEvent 'Hints Previous Clicked', category: 'Students', levelSlug: @level.get('slug'), hintCount: @hintsState.get('hints')?.length ? 0, hintCurrent: @state.get('hintIndex'), ['Mixpanel']
@state.set('hintIndex', Math.max(@state.get('hintIndex') - 1, 0))
@playSound 'menu-button-click'
@updateHintTimer()
hideView: -> @hintsState?.set('hidden', true)
visibilityChanged: (e) ->
@updateHintTimer()
updateHintTimer: ->
clearInterval(@timerIntervalID)
unless @hintsState.get('hidden') or @state.get('hintsUsed')?[@state.get('hintIndex')]
@timerIntervalID = setInterval(@incrementHintViewTime, 1000)
incrementHintViewTime: =>
hintIndex = @state.get('hintIndex')
hintsViewTime = @state.get('hintsViewTime')
hintsViewTime[hintIndex] ?= 0
hintsViewTime[hintIndex]++
hintsUsed = @state.get('hintsUsed')
if hintsViewTime[hintIndex] > @hintUsedThresholdSeconds and not hintsUsed[hintIndex]
window.tracker?.trackEvent 'Hint Used', category: 'Students', levelSlug: @level.get('slug'), hintCount: @hintsState.get('hints')?.length ? 0, hintCurrent: hintIndex, ['Mixpanel']
hintsUsed[hintIndex] = true
@state.set('hintsUsed', hintsUsed)
clearInterval(@timerIntervalID)
@state.set('hintsViewTime', hintsViewTime)

View file

@ -1,5 +1,5 @@
RootView = require 'views/core/RootView'
template = require 'templates/play/level'
template = require 'templates/play/play-level-view'
{me} = require 'core/auth'
ThangType = require 'models/ThangType'
utils = require 'core/utils'
@ -41,6 +41,8 @@ PicoCTFVictoryModal = require './modal/PicoCTFVictoryModal'
InfiniteLoopModal = require './modal/InfiniteLoopModal'
LevelSetupManager = require 'lib/LevelSetupManager'
ContactModal = require 'views/core/ContactModal'
HintsView = require './HintsView'
HintsState = require './HintsState'
PROFILE_ME = false
@ -259,7 +261,8 @@ module.exports = class PlayLevelView extends RootView
@god.setGoalManager @goalManager
insertSubviews: ->
@insertSubView @tome = new TomeView levelID: @levelID, session: @session, otherSession: @otherSession, thangs: @world.thangs, supermodel: @supermodel, level: @level, observing: @observing, courseID: @courseID, courseInstanceID: @courseInstanceID, god: @god
@hintsState = new HintsState({ hidden: true }, { @session, @level })
@insertSubView @tome = new TomeView { @levelID, @session, @otherSession, thangs: @world.thangs, @supermodel, @level, @observing, @courseID, @courseInstanceID, @god, @hintsState }
@insertSubView new LevelPlaybackView session: @session, level: @level
@insertSubView new GoalsView {}
@insertSubView new LevelFlagsView levelID: @levelID, world: @world if @$el.hasClass 'flags'
@ -270,6 +273,7 @@ module.exports = class PlayLevelView extends RootView
@insertSubView new ProblemAlertView session: @session, level: @level, supermodel: @supermodel
@insertSubView new DuelStatsView level: @level, session: @session, otherSession: @otherSession, supermodel: @supermodel, thangs: @world.thangs if @level.get('type') in ['hero-ladder', 'course-ladder']
@insertSubView @controlBar = new ControlBarView {worldName: utils.i18n(@level.attributes, 'name'), session: @session, level: @level, supermodel: @supermodel, courseID: @courseID, courseInstanceID: @courseInstanceID}
@insertSubView @hintsView = new HintsView({ @session, @level, @hintsState }), @$('.hints-view')
#_.delay (=> Backbone.Mediator.publish('level:set-debug', debug: true)), 5000 if @isIPadApp() # if me.displayName() is 'Nick'
initVolume: ->

View file

@ -51,7 +51,12 @@ module.exports = class Spell
if @canRead() # We can avoid creating these views if we'll never use them.
@view = new SpellView {spell: @, level: options.level, session: @session, otherSession: @otherSession, worker: @worker, god: options.god, @supermodel}
@view.render() # Get it ready and code loaded in advance
@tabView = new SpellListTabEntryView spell: @, supermodel: @supermodel, codeLanguage: @language, level: options.level
@tabView = new SpellListTabEntryView
hintsState: options.hintsState
spell: @
supermodel: @supermodel
codeLanguage: @language
level: options.level
@tabView.render()
Backbone.Mediator.publish 'tome:spell-created', spell: @

View file

@ -31,35 +31,10 @@ module.exports = class SpellListEntryView extends CocoView
getRenderData: (context={}) ->
context = super context
context.spell = @spell
context.methodSignature = @createMethodSignature()
context.thangNames = (thangID for thangID, spellThang of @spell.thangs when spellThang.thang.exists).join(', ') # + ', Marcus, Robert, Phoebe, Will Smith, Zap Brannigan, You, Gandaaaaalf'
context.showTopDivider = @showTopDivider
context
createMethodSignature: ->
return @spell.name if @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']
parameters = (@spell.parameters or []).slice()
if @spell.language in ['python', 'lua']
parameters.unshift 'self'
else if @spell.language is 'io'
parameters.unshift '...'
paramString = parameters.join ', '
name = @spell.name
switch @spell.language
when 'python'
"def #{name}(#{paramString}):"
when 'lua'
"function #{name}(#{paramString}) ... end"
when 'coffeescript'
if parameters.length
"@#{name} = (#{paramString}) ->"
else
"@#{name} = ->"
when 'javascript'
"function #{name}(#{paramString}) { ... }"
else
"#{name}(#{paramString})"
getPrimarySpellThang: ->
if @lastSelectedThang
spellThang = _.find @spell.thangs, (spellThang) => spellThang.thang.id is @lastSelectedThang.id

View file

@ -23,9 +23,11 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView
'click .reload-code': 'onCodeReload'
'click .beautify-code': 'onBeautifyClick'
'click .fullscreen-code': 'onToggleMaximize'
'click .hints-button': 'onClickHintsButton'
constructor: (options) ->
super options
@hintsState = options.hintsState
super(options)
getRenderData: (context={}) ->
context = super context
@ -91,6 +93,11 @@ module.exports = class SpellListTabEntryView extends SpellListEntryView
onDisableControls: (e) -> @toggleControls e, false
onEnableControls: (e) -> @toggleControls e, true
onClickHintsButton: ->
return unless @hintsState?
@hintsState.set('hidden', not @hintsState.get('hidden'))
window.tracker?.trackEvent 'Hints Clicked', category: 'Students', levelSlug: @options.level.get('slug'), hintCount: @hintsState.get('hints')?.length ? 0, ['Mixpanel']
onDropdownClick: (e) ->
return unless @controlsEnabled
Backbone.Mediator.publish 'tome:toggle-spell-list', {}

View file

@ -1,5 +1,4 @@
CocoView = require 'views/core/CocoView'
template = require 'templates/play/level/tome/spell_palette'
{me} = require 'core/auth'
filters = require 'lib/image_filter'
SpellPaletteEntryView = require './SpellPaletteEntryView'
@ -12,7 +11,7 @@ N_ROWS = 4
module.exports = class SpellPaletteView extends CocoView
id: 'spell-palette-view'
template: template
template: require 'templates/play/level/tome/spell-palette-view'
controlsEnabled: true
subscriptions:
@ -24,13 +23,8 @@ module.exports = class SpellPaletteView extends CocoView
events:
'click #spell-palette-help-button': 'onClickHelp'
constructor: (options) ->
super options
@level = options.level
@session = options.session
@supermodel = options.supermodel
@thang = options.thang
@useHero = options.useHero
initialize: (options) ->
{@level, @session, @supermodel, @thang, @useHero} = options
docs = @options.level.get('documentation') ? {}
@showsHelp = docs.specificArticles?.length or docs.generalArticles?.length
@createPalette()

View file

@ -122,6 +122,7 @@ module.exports = class TomeView extends CocoView
unless method.cloneOf
skipProtectAPI = @getQueryVariable 'skip_protect_api', (@options.levelID in ['gridmancer', 'minimax-tic-tac-toe'])
spell = @spells[spellKey] = new Spell
hintsState: @options.hintsState
programmableMethod: method
spellKey: spellKey
pathComponents: pathPrefixComponents.concat(pathComponents)
@ -219,7 +220,7 @@ module.exports = class TomeView extends CocoView
updateSpellPalette: (thang, spell) ->
return unless thang and @spellPaletteView?.thang isnt thang and thang.programmableProperties or thang.apiProperties
useHero = /hero/.test(spell.getSource()) or not /(self[\.\:]|this\.|\@)/.test(spell.getSource())
@spellPaletteView = @insertSubView new SpellPaletteView thang: thang, supermodel: @supermodel, programmable: spell?.canRead(), language: spell?.language ? @options.session.get('codeLanguage'), session: @options.session, level: @options.level, courseID: @options.courseID, courseInstanceID: @options.courseInstanceID, useHero: useHero
@spellPaletteView = @insertSubView new SpellPaletteView { thang, @supermodel, programmable: spell?.canRead(), language: spell?.language ? @options.session.get('codeLanguage'), session: @options.session, level: @options.level, courseID: @options.courseID, courseInstanceID: @options.courseInstanceID, useHero }
@spellPaletteView.toggleControls {}, spell.view.controlsEnabled if spell?.view # TODO: know when palette should have been disabled but didn't exist
spellFor: (thang, spellName) ->

View file

@ -0,0 +1,61 @@
HintsView = require 'views/play/level/HintsView'
factories = require 'test/app/factories'
hintWithCode = """
Hint #2 rosebud
```python
print('Hello World')
```
```javascript
console.log('Hello World')
```
"""
longHint = _.times(100, -> 'Beuller...').join('\n\n')
xdescribe 'HintsView', ->
beforeEach ->
level = factories.makeLevel({
documentation: {
hints: [
{ body: 'Hint #1 xyzzy' }
{ body: hintWithCode }
{ body: longHint }
]
}
})
@session = factories.makeLevelSession({ playtime: 0 })
@view = new HintsView({ level, @session })
@view.render()
jasmine.demoEl(@view.$el)
describe 'when the first hint is shown', ->
it 'does not show the previous button', ->
expect(@view.$el.find('.previous-btn').length).toBe(0)
describe 'when the user has played for a while', ->
beforeEach ->
@view.render()
it 'shows the first hint', ->
expect(_.string.contains(@view.$el.text(), 'xyzzy')).toBe(true)
it 'shows the next hint button', ->
expect(@view.$el.find('.next-btn').length).toBe(1)
it 'filters out all code blocks but those of the selected language', ->
@session.set({
codeLanguage: 'javascript'
playtime: 9001
})
@view.state.set('hintIndex', 1)
@view.render()
if _.string.contains(@view.$el.text(), 'print')
fail('Python code snippet found, should be filtered out')
if not _.string.contains(@view.$el.text(), 'console')
fail('JavaScript code snippet not found')