Real-time multiplayer initial commit

Simple matchmaking, synchronous multiplayer PVP, flags!

Rough matchmaking is under the game menu multiplayer tab, for ladder
games only.  After creating a 2-person game there, you can exit that
modal and real-time cast to play against each other.

If you’re the first person to cast, you’ll sit at the real-time level
playback view waiting until the other player casts.  When they do, you
both should start the real-time playback (and start placing flags like
crazy people).

If in a multiplayer session, the real-time simulation runs the players’
code against each other.  Your multiplayer opponent’s name should be up
near the level name.

Multiplayer sessions are stored completely in Firebase for now, and
removed if both players leave the game.  There’s plenty of bugs,
synchronization issues, and minimal polish to add before we push it to
master.
This commit is contained in:
Matt Lott 2014-08-28 23:34:07 -07:00
parent a4b2333fd3
commit 68cca74b43
11 changed files with 358 additions and 10 deletions

View file

@ -0,0 +1,6 @@
module.exports = class FlagCollection extends Backbone.Firebase.Collection
constructor: (savePath) ->
# TODO: Don't hard code this here
# TODO: Use prod path in prod
@firebase = 'https://codecombat.firebaseio.com/test/db/' + savePath
super()

View file

@ -5,24 +5,85 @@ if !ladderGame
input#multiplayer(name="multiplayer", type="checkbox", checked=multiplayer)
span(data-i18n="multiplayer.multiplayer_toggle") Enable multiplayer
span.help-block(data-i18n="multiplayer.multiplayer_toggle_description") Allow others to join your game.
hr
div#link-area
p(data-i18n="multiplayer.multiplayer_link_description") Give this link to anyone to have them join you.
textarea.well#multiplayer-join-link(readonly=true)= joinLink
p
strong(data-i18n="multiplayer.multiplayer_hint_label") Hint:
span(data-i18n="multiplayer.multiplayer_hint") Click the link to select all, then press ⌘-C or Ctrl-C to copy the link.
p(data-i18n="multiplayer.multiplayer_coming_soon") More multiplayer features to come!
if ladderGame
if me.get('anonymous')
p(data-i18n="multiplayer.multiplayer_sign_in_leaderboard") Sign in or create an account and get your solution on the leaderboard.
else if readyToRank
button#create-game-button Create Game
hr
div#created-multiplayer-session
h3 Your Game
if currentMultiplayerSession
div
span(style="margin:10px")= currentMultiplayerSession.get('levelID')
span(style="margin:10px")= currentMultiplayerSession.get('creatorName')
span(style="margin:10px")= currentMultiplayerSession.get('state')
span(style="margin:10px")= currentMultiplayerSession.id
button#leave-game-button(data-item=item) Leave Game
div
- var players = playersCollections[currentMultiplayerSession.id]
span(style="margin:10px") Players:
- for (var i=0; i < players.length; i++) {
- var name = players.at(i).get('name')
- var team = players.at(i).get('team')
span(style="margin:10px")= name
span(style="margin:10px")= team
- }
else
div Click something above to create a game.
hr
div#open-games
h3 Open Games
- var noOpenGames = true
if multiplayerSessions
- for (var i=0; i < multiplayerSessions.length; i++) {
if levelID === multiplayerSessions[i].get('levelID') && multiplayerSessions[i].get('state') === 'creating'
- var id = multiplayerSessions[i].get('id')
- var players = playersCollections[id]
if players && players.length < 2
- noOpenGames = false
- var creatorName = multiplayerSessions[i].get('creatorName')
- var creator = multiplayerSessions[i].get('creator')
- var state = multiplayerSessions[i].get('state')
- var item = multiplayerSessions[i]
div
button#join-game-button(data-item=item) Join Game
span(style="margin:10px")= levelID
span(style="margin:10px")= creatorName
span(style="margin:10px")= state
span(style="margin:10px")= id
div
span(style="margin:10px") Players:
- for (var j=0; j < players.length; j++) {
- var name = players.at(j).get('name')
- var team = players.at(j).get('team')
span(style="margin:10px")= name
span(style="margin:10px")= team
- }
- }
if noOpenGames
div No games available.
hr
.ladder-submission-view
else
a.btn.btn-primary(href="/play/ladder/#{levelSlug}#my-matches", data-i18n="multiplayer.victory_go_ladder") Return to Ladder

