Skip to content

Commit

Permalink
Messing around with real-time multiplayer in game development
Browse files Browse the repository at this point in the history
  • Loading branch information
nwinter committed Dec 6, 2017
1 parent a75294c commit ebb829d
Show file tree
Hide file tree
Showing 10 changed files with 217 additions and 15 deletions.
21 changes: 15 additions & 6 deletions app/lib/Bus.coffee
@@ -1,4 +1,6 @@
CocoClass = require 'core/CocoClass'
firebase = require('firebase/app')
require('firebase/database')

{me} = require 'core/auth'

Expand All @@ -11,7 +13,13 @@ module.exports = Bus = class Bus extends CocoClass
@get: (docName) -> return @getFromCache or new Bus docName
@getFromCache: (docName) -> return Bus.activeBuses[docName]
@activeBuses: {}
@fireHost: 'https://codecombat.firebaseio.com'
@fireConfig:
apiKey: "AIzaSyA0PxqiV7dsPT-0T2F9aaNlCUBkVeYrb8w"
authDomain: "codecombat.firebaseapp.com"
databaseURL: "https://codecombat.firebaseio.com"
projectId: "firebase-codecombat"
storageBucket: ""
messagingSenderId: "555257565317"

constructor: (@docName) ->
super()
Expand All @@ -22,13 +30,13 @@ module.exports = Bus = class Bus extends CocoClass
'auth:me-synced': 'onMeSynced'

connect: ->
# Put Firebase back in bower if you want to use this
Backbone.Mediator.publish 'bus:connecting', {bus: @}
Firebase.goOnline()
@fireRef = new Firebase(Bus.fireHost + '/' + @docName)
Bus.fireApp ?= firebase.initializeApp Bus.fireConfig
@fireRef = Bus.fireApp.database().ref @docName
@fireRef.once 'value', @onFireOpen

onFireOpen: (snapshot) =>
console.log 'on fire open', snapshot
if @destroyed
console.log("Leaving '#{@docName}' because class has been destroyed.")
return
Expand All @@ -46,6 +54,7 @@ module.exports = Bus = class Bus extends CocoClass
@myConnection = null
@joined = false
Backbone.Mediator.publish 'bus:disconnected', {bus: @}
# TODO: clean up Bus.fireApp if it's the last Bus?

