Add multiplayer player (#148)

* Add player index

* Add second player

* Add player index slider

* add multiplayer

* WIP

* Clean up

* Add multiplayer play
This commit is contained in:
giongto35 2019-12-14 12:32:31 +08:00 committed by GitHub
parent 76515a00ce
commit 55815cb9ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 210 additions and 25 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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 != "" {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:
}
}

50
web/css/main.css vendored
View file

@ -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;
}

5
web/game.html vendored
View file

@ -48,6 +48,11 @@
<div id="btn-load" unselectable="on" class="btn big unselectable" value="load">load</div>
<div id="btn-save" unselectable="on" class="btn big unselectable" value="save">save</div>
<div id="btn-join" unselectable="on" class="btn big unselectable" value="join">play</div>
<div id="slider-playeridx" class="slidecontainer">
player index
<input type="range" min="1" max="4" value="1" class="slider" id="playeridx" onkeydown="event.preventDefault()">
</div>
<div id="btn-quit" unselectable="on" class="btn big unselectable" value="quit">quit</div>
<div id="btn-select" unselectable="on" class="btn big unselectable" value="select">Select</div>
<div id="btn-start" unselectable="on" class="btn big unselectable" value="start">Start</div>

36
web/js/controller.js vendored
View file

@ -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);

View file

@ -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';

View file

@ -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
};

View file

@ -16,5 +16,9 @@ const KEY = (() => {
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
PAD1: 'pad1',
PAD2: 'pad2',
PAD3: 'pad3',
PAD4: 'pad4',
}
})();

15
web/js/input/touch.js vendored
View file

@ -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});

View file

@ -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
}