View file

@ -1,13 +1,24 @@
h4.home
a(href=homeLink || "/")
a(href=homeLink || "/")
i.icon-home.icon-white
span(data-i18n="play_level.home") Home
h4.title
| #{worldName}
| -
| -
a(href=editorLink, data-i18n="nav.editor", title="Open " + worldName + " in the Level Editor") Editor
if multiplayerSession
- found = false
- for (var i=0; i < multiplayerPlayers.length; i++) {
if (multiplayerPlayers.at(i).id !== meID)
| - vs #{multiplayerPlayers.at(i).get('name')}
- found = true
- break
- }
if !found
| - waiting...
button.btn.btn-xs.btn-warning.banner#stop-real-time-playback-button(title="Stop real-time playback", data-i18n="play_level.stop") Stop

View file

@ -3,6 +3,7 @@ template = require 'templates/game-menu/multiplayer-view'
{me} = require 'lib/auth'
ThangType = require 'models/ThangType'
LadderSubmissionView = require 'views/play/common/LadderSubmissionView'
RealTimeCollection = require 'collections/RealTimeCollection'
module.exports = class MultiplayerView extends CocoView
id: 'multiplayer-view'
@ -15,6 +16,9 @@ module.exports = class MultiplayerView extends CocoView
events:
'click textarea': 'onClickLink'
'change #multiplayer': 'updateLinkSection'
'click #create-game-button': 'onCreateGame'
'click #join-game-button': 'onJoinGame'
'click #leave-game-button': 'onLeaveGame'
constructor: (options) ->
super(options)
@ -23,6 +27,20 @@ module.exports = class MultiplayerView extends CocoView
@playableTeams = options.playableTeams
@listenTo @session, 'change:multiplayer', @updateLinkSection
# TODO: only request sessions for this level, !team, etc.
# TODO: don't hard code this path all over the place
@multiplayerSessions = new RealTimeCollection('multiplayer_level_sessions/')
@multiplayerSessions.on 'add', @onMultiplayerSessionAdded
@multiplayerSessions.on 'remove', @onMultiplayerSessionRemoved
@playersCollections = {}
destroy: ->
@multiplayerSessions?.off()
@currentMultiplayerSession?.off()
for id in @playersCollections
@playersCollections[id].off()
super()
getRenderData: ->
c = super()
c.joinLink = "#{document.location.href.replace(/\?.*/, '').replace('#', '')}?session=#{@session.id}"
@ -34,6 +52,11 @@ module.exports = class MultiplayerView extends CocoView
if @level?.get('type') is 'ladder'
c.ladderGame = true
c.readyToRank = @session?.readyToRank()
c.levelID = @session.get('levelID')
c.multiplayerSessions = @multiplayerSessions.models
c.currentMultiplayerSession = @currentMultiplayerSession if @currentMultiplayerSession
c.playersCollections = @playersCollections if @playersCollections
c
afterRender: ->
@ -41,6 +64,8 @@ module.exports = class MultiplayerView extends CocoView
@updateLinkSection()
@ladderSubmissionView = new LadderSubmissionView session: @session, level: @level
@insertSubView @ladderSubmissionView, @$el.find('.ladder-submission-view')
@$el.find('#created-multiplayer-session').toggle Boolean(@currentMultiplayerSession?)
@$el.find('#create-game-button').toggle Boolean(!(@currentMultiplayerSession?))
onClickLink: (e) ->
e.target.select()
@ -52,9 +77,96 @@ module.exports = class MultiplayerView extends CocoView
updateLinkSection: ->
multiplayer = @$el.find('#multiplayer').prop('checked')
la = @$el.find('#link-area')
la.toggle Boolean(multiplayer)
la.toggle if @level?.get('type') is 'ladder' then false else Boolean(multiplayer)
true
onHidden: ->
multiplayer = Boolean(@$el.find('#multiplayer').prop('checked'))
@session.set('multiplayer', multiplayer)
# TODO: shouldn't have to open MultiplayerView to read existing multiplayerSession?
# TODO: No current game shown when: this view closed, opponent leaves your game, this view opened
onMultiplayerSessionAdded: (e) =>
# TODO: double check these players events are needed on top of onMultiplayerSessionChanged
@playersCollections[e.id] = new RealTimeCollection('multiplayer_level_sessions/' + e.id + '/players')
@playersCollections[e.id].on 'add', @onPlayerAdded
@playersCollections[e.id].on 'remove', @onPlayerRemoved
# Check if we've already joined this multiplayer session
if not @currentMultiplayerSession and e.get('levelID') == @session.get('levelID')
for i in [0...@playersCollections[e.id].length]
player = @playersCollections[e.id].at(i)
if player.get('id') is me.id and player.get('team') is @session.get('team')
@currentMultiplayerSession = e
@currentMultiplayerSession.on 'change', @onMultiplayerSessionChanged
Backbone.Mediator.publish 'realtime-multiplayer:joined-game', @currentMultiplayerSession
break
@render()
onMultiplayerSessionRemoved: (e) =>
@playersCollections[e.id].off()
delete @playersCollections[e.id]
@render()
onMultiplayerSessionChanged: (e) =>
@render()
onPlayerAdded: (e) =>
# TODO: listeners not being unhooked, this should not be called if no @render.
@render() if @render
onPlayerRemoved: (e) =>
# TODO: listeners not being unhooked, this should not be called if no @render.
@render() if @render
onCreateGame: ->
s = @multiplayerSessions.create {
creator: @session.get('creator')
creatorName: @session.get('creatorName')
levelID: @session.get('levelID')
created: Date.now()
state: 'creating'
}
@currentMultiplayerSession = @multiplayerSessions.get(s.id)
@currentMultiplayerSession.on 'change', @onMultiplayerSessionChanged
players = new RealTimeCollection('multiplayer_level_sessions/' + @currentMultiplayerSession.id + '/players')
players.create {id: me.id, name: @session.get('creatorName'), team: @session.get('team')}
Backbone.Mediator.publish 'realtime-multiplayer:joined-game', @currentMultiplayerSession
@render()
onJoinGame: (e) ->
return if @currentMultiplayerSession
item = @$el.find(e.target).data('item')
@currentMultiplayerSession = @multiplayerSessions.get(item.id)
@currentMultiplayerSession.on 'change', @onMultiplayerSessionChanged
if @playersCollections[item.id]
@playersCollections[item.id].create {id: me.id, name: @session.get('creatorName'), team: @session.get('team')}
else
console.error 'onJoinGame did not have a players collection', @currentMultiplayerSession
Backbone.Mediator.publish 'realtime-multiplayer:joined-game', @currentMultiplayerSession
if @playersCollections[item.id]?.length is 2
@currentMultiplayerSession.set 'state', 'coding'
# TODO: close multiplayer view?
@render()
onLeaveGame: (e) ->
# TODO: This doesn't update open games or current game
if @currentMultiplayerSession
players = @playersCollections[@currentMultiplayerSession.id]
for i in [0...players.length]
player = players.at(i)
if player.get('id') is me.id
players.remove(player)
# NOTE: remove(@something) doesn't stick locally, only remotely
cms = @currentMultiplayerSession
@currentMultiplayerSession.off()
@currentMultiplayerSession = null
if players.length is 0
@multiplayerSessions.remove(cms)
break
console.error "Tried to leave a game we hadn't joined!" if @currentMultiplayerSession
Backbone.Mediator.publish 'realtime-multiplayer:left-game'
else
console.error "Tried to leave a game with no currentMultiplayerSession"
@render()