init: ->
"""
Expand All @@ -60,12 +69,12 @@ module.exports = Bus = class Bus extends CocoClass
join: ->
@joined = true
@myConnection = @firePlayersRef.child(me.id)
@myConnection.set({id: me.id, name: me.get('name'), connected: true})
@myConnection.set({id: me.id, name: me.displayName(), connected: true})
@onDisconnect = @myConnection.child('connected').onDisconnect()
@onDisconnect.set(false)

listenForChanges: ->
@fireChatRef.limit(CHAT_SIZE_LIMIT).on 'child_added', @onChatAdded
@fireChatRef.limitToLast(CHAT_SIZE_LIMIT).on 'child_added', @onChatAdded
@firePlayersRef.on 'child_added', @onPlayerJoined
@firePlayersRef.on 'child_removed', @onPlayerLeft
@firePlayersRef.on 'child_changed', @onPlayerChanged
Expand Down
161 changes: 161 additions & 0 deletions app/lib/GameDevLevelBus.coffee
@@ -0,0 +1,161 @@
Bus = require './Bus'
{me} = require 'core/auth'
LevelSession = require 'models/LevelSession'
utils = require 'core/utils'
firebase = require('firebase/app')
require('firebase/database')

module.exports = class GameDevLevelBus extends Bus

@get: (sessionID) ->
docName = "play/game-dev-level/#{sessionID}"
return @getByDocName docName

@getByDocName: (docName) ->
return Bus.getFromCache(docName) or new GameDevLevelBus docName

subscriptions:
'playback:real-time-playback-started': 'onRealTimePlaybackStarted'
'playback:real-time-playback-ended': 'onRealTimePlaybackEnded'
'god:new-world-created': 'onNewWorldCreated'

constructor: ->
super(arguments...)

setGameUIState: (gameUIState) ->
return if @gameUIState is gameUIState
@gameUIState = gameUIState
@realTimeInputEvents = @gameUIState.get 'realTimeInputEvents'
@listenTo @realTimeInputEvents, 'add', @onRealTimeInputEventsChanged
@listenTo @realTimeInputEvents, 'reset', @onRealTimeInputEventsChanged

init: ->
super()
@firePlaybackRef = @fireRef.child('playback')
@firePlaybackRef.on 'value', @onFirePlaybackChanged
#@firePlaybackRealTimeInputEventsRef = @firePlaybackRef.child('realTimeInputEvents')

onMeSynced: =>
super()

join: ->
super()

disconnect: ->
super()
@firePlaybackRef?.off()
@firePlaybackRef = null

onRealTimePlaybackStarted: (e) ->
return if @playing
@playing = true
return if @playback?.startPlayer is me.id and @playbackIsCurrent()
elapsed = new Date().getTime() - @playback?.startDate ? 0
console.log "It's been #{elapsed} since playback was started by #{if @playback?.startPlayer is me.id then 'me' else @playback?.startPlayer}, so we're starting playback!"
@playback = playing: true, startDate: new Date().getTime(), startPlayer: me.id, realTimeInputEvents: []
playback = _.clone @playback
playback.startDate = firebase.database.ServerValue.TIMESTAMP
@firePlaybackRef.set playback

onRealTimePlaybackEnded: (e) ->
return unless @playing
@playing = false
if @playback?.startPlayer is me.id
@playback = playing: false
console.log "Playback has ended, stopping it right up."
@firePlaybackRef.set @playback
else
console.log "Playback has ended, so #{@playback?.startPlayer} should stop that up."

onFirePlaybackChanged: (snapshot) =>
newPlayback = snapshot.val()
return if newPlayback.playing is false and @playing # Someone else stopped playing, but we haven't yet.
@playback = newPlayback
return unless @playback
console.log 'Playback has been updated:', @playback, '-- is me?', @playback.startPlayer is me.id, 'is current?', @playbackIsCurrent(), 'after', new Date().getTime() - @playback?.startDate ? 0
if not @playing and @playback.playing and @playback.startPlayer isnt me.id and @playbackIsCurrent()
console.log ' We should start too, yo!'
Backbone.Mediator.publish 'bus:multiplayer-level-start', playback: @playback, bus: @
existingEvents = @formatRealTimeInputEvents()
if @playing
for key, event of @playback.realTimeInputEvents ? []
if _.find existingEvents, event
console.log "Found existing network input event:", event, event.type, event.keyCode, 'at', event.time
else
console.log 'Got new networked input event:', event, event.type, event.keyCode, 'at', event.time
@realTimeInputEvents.add event

playbackIsCurrent: ->
Math.abs(@playback.startDate - new Date().getTime()) < 2000

onRealTimeInputEventsChanged: (e) ->
# TODO: differentiate between reset and new event handlers
console.log 'yo yo yo yo yo got new real time input event', e
currentEvents = @formatRealTimeInputEvents()
if not currentEvents.length and (@playback.realTimeInputEvents ? []).length
console.log " Clearing events."
@playback.realTimeInputEvents = currentEvents
@firePlaybackRef.child('realTimeInputEvents').set @playback.realTimeInputEvents
else if currentEvents.length and not @playback.realTimeInputEvents
@playback.realTimeInputEvents = currentEvents
@firePlaybackRef.child('realTimeInputEvents').set @playback.realTimeInputEvents
else
for event in currentEvents
unless _.find @playback.realTimeInputEvents, event
console.log " Adding new event:", event.type, event.keyCode, event.time, event
key = @firePlaybackRef.child('realTimeInputEvents').push event
@playback.realTimeInputEvents[key] = event
#@playback.realTimeInputEvents = @formatRealTimeInputEvents()
#console.log @playback.realTimeInputEvents
#@firePlaybackRef.child('realTimeInputEvents').push @playback.realTimeInputEvents

formatRealTimeInputEvents: ->
formattedEvents = []
for event in @realTimeInputEvents.models
formattedEvent = event.attributes
formattedEvents.push formattedEvent
formattedEvents

onNewWorldCreated: (e) ->
return unless @onPoint()
console.log 'on New World Created'
return
# Record the flag history.
state = @session.get('state')
flagHistory = (flag for flag in e.world.flagHistory when flag.source isnt 'code')
return if _.isEqual state.flagHistory, flagHistory
state.flagHistory = flagHistory
@changedSessionProperties.state = true
@session.set('state', state)
@saveSession()

onPlayerJoined: (snapshot) =>
super(arguments...)
return unless @onPoint()
# TODO: anything?
return
players = @session.get('players')
players ?= {}
player = snapshot.val()
return if players[player.id]?
players[player.id] = {}
@session.set('players', players)
@changedSessionProperties.players = true
@saveSession()

onChatAdded: (snapshot) =>
super(arguments...)
# TODO: anything?
return
chat = @session.get('chat')
chat ?= []
message = snapshot.val()
return if message.system
chat.push(message)
chat = chat[chat.length-50...] if chat.length > 50
@session.set('chat', chat)
@changedSessionProperties.chat = true
@saveSession()

destroy: ->
super()
3 changes: 3 additions & 0 deletions app/lib/LevelBus.coffee
Expand Up @@ -7,6 +7,9 @@ module.exports = class LevelBus extends Bus

@get: (levelID, sessionID) ->
docName = "play/level/#{levelID}/#{sessionID}"
return @getByDocName docName

@getByDocName: (docName) ->
return Bus.getFromCache(docName) or new LevelBus docName

subscriptions:
Expand Down
6 changes: 4 additions & 2 deletions app/lib/surface/Surface.coffee
Expand Up @@ -44,6 +44,7 @@ module.exports = Surface = class Surface extends CocoClass
worldLoaded: false
scrubbing: false
debug: false
realTineInputDelay: 1.5

defaults:
paths: true
Expand Down Expand Up @@ -529,7 +530,7 @@ module.exports = Surface = class Surface extends CocoClass
@realTimeInputEvents.add({
type: 'mousedown'
pos: @camera.screenToWorld x: e.originalEvent.stageX, y: e.originalEvent.stageY
time: @world.dt * @world.frames.length
time: @world.dt * @world.frames.length + @realTineInputDelay
thangID: e.sprite.thang.id
})

Expand Down Expand Up @@ -561,7 +562,8 @@ module.exports = Surface = class Surface extends CocoClass
onKeyEvent: (e) =>
return unless @realTime
event = _.pick(e, 'type', 'keyCode', 'ctrlKey', 'metaKey', 'shiftKey')
event.time = @world.dt * @world.frames.length
event.time = @world.dt * @world.frames.length + @realTineInputDelay
console.log 'adding', event.type, event.keyCode, 'at', event.time
@realTimeInputEvents.add(event)

#- Canvas callbacks
Expand Down
4 changes: 4 additions & 0 deletions app/schemas/subscriptions/bus.coffee
Expand Up @@ -25,3 +25,7 @@ module.exports =
'bus:player-states-changed': c.object {title: 'Player state changes', description: 'State of the players has changed'},
states: {type: 'object', additionalProperties: {type: 'object'}}
bus: {$ref: 'bus'}

'bus:multiplayer-level-start': c.object {title: 'Multiplayer level start', description: 'The level should start'},
playback: {type: 'object'}
bus: {$ref: 'bus'}
3 changes: 3 additions & 0 deletions app/styles/play/level/play-game-dev-level-view.sass
Expand Up @@ -63,3 +63,6 @@
#game-dev-track-view
right: 10px
top: 10px

#level-chat-view
bottom: 40px
1 change: 1 addition & 0 deletions app/templates/play/level/play-game-dev-level-view.jade
Expand Up @@ -9,6 +9,7 @@ include ../game-dev-goals.jade
#game-dev-track-view
canvas(width=924, height=589)#webgl-surface
canvas(width=924, height=589)#normal-surface
#level-chat-view

#info-col.col-xs-3
.panel.panel-default
Expand Down
11 changes: 5 additions & 6 deletions app/views/play/level/LevelChatView.coffee
Expand Up @@ -17,20 +17,19 @@ module.exports = class LevelChatView extends CocoView
'bus:new-message': 'onNewMessage'

constructor: (options) ->
@levelID = options.levelID
@session = options.session
# TODO: we took out session.multiplayer, so this will not fire. If we want to resurrect it, we'll of course need a new way of activating chat.
@listenTo(@session, 'change:multiplayer', @updateMultiplayerVisibility)
@sessionID = options.sessionID
@bus = LevelBus.get(@levelID, @sessionID)
if @session
# TODO: we took out session.multiplayer, so this will not fire. If we want to resurrect it, we'll of course need a new way of activating chat.
@listenTo(@session, 'change:multiplayer', @updateMultiplayerVisibility)
@bus = options.bus ? LevelBus.get(options.levelID, options.sessionID)
super()
@regularlyClearOldMessages()
@playNoise = _.debounce(@playNoise, 100)

updateMultiplayerVisibility: ->
return unless @$el?
try
@$el.toggle Boolean @session.get('multiplayer')
@$el.toggle Boolean @session?.get('multiplayer') ? true
catch e
console.error "Couldn't toggle the style on the LevelChatView to #{Boolean @session.get('multiplayer')} because of an error:", e

Expand Down
21 changes: 20 additions & 1 deletion app/views/play/level/PlayGameDevLevelView.coffee
Expand Up @@ -18,6 +18,8 @@ GameDevVictoryModal = require './modal/GameDevVictoryModal'
aetherUtils = require 'lib/aether_utils'
GameDevTrackView = require './GameDevTrackView'
api = require 'core/api'
GameDevLevelBus = require 'lib/GameDevLevelBus'
ChatView = require './LevelChatView'

require 'lib/game-libraries'
window.Box2D = require('exports-loader?Box2D!vendor/scripts/Box2dWeb-2.1.a.3')
Expand All @@ -32,6 +34,7 @@ module.exports = class PlayGameDevLevelView extends RootView
'god:new-world-created': 'onNewWorld'
'surface:ticked': 'onSurfaceTicked'
'god:streaming-world-updated': 'onStreamingWorldUpdated'
'bus:multiplayer-level-start': 'onMultiplayerLevelStart'

events:
'click #edit-level-btn': 'onEditLevelButton'
Expand Down Expand Up @@ -119,6 +122,8 @@ module.exports = class PlayGameDevLevelView extends RootView
else
$.i18n.t('play_game_dev_level.created_during_hoc')

@register() # TODO: where to put? Wait for it?

@state.set({
loading: false
goalNames
Expand All @@ -135,13 +140,19 @@ module.exports = class PlayGameDevLevelView extends RootView
}
window.tracker?.trackEvent 'Play GameDev Level - Load', @eventProperties, ['Mixpanel']
@insertSubView new GameDevTrackView {} if @level.isType('game-dev')
@insertSubView new ChatView bus: @bus
worldCreationOptions = {spells: @spells, preload: false, realTime: false, justBegin: true, keyValueDb: @session.get('keyValueDb') ? {}}
@god.createWorld(worldCreationOptions)

.catch (e) =>
throw e if e.stack
@state.set('errorMessage', e.message)

register: ->
@bus = GameDevLevelBus.get @session.id
@bus.setGameUIState @gameUIState
@bus.connect() # TODO: only do sometimes?

onEditLevelButton: ->
viewClass = 'views/play/level/PlayLevelView'
route = "/play/level/#{@level.get('slug')}"
Expand All @@ -152,7 +163,13 @@ module.exports = class PlayGameDevLevelView extends RootView
viewArgs: [{}, @levelID]
}

onClickPlayButton: ->
onClickPlayButton: (e) ->
@startLevel()

onMultiplayerLevelStart: (e) ->
@startLevel()

startLevel: ->
worldCreationOptions = {spells: @spells, preload: false, realTime: true, justBegin: false, keyValueDb: @session.get('keyValueDb') ? {}, synchronous: true}
@god.createWorld(worldCreationOptions)
Backbone.Mediator.publish('playback:real-time-playback-started', {})
Expand Down Expand Up @@ -183,6 +200,8 @@ module.exports = class PlayGameDevLevelView extends RootView
modal = new GameDevVictoryModal({ shareURL: @state.get('shareURL'), @eventProperties })
@openModalView(modal)
modal.once 'replay', @onClickPlayButton, @
if e.world.frames.length is e.world.totalFrames
Backbone.Mediator.publish('playback:real-time-playback-ended', {})

onSurfaceTicked: (e) ->
return if @studentGoals
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -88,6 +88,7 @@
"express-useragent": "^1.0.4",
"extract-text-webpack-plugin": "^3.0.1",
"fastclick": "~1.0.3",
"firebase": "^4.6.2",
"geoip-lite": "^1.1.6",
"graceful-fs": "~2.0.1",
"gridfs-stream": "~1.1.1",
Expand Down

0 comments on commit ebb829d

Please sign in to comment.