mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-25 23:12:29 -05:00
407 lines
14 KiB
CoffeeScript
407 lines
14 KiB
CoffeeScript
CocoView = require 'views/core/CocoView'
|
|
template = require 'templates/play/level/playback'
|
|
{me} = require 'core/auth'
|
|
|
|
module.exports = class LevelPlaybackView extends CocoView
|
|
id: 'playback-view'
|
|
template: template
|
|
|
|
subscriptions:
|
|
'level:disable-controls': 'onDisableControls'
|
|
'level:enable-controls': 'onEnableControls'
|
|
'level:set-playing': 'onSetPlaying'
|
|
'level:toggle-playing': 'onTogglePlay'
|
|
'level:scrub-forward': 'onScrubForward'
|
|
'level:scrub-back': 'onScrubBack'
|
|
'level:set-volume': 'onSetVolume'
|
|
'surface:frame-changed': 'onFrameChanged'
|
|
'god:new-world-created': 'onNewWorld'
|
|
'god:streaming-world-updated': 'onNewWorld'
|
|
'level:set-letterbox': 'onSetLetterbox'
|
|
'tome:cast-spells': 'onTomeCast'
|
|
'playback:real-time-playback-ended': 'onRealTimePlaybackEnded'
|
|
'playback:stop-real-time-playback': 'onStopRealTimePlayback'
|
|
'real-time-multiplayer:manual-cast': 'onRealTimeMultiplayerCast'
|
|
|
|
events:
|
|
'click #music-button': 'onToggleMusic'
|
|
'click #zoom-in-button': -> Backbone.Mediator.publish 'camera:zoom-in', {} unless @shouldIgnore()
|
|
'click #zoom-out-button': -> Backbone.Mediator.publish 'camera:zoom-out', {} unless @shouldIgnore()
|
|
'click #volume-button': 'onToggleVolume'
|
|
'click #play-button': 'onTogglePlay'
|
|
'click': -> Backbone.Mediator.publish 'tome:focus-editor', {} unless @realTime
|
|
'mouseenter #timeProgress': 'onProgressEnter'
|
|
'mouseleave #timeProgress': 'onProgressLeave'
|
|
'mousemove #timeProgress': 'onProgressHover'
|
|
'tapstart #timeProgress': 'onProgressTapStart'
|
|
'tapend #timeProgress': 'onProgressTapEnd'
|
|
'tapmove #timeProgress': 'onProgressTapMove'
|
|
|
|
shortcuts:
|
|
'⌘+p, p, ctrl+p': 'onTogglePlay'
|
|
'⌘+[, ctrl+[': 'onScrubBack'
|
|
'⌘+⇧+[, ctrl+⇧+[': 'onSingleScrubBack'
|
|
'⌘+], ctrl+]': 'onScrubForward'
|
|
'⌘+⇧+], ctrl+⇧+]': 'onSingleScrubForward'
|
|
|
|
constructor: ->
|
|
super(arguments...)
|
|
me.on('change:music', @updateMusicButton, @)
|
|
|
|
afterRender: ->
|
|
super()
|
|
@$progressScrubber = $('.scrubber .progress', @$el)
|
|
@hookUpScrubber()
|
|
@updateMusicButton()
|
|
$(window).on('resize', @onWindowResize)
|
|
ua = navigator.userAgent.toLowerCase()
|
|
if /safari/.test(ua) and not /chrome/.test(ua)
|
|
@$el.find('.toggle-fullscreen').hide()
|
|
@timePopup ?= new HoverPopup
|
|
t = $.i18n.t
|
|
@second = t 'units.second'
|
|
@seconds = t 'units.seconds'
|
|
@minute = t 'units.minute'
|
|
@minutes = t 'units.minutes'
|
|
@goto = t 'play_level.time_goto'
|
|
@current = t 'play_level.time_current'
|
|
@total = t 'play_level.time_total'
|
|
@$el.find('#play-button').css('visibility', 'hidden') if @options.level.get 'hidesPlayButton' # Don't show for first few levels, confuses new players.
|
|
|
|
updatePopupContent: ->
|
|
@timePopup?.updateContent "<h2>#{@timeToString @newTime}</h2>#{@formatTime(@current, @currentTime)}<br/>#{@formatTime(@total, @totalTime)}"
|
|
|
|
# These functions could go to some helper class
|
|
|
|
pad2: (num) ->
|
|
if not num? or num is 0 then '00' else ((if num < 10 then '0' else '') + num)
|
|
|
|
formatTime: (text, time) =>
|
|
"#{text}\t#{@timeToString time}"
|
|
|
|
timeToString: (time=0, withUnits=false) ->
|
|
mins = Math.floor(time / 60)
|
|
secs = (time - mins * 60).toFixed(1)
|
|
if withUnits
|
|
ret = ''
|
|
ret = (mins + ' ' + (if mins is 1 then @minute else @minutes)) if (mins > 0)
|
|
ret = (ret + ' ' + secs + ' ' + (if secs is 1 then @second else @seconds)) if (secs > 0 or mins is 0)
|
|
else
|
|
"#{mins}:#{@pad2 secs}"
|
|
|
|
# callbacks
|
|
|
|
updateMusicButton: ->
|
|
@$el.find('#music-button').toggleClass('music-on', me.get('music'))
|
|
|
|
onSetLetterbox: (e) ->
|
|
return if @realTime
|
|
@togglePlaybackControls !e.on
|
|
@disabled = e.on
|
|
|
|
togglePlaybackControls: (to) ->
|
|
buttons = @$el.find '#play-button, .scrubber-handle'
|
|
buttons.css 'visibility', if to then 'visible' else 'hidden'
|
|
|
|
onTomeCast: (e) ->
|
|
return unless e.realTime
|
|
@realTime = true
|
|
@togglePlaybackControls false
|
|
Backbone.Mediator.publish 'playback:real-time-playback-started', {}
|
|
@playSound 'real-time-playback-start'
|
|
|
|
onRealTimeMultiplayerCast: (e) ->
|
|
@realTime = true
|
|
@togglePlaybackControls false
|
|
Backbone.Mediator.publish 'playback:real-time-playback-waiting', {}
|
|
|
|
onWindowResize: (s...) =>
|
|
@barWidth = $('.progress', @$el).width()
|
|
|
|
onNewWorld: (e) ->
|
|
@updateBarWidth e.world.frames.length, e.world.maxTotalFrames, e.world.dt
|
|
|
|
updateBarWidth: (loadedFrameCount, maxTotalFrames, dt) ->
|
|
@totalTime = (loadedFrameCount - 1) * dt
|
|
pct = parseInt(100 * loadedFrameCount / (maxTotalFrames - 1)) + '%'
|
|
@barWidth = $('.progress', @$el).css('width', pct).show().width()
|
|
$('.scrubber .progress', @$el).slider('enable', true)
|
|
@newTime = 0
|
|
@currentTime = 0
|
|
@lastLoadedFrameCount = loadedFrameCount
|
|
|
|
onDisableControls: (e) ->
|
|
if not e.controls or ('playback' in e.controls)
|
|
@disabled = true
|
|
$('button', @$el).addClass('disabled')
|
|
try
|
|
@$progressScrubber.slider('disable', true)
|
|
catch error
|
|
console.warn('error disabling scrubber', error)
|
|
@timePopup?.disable()
|
|
$('#volume-button', @$el).removeClass('disabled')
|
|
@$el.addClass 'controls-disabled'
|
|
|
|
onEnableControls: (e) ->
|
|
return if @realTime
|
|
if not e.controls or ('playback' in e.controls)
|
|
@disabled = false
|
|
$('button', @$el).removeClass('disabled')
|
|
try
|
|
@$progressScrubber.slider('enable', true)
|
|
catch error
|
|
console.warn('error enabling scrubber', error)
|
|
@timePopup?.enable()
|
|
@$el.removeClass 'controls-disabled'
|
|
|
|
onSetPlaying: (e) ->
|
|
@playing = (e ? {}).playing ? true
|
|
button = @$el.find '#play-button'
|
|
ended = button.hasClass 'ended'
|
|
changed = button.hasClass('playing') isnt @playing
|
|
button.toggleClass('playing', @playing and not ended).toggleClass('paused', not @playing and not ended)
|
|
@playSound (if @playing then 'playback-play' else 'playback-pause')
|
|
return # don't stripe the bar
|
|
bar = @$el.find '.scrubber .progress'
|
|
bar.toggleClass('progress-striped', @playing and not ended).toggleClass('active', @playing and not ended)
|
|
|
|
onSetVolume: (e) ->
|
|
classes = ['vol-off', 'vol-down', 'vol-up']
|
|
button = $('#volume-button', @$el)
|
|
button.removeClass(c) for c in classes
|
|
button.addClass(classes[0]) if e.volume <= 0.0
|
|
button.addClass(classes[1]) if e.volume > 0.0 and e.volume < 1.0
|
|
button.addClass(classes[2]) if e.volume >= 1.0
|
|
|
|
onScrub: (e, options) ->
|
|
e?.preventDefault?()
|
|
options.scrubDuration = 500
|
|
Backbone.Mediator.publish('level:set-time', options)
|
|
|
|
onScrubForward: (e) ->
|
|
@onScrub e, ratioOffset: 0.05
|
|
|
|
onSingleScrubForward: (e) ->
|
|
@onScrub e, frameOffset: 1
|
|
|
|
onScrubBack: (e) ->
|
|
@onScrub e, ratioOffset: -0.05
|
|
|
|
onSingleScrubBack: (e) ->
|
|
@onScrub e, frameOffset: -1
|
|
|
|
onFrameChanged: (e) ->
|
|
if e.progress isnt @lastProgress
|
|
@currentTime = e.frame / e.world.frameRate
|
|
@updatePopupContent() if @timePopup?.shown
|
|
@updateProgress(e.progress, e.world)
|
|
@updatePlayButton(e.progress)
|
|
@lastProgress = e.progress
|
|
|
|
onProgressEnter: (e) ->
|
|
# Why it needs itself as parameter you ask? Ask Twitter instead.
|
|
@timePopup?.enter @timePopup
|
|
|
|
onProgressLeave: (e) ->
|
|
@timePopup?.leave @timePopup
|
|
|
|
onProgressHover: (e, offsetX) ->
|
|
timeRatio = @$progressScrubber.width() / @totalTime
|
|
offsetX ?= e.clientX - $(e.target).closest('#timeProgress').offset().left
|
|
offsetX = Math.max 0, offsetX
|
|
@newTime = offsetX / timeRatio
|
|
@updatePopupContent()
|
|
@timePopup?.onHover e
|
|
|
|
# Show it instantaneously if close enough to current time.
|
|
if @timePopup and Math.abs(@currentTime - @newTime) < 1 and not @timePopup.shown
|
|
@timePopup.show()
|
|
|
|
onProgressTapStart: (e, touchData) ->
|
|
return unless application.isIPadApp
|
|
@onProgressEnter e
|
|
screenOffsetX = e.clientX ? touchData?.position.x ? 0
|
|
offsetX = screenOffsetX - $(e.target).closest('#timeProgress').offset().left
|
|
offsetX = Math.max offsetX, 0
|
|
@scrubTo offsetX / @$progressScrubber.width()
|
|
@onTogglePlay() if @$el.find('#play-button').hasClass 'playing'
|
|
|
|
onProgressTapEnd: (e, touchData) ->
|
|
return unless application.isIPadApp
|
|
@onProgressLeave e
|
|
|
|
onProgressTapMove: (e, touchData) ->
|
|
return unless application.isIPadApp # Not sure why the tap events would fire when it's not one.
|
|
screenOffsetX = e.clientX ? touchData?.position.x ? 0
|
|
offsetX = screenOffsetX - $(e.target).closest('#timeProgress').offset().left
|
|
offsetX = Math.max offsetX, 0
|
|
@onProgressHover e, offsetX
|
|
@scrubTo offsetX / @$progressScrubber.width()
|
|
|
|
updateProgress: (progress, world) ->
|
|
if world.frames.length isnt @lastLoadedFrameCount
|
|
@updateBarWidth world.frames.length, world.maxTotalFrames, world.dt
|
|
wasLoaded = @worldCompletelyLoaded
|
|
@worldCompletelyLoaded = world.frames.length is world.totalFrames
|
|
if @realTime and @worldCompletelyLoaded and not wasLoaded
|
|
Backbone.Mediator.publish 'playback:real-time-playback-ended', {}
|
|
Backbone.Mediator.publish 'level:set-letterbox', on: false
|
|
$('.scrubber .progress-bar', @$el).css('width', "#{progress * 100}%")
|
|
|
|
updatePlayButton: (progress) ->
|
|
playButton = @$el.find('#play-button')
|
|
wasEnded = playButton.hasClass('ended')
|
|
if @worldCompletelyLoaded and progress >= 0.99 and @lastProgress < 0.99
|
|
playButton.removeClass('playing').removeClass('paused').addClass('ended')
|
|
Backbone.Mediator.publish 'level:set-letterbox', on: false if @realTime
|
|
Backbone.Mediator.publish 'playback:real-time-playback-ended', {} if @realTime
|
|
if progress < 0.99 and @lastProgress >= 0.99
|
|
playButton.removeClass('ended')
|
|
playButton.addClass(if @playing then 'playing' else 'paused')
|
|
isEnded = playButton.hasClass('ended')
|
|
if wasEnded isnt isEnded
|
|
Backbone.Mediator.publish 'playback:ended-changed', ended: isEnded
|
|
|
|
onRealTimePlaybackEnded: (e) ->
|
|
return unless @realTime
|
|
@realTime = false
|
|
@togglePlaybackControls true
|
|
@playSound 'real-time-playback-end'
|
|
|
|
onStopRealTimePlayback: (e) ->
|
|
Backbone.Mediator.publish 'level:set-letterbox', on: false
|
|
Backbone.Mediator.publish 'playback:real-time-playback-ended', {}
|
|
|
|
# to refactor
|
|
|
|
hookUpScrubber: ->
|
|
@sliderIncrements = 500 # max slider width before we skip pixels
|
|
@$progressScrubber.slider(
|
|
max: @sliderIncrements
|
|
animate: 'slow'
|
|
slide: (event, ui) =>
|
|
return if @shouldIgnore()
|
|
++@slideCount
|
|
oldRatio = @getScrubRatio()
|
|
@scrubTo ui.value / @sliderIncrements
|
|
if ratioChange = @getScrubRatio() - oldRatio
|
|
sound = "playback-scrub-slide-#{if ratioChange > 0 then 'forward' else 'back'}-#{@slideCount % 3}"
|
|
unless /back/.test sound # We don't have the back sounds in yet: http://discourse.codecombat.com/t/bug-some-mp3-lost/4830
|
|
@playSound sound, (Math.min 1, Math.abs ratioChange * 50)
|
|
|
|
start: (event, ui) =>
|
|
return if @shouldIgnore()
|
|
@slideCount = 0
|
|
@wasPlaying = @playing and not $('#play-button').hasClass('ended')
|
|
Backbone.Mediator.publish 'level:set-playing', {playing: false}
|
|
@playSound 'playback-scrub-start', 0.5
|
|
|
|
|
|
stop: (event, ui) =>
|
|
return if @shouldIgnore()
|
|
@actualProgress = ui.value / @sliderIncrements
|
|
Backbone.Mediator.publish 'playback:manually-scrubbed', ratio: @actualProgress # For scripts
|
|
Backbone.Mediator.publish 'level:set-playing', {playing: @wasPlaying}
|
|
if @slideCount < 3
|
|
@wasPlaying = false
|
|
Backbone.Mediator.publish 'level:set-playing', {playing: false}
|
|
@$el.find('.scrubber-handle').effect('bounce', {times: 2})
|
|
else
|
|
@playSound 'playback-scrub-end', 0.5
|
|
|
|
)
|
|
|
|
getScrubRatio: ->
|
|
@$progressScrubber.find('.progress-bar').width() / @$progressScrubber.width()
|
|
|
|
scrubTo: (ratio, duration=0) ->
|
|
return if @shouldIgnore()
|
|
Backbone.Mediator.publish 'level:set-time', ratio: ratio, scrubDuration: duration
|
|
|
|
shouldIgnore: -> return @disabled or @realTime
|
|
|
|
onTogglePlay: (e) ->
|
|
e?.preventDefault?()
|
|
return if @shouldIgnore()
|
|
button = $('#play-button')
|
|
willPlay = button.hasClass('paused') or button.hasClass('ended')
|
|
Backbone.Mediator.publish 'level:set-playing', playing: willPlay
|
|
$(document.activeElement).blur()
|
|
|
|
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
|
|
Backbone.Mediator.publish 'level:set-volume', volume: volumes[newI]
|
|
$(document.activeElement).blur()
|
|
|
|
onToggleMusic: (e) ->
|
|
e?.preventDefault()
|
|
me.set('music', not me.get('music', true))
|
|
me.patch()
|
|
$(document.activeElement).blur()
|
|
|
|
destroy: ->
|
|
me.off('change:music', @updateMusicButton, @)
|
|
$(window).off('resize', @onWindowResize)
|
|
@onWindowResize = null
|
|
super()
|
|
|
|
# popover that shows at the current mouse position on the progressbar, using the bootstrap popover.
|
|
# Could make this into a jQuery plugins itself theoretically.
|
|
class HoverPopup extends $.fn.popover.Constructor
|
|
constructor: () ->
|
|
@enabled = true
|
|
@shown = false
|
|
@type = 'HoverPopup'
|
|
@options =
|
|
placement: 'top'
|
|
container: 'body'
|
|
animation: true
|
|
html: true
|
|
delay:
|
|
show: 400
|
|
@$element = $('#timeProgress')
|
|
@$tip = $('#timePopover')
|
|
|
|
@content = ''
|
|
|
|
getContent: -> @content
|
|
|
|
show: ->
|
|
unless @shown
|
|
super()
|
|
@shown = true
|
|
|
|
updateContent: (@content) ->
|
|
@setContent()
|
|
@$tip.addClass('fade top in')
|
|
|
|
onHover: (@e) ->
|
|
pos = @getPosition()
|
|
actualWidth = @$tip[0].offsetWidth
|
|
actualHeight = @$tip[0].offsetHeight
|
|
calculatedOffset =
|
|
top: pos.top - actualHeight
|
|
left: pos.left + pos.width / 2 - actualWidth / 2
|
|
@applyPlacement(calculatedOffset, 'top')
|
|
|
|
getPosition: ->
|
|
top: @$element.offset().top
|
|
left: if @e? then @e.pageX else @$element.offset().left
|
|
height: 0
|
|
width: 0
|
|
|
|
hide: ->
|
|
super()
|
|
@shown = false
|
|
|
|
disable: ->
|
|
super()
|
|
@hide()
|