View file

@ -1,8 +1,10 @@
CocoView = require 'views/kinds/CocoView'
template = require 'templates/play/level/control_bar'
{me} = require 'lib/auth'
LevelGuideModal = require './modal/LevelGuideModal'
GameMenuModal = require 'views/game-menu/GameMenuModal'
RealTimeCollection = require 'collections/RealTimeCollection'
module.exports = class ControlBarView extends CocoView
id: 'control-bar-view'
@ -10,6 +12,8 @@ module.exports = class ControlBarView extends CocoView
subscriptions:
'bus:player-states-changed': 'onPlayerStatesChanged'
'realtime-multiplayer:joined-game': 'onJoinedRealTimeMultiplayerGame'
'realtime-multiplayer:left-game': 'onLeftRealTimeMultiplayerGame'
events:
'click #docs-button': ->
@ -55,6 +59,9 @@ module.exports = class ControlBarView extends CocoView
else
c.homeLink = '/'
c.editorLink = "/editor/level/#{@level.get('slug')}"
c.multiplayerSession = @multiplayerSession if @multiplayerSession
c.multiplayerPlayers = @multiplayerPlayers if @multiplayerPlayers
c.meID = me.id
c
afterRender: ->
@ -77,3 +84,22 @@ module.exports = class ControlBarView extends CocoView
showGameMenuModal: ->
@openModalView new GameMenuModal level: @level, session: @session, playableTeams: @playableTeams
onJoinedRealTimeMultiplayerGame: (item) ->
@multiplayerSession = item
@multiplayerPlayers = new RealTimeCollection('multiplayer_level_sessions/' + item.id + '/players')
@multiplayerPlayers.on 'add', @onRealTimeMultiplayerPlayerAdded
@multiplayerPlayers.on 'remove', @onRealTimeMultiplayerPlayerRemoved
@render()
onLeftRealTimeMultiplayerGame: ->
@multiplayerSession = null
@multiplayerPlayers.off()
@multiplayerPlayers = null
@render()
onRealTimeMultiplayerPlayerAdded: (e) =>
@render()
onRealTimeMultiplayerPlayerRemoved: (e) =>
@render()

