From 55815cb9ef8d5bae98f5310e61314d03aeda3b76 Mon Sep 17 00:00:00 2001 From: giongto35 Date: Sat, 14 Dec 2019 12:32:31 +0800 Subject: [PATCH] Add multiplayer player (#148) * Add player index * Add second player * Add player index slider * add multiplayer * WIP * Clean up * Add multiplayer play --- pkg/emulator/libretro/nanoarch/naemulator.go | 21 +++++--- pkg/emulator/libretro/nanoarch/nanoarch.go | 4 +- pkg/overlord/browser.go | 17 +++++++ pkg/overlord/handlers.go | 1 - pkg/webrtc/webrtc.go | 8 ++++ pkg/worker/handlers.go | 12 ++++- pkg/worker/overlord.go | 30 ++++++++++-- pkg/worker/room/room.go | 21 ++++---- web/css/main.css | 50 ++++++++++++++++++++ web/game.html | 5 ++ web/js/controller.js | 36 ++++++++++++-- web/js/event/event.js | 1 + web/js/input/keyboard.js | 4 ++ web/js/input/keys.js | 4 ++ web/js/input/touch.js | 15 ++++++ web/js/network/socket.js | 6 +++ 16 files changed, 210 insertions(+), 25 deletions(-) diff --git a/pkg/emulator/libretro/nanoarch/naemulator.go b/pkg/emulator/libretro/nanoarch/naemulator.go index ce470e18..da0185c3 100644 --- a/pkg/emulator/libretro/nanoarch/naemulator.go +++ b/pkg/emulator/libretro/nanoarch/naemulator.go @@ -50,7 +50,7 @@ import "C" type naEmulator struct { imageChannel chan<- *image.RGBA audioChannel chan<- []int16 - inputChannel <-chan int + inputChannel <-chan InputEvent meta config.EmulatorMeta gamePath string @@ -65,11 +65,16 @@ type naEmulator struct { lock *sync.Mutex } +type InputEvent struct { + KeyState int + PlayerIdx int +} + var NAEmulator *naEmulator var outputImg *image.RGBA // NAEmulator implements CloudEmulator interface based on NanoArch(golang RetroArch) -func NewNAEmulator(etype string, roomID string, inputChannel <-chan int) (*naEmulator, chan *image.RGBA, chan []int16) { +func NewNAEmulator(etype string, roomID string, inputChannel <-chan InputEvent) (*naEmulator, chan *image.RGBA, chan []int16) { meta := config.EmulatorConfig[etype] imageChannel := make(chan *image.RGBA, 30) audioChannel := make(chan []int16, 30) @@ -79,7 +84,7 @@ func NewNAEmulator(etype string, roomID string, inputChannel <-chan int) (*naEmu imageChannel: imageChannel, audioChannel: audioChannel, inputChannel: inputChannel, - keys: make([]bool, joypadNumKeys), + keys: make([]bool, joypadNumKeys*4), roomID: roomID, done: make(chan struct{}, 1), lock: &sync.Mutex{}, @@ -87,7 +92,7 @@ func NewNAEmulator(etype string, roomID string, inputChannel <-chan int) (*naEmu } // Init initialize new RetroArch cloud emulator -func Init(etype string, roomID string, inputChannel <-chan int) (*naEmulator, chan *image.RGBA, chan []int16) { +func Init(etype string, roomID string, inputChannel <-chan InputEvent) (*naEmulator, chan *image.RGBA, chan []int16) { emulator, imageChannel, audioChannel := NewNAEmulator(etype, roomID, inputChannel) // Set to global NAEmulator NAEmulator = emulator @@ -99,7 +104,9 @@ func Init(etype string, roomID string, inputChannel <-chan int) (*naEmulator, ch func (na *naEmulator) listenInput() { // input from javascript follows bitmap. Ex: 00110101 // we decode the bitmap and send to channel - for inpBitmap := range NAEmulator.inputChannel { + for inpEvent := range NAEmulator.inputChannel { + inpBitmap := inpEvent.KeyState + for k := 0; k < len(bindRetroKeys); k++ { key, ok := bindRetroKeys[k] if ok == false { @@ -107,9 +114,9 @@ func (na *naEmulator) listenInput() { } if (inpBitmap & 1) == 1 { - na.keys[key] = true + na.keys[key*4+inpEvent.PlayerIdx] = true } else { - na.keys[key] = false + na.keys[key*4+inpEvent.PlayerIdx] = false } inpBitmap >>= 1 } diff --git a/pkg/emulator/libretro/nanoarch/nanoarch.go b/pkg/emulator/libretro/nanoarch/nanoarch.go index dee25de8..ab6a9dcb 100644 --- a/pkg/emulator/libretro/nanoarch/nanoarch.go +++ b/pkg/emulator/libretro/nanoarch/nanoarch.go @@ -118,11 +118,11 @@ func coreInputPoll() { //export coreInputState func coreInputState(port C.unsigned, device C.unsigned, index C.unsigned, id C.unsigned) C.int16_t { - if port > 0 || index > 0 || device != C.RETRO_DEVICE_JOYPAD { + if id >= 255 || index > 0 || device != C.RETRO_DEVICE_JOYPAD { return 0 } - if id < 255 && NAEmulator.keys[id] { + if NAEmulator.keys[id*4+port] { return 1 } return 0 diff --git a/pkg/overlord/browser.go b/pkg/overlord/browser.go index ee670129..2af4a8de 100644 --- a/pkg/overlord/browser.go +++ b/pkg/overlord/browser.go @@ -123,6 +123,23 @@ func (s *Session) RouteBrowser() { return resp }) + + browserClient.Receive("playerIdx", func(resp cws.WSPacket) (req cws.WSPacket) { + log.Println("Overlord: Received update player index request from a browser") + log.Println("Overlord: Relay update player index request from a browser to worker") + // TODO: Async + resp.SessionID = s.ID + resp.RoomID = s.RoomID + wc, ok := s.handler.workerClients[s.ServerID] + if !ok { + return cws.EmptyPacket + } + resp = wc.SyncSend( + resp, + ) + + return resp + }) } // NewOverlordClient returns a client connecting to browser. This connection exchanges information between clients and server diff --git a/pkg/overlord/handlers.go b/pkg/overlord/handlers.go index baf46b2c..6fbdb0ea 100644 --- a/pkg/overlord/handlers.go +++ b/pkg/overlord/handlers.go @@ -139,7 +139,6 @@ func (o *Server) WS(w http.ResponseWriter, r *http.Request) { // zone param is to pick worker in that zone only // if there is no zone param, we can pic userZone := r.URL.Query().Get("zone") - log.Printf("Get Room %s Zone %s From URL %v", roomID, userZone, r.URL) if roomID != "" { diff --git a/pkg/webrtc/webrtc.go b/pkg/webrtc/webrtc.go index 472b8315..a71aa034 100644 --- a/pkg/webrtc/webrtc.go +++ b/pkg/webrtc/webrtc.go @@ -89,6 +89,14 @@ type WebRTC struct { curFPS int RoomID string + + // store thing related to game + GameMeta GameMeta +} + +// Game Meta +type GameMeta struct { + PlayerIndex int } // StartClient start webrtc diff --git a/pkg/worker/handlers.go b/pkg/worker/handlers.go index d8f3ac0c..982ddf12 100644 --- a/pkg/worker/handlers.go +++ b/pkg/worker/handlers.go @@ -136,6 +136,16 @@ func (h *Handler) getRoom(roomID string) *room.Room { return room } +// getRoom returns session from sessionID +func (h *Handler) getSession(sessionID string) *Session { + session, ok := h.sessions[sessionID] + if !ok { + return nil + } + + return session +} + // detachRoom detach room from Handler func (h *Handler) detachRoom(roomID string) { delete(h.rooms, roomID) @@ -143,7 +153,7 @@ func (h *Handler) detachRoom(roomID string) { // createNewRoom creates a new room // Return nil in case of room is existed -func (h *Handler) createNewRoom(gameName string, roomID string, playerIndex int, videoEncoderType string) *room.Room { +func (h *Handler) createNewRoom(gameName string, roomID string, videoEncoderType string) *room.Room { // If the roomID is empty, // or the roomID doesn't have any running sessions (room was closed) // we spawn a new room diff --git a/pkg/worker/overlord.go b/pkg/worker/overlord.go index e5ca3b23..f237d655 100644 --- a/pkg/worker/overlord.go +++ b/pkg/worker/overlord.go @@ -3,6 +3,7 @@ package worker import ( "encoding/json" "log" + "strconv" "github.com/giongto35/cloud-game/pkg/cws" "github.com/giongto35/cloud-game/pkg/util" @@ -175,6 +176,27 @@ func (h *Handler) RouteOverlord() { return req }) + oClient.Receive( + "playerIdx", + func(resp cws.WSPacket) (req cws.WSPacket) { + log.Println("Received an update player index event from overlord") + req.ID = "playerIdx" + + room := h.getRoom(resp.RoomID) + session := h.getSession(resp.SessionID) + idx, err := strconv.Atoi(resp.Data) + log.Printf("Got session %v and room %v", session, room) + + if room != nil && session != nil && err == nil { + room.UpdatePlayerIndex(session.peerconnection, idx) + req.Data = strconv.Itoa(idx) + } else { + req.Data = "error" + } + + return req + }) + oClient.Receive( "icecandidate", func(resp cws.WSPacket) (req cws.WSPacket) { @@ -224,8 +246,10 @@ func (h *Handler) startGameHandler(gameName, existedRoomID string, playerIndex i // If room is not running if room == nil { log.Println("Got Room from local ", room, " ID: ", existedRoomID) - // Create new room - room = h.createNewRoom(gameName, existedRoomID, playerIndex, videoEncoderType) + // Create new room and update player index + room = h.createNewRoom(gameName, existedRoomID, videoEncoderType) + room.UpdatePlayerIndex(peerconnection, playerIndex) + // Wait for done signal from room go func() { <-room.Done @@ -242,7 +266,7 @@ func (h *Handler) startGameHandler(gameName, existedRoomID string, playerIndex i log.Println("Is PC in room", room.IsPCInRoom(peerconnection)) if !room.IsPCInRoom(peerconnection) { h.detachPeerConn(peerconnection) - room.AddConnectionToRoom(peerconnection, playerIndex) + room.AddConnectionToRoom(peerconnection) } // Register room to overlord if we are connecting to overlord diff --git a/pkg/worker/room/room.go b/pkg/worker/room/room.go index cd52b56c..b7177bd1 100644 --- a/pkg/worker/room/room.go +++ b/pkg/worker/room/room.go @@ -32,8 +32,9 @@ type Room struct { imageChannel <-chan *image.RGBA // audioChannel is audio stream received from director audioChannel <-chan []int16 - // inputChannel is input stream from websocket send to room - inputChannel chan<- int + // inputChannel is input stream send to director. This inputChannel is combined + // input from webRTC + connection info (player indexc) + inputChannel chan<- nanoarch.InputEvent // State of room IsRunning bool // Done channel is to fire exit event when room is closed @@ -70,7 +71,7 @@ func NewRoom(roomID string, gameName string, videoEncoderType string, onlineStor gameInfo := gamelist.GetGameInfoFromName(gameName) log.Println("Init new room: ", roomID, gameName, gameInfo) - inputChannel := make(chan int, 100) + inputChannel := make(chan nanoarch.InputEvent, 100) room := &Room{ ID: roomID, @@ -185,14 +186,19 @@ func (r *Room) isGameOnLocal(savepath string) bool { return err == nil } -func (r *Room) AddConnectionToRoom(peerconnection *webrtc.WebRTC, playerIndex int) { +func (r *Room) AddConnectionToRoom(peerconnection *webrtc.WebRTC) { peerconnection.AttachRoomID(r.ID) r.rtcSessions = append(r.rtcSessions, peerconnection) - go r.startWebRTCSession(peerconnection, playerIndex) + go r.startWebRTCSession(peerconnection) } -func (r *Room) startWebRTCSession(peerconnection *webrtc.WebRTC, playerIndex int) { +func (r *Room) UpdatePlayerIndex(peerconnection *webrtc.WebRTC, playerIndex int) { + log.Println("Updated player Index to: ", playerIndex) + peerconnection.GameMeta.PlayerIndex = playerIndex +} + +func (r *Room) startWebRTCSession(peerconnection *webrtc.WebRTC) { defer func() { if r := recover(); r != nil { log.Println("Warn: Recovered when sent to close inputChannel") @@ -210,9 +216,8 @@ func (r *Room) startWebRTCSession(peerconnection *webrtc.WebRTC, playerIndex int // the first 10 bits belong to player 1 // the next 10 belongs to player 2 ... // We standardize and put it to inputChannel (20 bits) - input = input << ((uint(playerIndex) - 1) * config.NumKeys) select { - case r.inputChannel <- input: + case r.inputChannel <- nanoarch.InputEvent{KeyState: input, PlayerIdx: peerconnection.GameMeta.PlayerIndex}: default: } } diff --git a/web/css/main.css b/web/css/main.css index 95e8faea..bac2873f 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -535,6 +535,56 @@ body { font-size: 12px; } +#slider-playeridx { + display: block; + padding-top: 10px; + text-align: center; + + position: absolute; + top: 90px; + left: 20px; + width: 100px; +} + +/* The slider itself */ +.slider { + -webkit-appearance: none; /* Override default CSS styles */ + appearance: none; + width: 100%; /* Full-width */ + height: 25px; /* Specified height */ + background: #d3d3d3; /* Grey background */ + outline: none; /* Remove outline */ + opacity: 0.7; /* Set transparency (for mouse-over effects on hover) */ + -webkit-transition: .2s; /* 0.2 seconds transition on hover */ + transition: opacity .2s; + border-radius: 8px; +} + +/* Mouse-over effects */ +.slider:hover { + opacity: 1; /* Fully shown on mouse-over */ +} + +/* The slider handle (use -webkit- (Chrome, Opera, Safari, Edge) and -moz- (Firefox) to override default look) */ +.slider::-webkit-slider-thumb { + -webkit-appearance: none; /* Override default look */ + appearance: none; + width: 25px; /* Set a specific slider handle width */ + height: 25px; /* Slider handle height */ + background: #4CAF50; /* Green background */ + cursor: pointer; /* Cursor on hover */ + border-radius: 8px; +} + +.slider::-moz-range-thumb { + width: 25px; /* Set a specific slider handle width */ + height: 25px; /* Slider handle height */ + background: #4CAF50; /* Green background */ + cursor: pointer; /* Cursor on hover */ + border-radius: 8px; +} + * { touch-action: manipulation; } + diff --git a/web/game.html b/web/game.html index 3f890bac..baa19dbb 100644 --- a/web/game.html +++ b/web/game.html @@ -48,6 +48,11 @@
load
save
play
+
+ player index + +
+
quit
Select
Start
diff --git a/web/js/controller.js b/web/js/controller.js index 8022fddf..ddcd174b 100644 --- a/web/js/controller.js +++ b/web/js/controller.js @@ -121,6 +121,9 @@ return; } + //const el = document.createElement('textarea'); + const playeridx = parseInt($('#playeridx').val(), 10) - 1 + log.info('[control] starting game screen'); setState(app.state.game); @@ -142,7 +145,7 @@ // currently it's a game with the index 1 // on the server this game is ignored and the actual game will be extracted from the share link // so there's no point in doing this and this' really confusing - socket.startGame(gameList.getCurrentGame(), env.isMobileDevice(), room.getId(), 1); + socket.startGame(gameList.getCurrentGame(), env.isMobileDevice(), room.getId(), playeridx); // clear menu screen input.poll().disable(); @@ -183,6 +186,13 @@ state.keyRelease(data.key); }; + const updatePlayerIndex = (idx) => { + var slider = document.getElementById('playeridx'); + slider.value = idx + 1; + socket.updatePlayerIndex(idx); + }; + + const app = { state: { eden: { @@ -264,8 +274,10 @@ input.setKeyState(key, false); switch (key) { - // nani? why join / copy switch, it's confusing - case KEY.JOIN: + // nani? why join / copy switch, it's confusing. Me: It's because of the original design to update label only :-s. + case KEY.JOIN: // or SHARE + // save when click share + event.pub(KEY_PRESSED, {key: KEY.SAVE}) room.copyToClipboard(); popup('Copy link to clipboard!'); break; @@ -278,6 +290,22 @@ case KEY.FULL: env.display().toggleFullscreen(gameScreen.height() !== window.innerHeight, gameScreen[0]); break; + + // update player index + case KEY.PAD1: + updatePlayerIndex(0); + break; + case KEY.PAD2: + updatePlayerIndex(1); + break; + case KEY.PAD3: + updatePlayerIndex(2); + break; + case KEY.PAD4: + updatePlayerIndex(3); + break; + + // quit case KEY.QUIT: input.poll().disable(); @@ -302,6 +330,8 @@ event.sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2); event.sub(GAME_SAVED, () => popup('Saved')); event.sub(GAME_LOADED, () => popup('Loaded')); + event.sub(GAME_PLAYER_IDX, (idx) => popup(parseInt(idx)+1)); + event.sub(MEDIA_STREAM_INITIALIZED, (data) => { rtcp.start(data.stunturn); gameList.set(data.games); diff --git a/web/js/event/event.js b/web/js/event/event.js index 7e01f06a..58372e11 100644 --- a/web/js/event/event.js +++ b/web/js/event/event.js @@ -60,6 +60,7 @@ const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested'; const GAME_ROOM_AVAILABLE = 'gameRoomAvailable'; const GAME_SAVED = 'gameSaved'; const GAME_LOADED = 'gameLoaded'; +const GAME_PLAYER_IDX = 'gamePlayerIndex'; const CONNECTION_READY = 'connectionReady'; const CONNECTION_CLOSED = 'connectionClosed'; diff --git a/web/js/input/keyboard.js b/web/js/input/keyboard.js index d8d1a7bd..e3089360 100644 --- a/web/js/input/keyboard.js +++ b/web/js/input/keyboard.js @@ -21,6 +21,10 @@ const keyboard = (() => { 83: KEY.SAVE, // s 87: KEY.JOIN, // w 65: KEY.LOAD, // a + 49: KEY.PAD1, // 1 + 50: KEY.PAD2, // 2 + 51: KEY.PAD3, // 3 + 52: KEY.PAD4, // 4 70: KEY.FULL, // f 72: KEY.HELP, // h }; diff --git a/web/js/input/keys.js b/web/js/input/keys.js index 40898afa..442ea2bf 100644 --- a/web/js/input/keys.js +++ b/web/js/input/keys.js @@ -16,5 +16,9 @@ const KEY = (() => { DOWN: 'down', LEFT: 'left', RIGHT: 'right', + PAD1: 'pad1', + PAD2: 'pad2', + PAD3: 'pad3', + PAD4: 'pad4', } })(); diff --git a/web/js/input/touch.js b/web/js/input/touch.js index fb0d8e4c..fe17ca5f 100644 --- a/web/js/input/touch.js +++ b/web/js/input/touch.js @@ -20,6 +20,7 @@ const touch = (() => { const window_ = $(window); const buttons = $(".btn"); + const playerSlider = $("#playeridx") const dpad = $(".dpad"); function resetVpadState() { @@ -124,6 +125,15 @@ const touch = (() => { } + /* + Player index slider + */ + + function handlePlayerSlider() { + socket.updatePlayerIndex($(this).val() - 1); + } + + /* Touch menu */ @@ -226,6 +236,11 @@ const touch = (() => { vpadHolder.on('touchstart', handleVpadJoystickDown); vpadHolder.on('touchend', handleVpadJoystickUp); + // touch/mouse events for player slider. + playerSlider.on('oninput', handlePlayerSlider); + playerSlider.on('onchange', handlePlayerSlider); + playerSlider.on('mouseup', handlePlayerSlider); + // Bind events for menu // TODO change this flow event.pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown}); diff --git a/web/js/network/socket.js b/web/js/network/socket.js index 4121abfc..060f05ea 100644 --- a/web/js/network/socket.js +++ b/web/js/network/socket.js @@ -25,6 +25,7 @@ const socket = (() => { }; conn.onerror = error => log.error(`[ws] ${error}`); conn.onclose = () => log.info('[ws] closed'); + // Message received from server conn.onmessage = response => { const data = JSON.parse(response.data); const message = data.id; @@ -59,6 +60,9 @@ const socket = (() => { case 'load': event.pub(GAME_LOADED); break; + case 'playerIdx': + event.pub(GAME_PLAYER_IDX, data.data); + break; case 'checkLatency': curPacketId = data.packet_id; const addresses = data.data.split(','); @@ -77,6 +81,7 @@ const socket = (() => { }); const saveGame = () => send({"id": "save", "data": ""}); const loadGame = () => send({"id": "load", "data": ""}); + const updatePlayerIndex = (idx) => send({"id": "playerIdx", "data": idx.toString()}); const startGame = (gameName, isMobile, roomId, playerIndex) => send({ "id": "start", "data": JSON.stringify({ @@ -94,6 +99,7 @@ const socket = (() => { latency: latency, saveGame: saveGame, loadGame: loadGame, + updatePlayerIndex: updatePlayerIndex, startGame: startGame, quitGame: quitGame }