View file

@ -1,6 +1,7 @@
CocoView = require 'views/kinds/CocoView'
template = require 'templates/play/level/level-flags-view'
{me} = require 'lib/auth'
RealTimeCollection = require 'collections/RealTimeCollection'
module.exports = class LevelFlagsView extends CocoView
id: 'level-flags-view'
@ -13,6 +14,8 @@ module.exports = class LevelFlagsView extends CocoView
'god:new-world-created': 'onNewWorld'
'god:streaming-world-updated': 'onNewWorld'
'surface:remove-flag': 'onRemoveFlag'
'realtime-multiplayer:joined-game': 'onJoinedMultiplayerGame'
'realtime-multiplayer:left-game': 'onLeftMultiplayerGame'
events:
'click .green-flag': -> @onFlagSelected color: 'green', source: 'button'
@ -40,6 +43,7 @@ module.exports = class LevelFlagsView extends CocoView
@onFlagSelected color: null
@realTime = false
@$el.hide()
@multiplayerSession?.set 'state', 'coding'
onFlagSelected: (e) ->
return unless @realTime
@ -55,6 +59,7 @@ module.exports = class LevelFlagsView extends CocoView
flag = player: me.id, team: me.team, color: @flagColor, pos: pos, time: @world.dt * @world.frames.length, active: true
@flags[@flagColor] = flag
@flagHistory.push flag
@realTimeFlags?.create flag
Backbone.Mediator.publish 'level:flag-updated', flag
#console.log 'trying to place flag at', @world.age, 'and think it will happen by', flag.time
@ -72,3 +77,30 @@ module.exports = class LevelFlagsView extends CocoView
onNewWorld: (event) ->
return unless event.world.name is @world.name
@world = @options.world = event.world
onJoinedMultiplayerGame: (item) ->
@realTimeFlags = new RealTimeCollection('multiplayer_level_sessions/' + item.id + '/flagHistory')
@realTimeFlags.on 'add', @onRealTimeMultiplayerFlagAdded
onLeftMultiplayerGame: () ->
@multiplayerState = null
if @multiplayerSession
@multiplayerSession.off()
@multiplayerSession = null
if @realTimeFlags
@realTimeFlags.off()
@realTimeFlags = null
onRealTimeMultiplayerFlagAdded: (e) =>
if e.get('player') != me.id
# TODO: what is @flags used for?
# Build local flag from Backbone.Model flag
flag =
player: e.get('player')
team: e.get('team')
color: e.get('color')
pos: e.get('pos')
time: e.get('time')
active: e.get('active')
@flagHistory.push flag
Backbone.Mediator.publish 'level:flag-updated', flag

View file

@ -25,6 +25,7 @@ module.exports = class LevelPlaybackView extends CocoView
'tome:cast-spells': 'onTomeCast'
'playback:real-time-playback-ended': 'onRealTimePlaybackEnded'
'playback:stop-real-time-playback': 'onStopRealTimePlayback'
'realtime-multiplayer:manual-cast': 'onRealTimeMultiplayerCast'
events:
'click #debug-toggle': 'onToggleDebug'
@ -161,6 +162,9 @@ module.exports = class LevelPlaybackView extends CocoView
onTomeCast: (e) ->
return unless e.realTime
@onRealTimeMultiplayerCast e
onRealTimeMultiplayerCast: (e) ->
@realTime = true
@togglePlaybackControls false
Backbone.Mediator.publish 'playback:real-time-playback-started', {}

View file

@ -20,6 +20,7 @@ LevelComponent = require 'models/LevelComponent'
Article = require 'models/Article'
Camera = require 'lib/surface/Camera'
AudioPlayer = require 'lib/AudioPlayer'
RealTimeCollection = require 'collections/RealTimeCollection'
# subviews
LevelLoadingView = require './LevelLoadingView'
@ -65,6 +66,9 @@ module.exports = class PlayLevelView extends RootView
'level:loading-view-unveiled': 'onLoadingViewUnveiled'
'playback:real-time-playback-started': 'onRealTimePlaybackStarted'
'playback:real-time-playback-ended': 'onRealTimePlaybackEnded'
'realtime-multiplayer:joined-game': 'onJoinedRealTimeMultiplayerGame'
'realtime-multiplayer:left-game': 'onLeftRealTimeMultiplayerGame'
'realtime-multiplayer:manual-cast': 'onRealTimeMultiplayerCast'
events:
'click #level-done-button': 'onDonePressed'
@ -543,3 +547,61 @@ module.exports = class PlayLevelView extends RootView
delete window.nextLevelURL
console.profileEnd?() if PROFILE_ME
super()
# Real-time Multiplayer ######################################################
onJoinedRealTimeMultiplayerGame: (item) ->
@multiplayerSession = item
onLeftRealTimeMultiplayerGame: () ->
if @multiplayerSession
@multiplayerSession.off()
@multiplayerSession = null
onRealTimeMultiplayerCast: (e) ->
unless @multiplayerSession
console.error 'onRealTimeMultiplayerCast without a multiplayerSession'
return
players = new RealTimeCollection('multiplayer_level_sessions/' + @multiplayerSession.id + '/players')
myPlayer = opponentPlayer = null
for i in [0...players.length]
player = players.at(i)
if player.get('id') is me.id
myPlayer = player
else
opponentPlayer = player
if myPlayer
console.info 'Submitting my code'
myPlayer.set 'code', @session.get('code')
myPlayer.set 'codeLanguage', @session.get('codeLanguage')
myPlayer.set 'state', 'submitted'
else
console.error 'Did not find my player in onRealTimeMultiplayerCast'
if opponentPlayer
# TODO: Shouldn't need nested opponentPlayer change listeners here
state = opponentPlayer.get('state')
console.info 'Other player is', state
if state in ['submitted', 'ready']
@onOpponentSubmitted(opponentPlayer, myPlayer)
else
# Wait for opponent to submit their code
opponentPlayer.on 'change', (e) =>
state = opponentPlayer.get('state')
if state in ['submitted', 'ready']
@onOpponentSubmitted(opponentPlayer, myPlayer)
onOpponentSubmitted: (opponentPlayer, myPlayer) =>
# Save opponent's code
Backbone.Mediator.publish 'realtime-multiplayer:new-opponent-code', {codeLanguage: opponentPlayer.get('codeLanguage'), code: opponentPlayer.get('code')}
# I'm ready to rumble
myPlayer.set 'state', 'ready'
if opponentPlayer.get('state') is 'ready'
console.info 'All real-time multiplayer players are ready!'
@multiplayerSession.set 'state', 'running'
else
# Wait for opponent to be ready
opponentPlayer.on 'change', (e) =>
if opponentPlayer.get('state') is 'ready'
opponentPlayer.off()
console.info 'All real-time multiplayer players are ready!'
@multiplayerSession.set 'state', 'running'

View file

@ -15,6 +15,8 @@ module.exports = class CastButtonView extends CocoView
'tome:cast-spells': 'onCastSpells'
'god:world-load-progress-changed': 'onWorldLoadProgressChanged'
'god:new-world-created': 'onNewWorld'
'realtime-multiplayer:joined-game': 'onJoinedRealTimeMultiplayerGame'
'realtime-multiplayer:left-game': 'onLeftRealTimeMultiplayerGame'
constructor: (options) ->
super options
@ -48,7 +50,16 @@ module.exports = class CastButtonView extends CocoView
Backbone.Mediator.publish 'tome:manual-cast', {}
onCastRealTimeButtonClick: (e) ->
Backbone.Mediator.publish 'tome:manual-cast', {realTime: true}
if @multiplayerSession
Backbone.Mediator.publish 'realtime-multiplayer:manual-cast', {}
# Wait for multiplayer session to be up and running
@multiplayerSession.on 'change', (e) =>
if @multiplayerSession.get('state') is 'running'
# Real-time multiplayer session is ready to go, so resume normal cast
@multiplayerSession.off()
Backbone.Mediator.publish 'tome:manual-cast', {realTime: true}
else
Backbone.Mediator.publish 'tome:manual-cast', {realTime: true}
onCastOptionsClick: (e) =>
Backbone.Mediator.publish 'tome:focus-editor', {}
@ -106,3 +117,11 @@ module.exports = class CastButtonView extends CocoView
spell.view?.setAutocastDelay delay for spellKey, spell of @spells
@castOptions.find('a').each ->
$(@).toggleClass('selected', parseInt($(@).attr('data-delay')) is delay)
onJoinedRealTimeMultiplayerGame: (item) ->
@multiplayerSession = item
onLeftRealTimeMultiplayerGame: () ->
if @multiplayerSession
@multiplayerSession.off()
@multiplayerSession = null

View file

@ -47,6 +47,7 @@ module.exports = class Spell
@tabView.render()
@team = @permissions.readwrite[0] ? 'common'
Backbone.Mediator.publish 'tome:spell-created', spell: @
Backbone.Mediator.subscribe 'realtime-multiplayer:new-opponent-code', @onNewOpponentCode
destroy: ->
@view?.destroy()
@ -180,3 +181,13 @@ module.exports = class Spell
# Players without permissions can't view the raw code.
return true if @session.get('creator') isnt me.id and not (me.isAdmin() or 'employer' in me.get('permissions'))
false
onNewOpponentCode: (e) =>
return unless @spellKey
if e.codeLanguage and e.code
spellkeyComponents = @spellKey.split '/'
if e.code[spellkeyComponents[0]]?[spellkeyComponents[1]]
@source = e.code[spellkeyComponents[0]][spellkeyComponents[1]]
@updateLanguageAether e.codeLanguage
else
console.error 'Spell onNewOpponentCode did not recieve code', e

View file

@ -45,12 +45,16 @@
"validated-backbone-mediator": "~0.1.3",
"jquery.browser": "~0.0.6",
"zatanna": "~0.0.6",
"modernizr": "~2.8.3"
"modernizr": "~2.8.3",
"backfire": "~0.3.0"
},
"overrides": {
"backbone": {
"main": "backbone.js"
},
"backfire": {
"main": "backbone-firebase.min.js"
},
"lodash": {
"main": "dist/lodash.js"
},