merge master

This commit is contained in:
trichimtrich 2019-04-23 03:36:31 +08:00
commit 79b0337ee8
20 changed files with 924 additions and 384 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* linguist-vendored
*.go linguist-vendored=false

3
.gitignore vendored
View file

@ -45,3 +45,6 @@ Network Trash Folder
Temporary Items
.apdisk
### Production
DockerfileProd
prod/

1
Dockerfile vendored
View file

@ -13,6 +13,7 @@ RUN apt-get update
RUN apt-get install pkg-config libvpx-dev libopus-dev libopusfile-dev -y
RUN go get github.com/pion/webrtc
RUN go get github.com/gorilla/websocket
RUN go get github.com/satori/go.uuid
RUN go install github.com/giongto35/cloud-game
EXPOSE 8000

10
README.md vendored
View file

@ -1,8 +1,8 @@
# Cloud Gaming Service Lite
Cloud Gaming Service is an open source Cloud Gaming Service building on [WebRTC](https://github.com/pion).
Cloud Gaming Service Lite is an open source Cloud Gaming Service building on [WebRTC](https://github.com/pion).
With cloud gaming, you can play any of your favourite NES game directly on your browser without installing it. It also brings modern online multiplayer gaming experience to classic NES game, so two people can play the game together.
With cloud gaming, you can play any of your favourite NES games directly on your browser without installing it. It also brings modern online multiplayer gaming experience to classic NES game, so two people can play the game together.
![screenshot](static/img/landing-page.png)
@ -34,13 +34,15 @@ US East: (Haven't hosted)
Europe: (Haven't hosted)
* [http://eu.nes.webgame2d.com](http://eu.nes.webgame2d.com)
* [http://eu.nes.playcloud.games](http://eu.nes.playcloud.games)
* [http://eu.nes.playcloud.games](http://eu.nes.playcloud.games)
Note: The current state of cloud gaming service lite are not optimized for production. The service will still experience lag in the case of heavy traffic. You can try hosting your own service following the instruction in the next session.
## Run on local
You can host the server yourself by running `./run_local.sh`. It will spawn a docker environment and you can access the emulator on `localhost:8000`.
You can open port, so other person can access your local machine and play the game together.
You can open port on your machine, so other person can access your local machine and play the game together.
## Development environment

10
config/config.go Normal file
View file

@ -0,0 +1,10 @@
package config
import "flag"
const defaultoverlord = "ws://localhost:9000/wso"
var IsDebug = flag.Bool("debug", false, "Is game running in debug mode?")
var OverlordHost = flag.String("overlordhost", defaultoverlord, "Specify the path for overlord. If the flag is `overlord`, the server will be run as overlord")
var Port = flag.String("port", "8000", "Port of the game")
var IsMonitor = flag.Bool("monitor", false, "Turn on monitor")

609
main.go
View file

@ -1,9 +1,8 @@
package main
import (
"os"
"encoding/json"
"fmt"
"flag"
"image"
"io/ioutil"
"log"
@ -11,80 +10,104 @@ import (
"net/http"
_ "net/http/pprof"
"strconv"
"strings"
"sync"
"time"
"github.com/giongto35/cloud-game/config"
"github.com/giongto35/cloud-game/ui"
"github.com/giongto35/cloud-game/util"
"github.com/giongto35/cloud-game/webrtc"
"github.com/gorilla/websocket"
pionRTC "github.com/pion/webrtc"
"gopkg.in/hraban/opus.v2"
uuid "github.com/satori/go.uuid"
)
const (
width = 256
height = 240
scale = 3
title = "NES"
width = 256
height = 240
scale = 3
title = "NES"
gameboyIndex = "./static/gameboy.html"
debugIndex = "./static/index_ws.html"
debugIndex = "./static/index_ws.html"
)
var indexFN = gameboyIndex
// Time allowed to write a message to the peer.
var readWait = 30 * time.Second
var writeWait = 30 * time.Second
var IsOverlord = false
var upgrader = websocket.Upgrader{}
type WSPacket struct {
ID string `json:"id"`
Data string `json:"data"`
RoomID string `json:"room_id"`
PlayerIndex int `json:"player_index"`
}
// Room is a game session. multi webRTC sessions can connect to a same game.
// A room stores all the channel for interaction between all webRTCs session and emulator
type Room struct {
imageChannel chan *image.RGBA
audioChanel chan float32
audioChannel chan float32
inputChannel chan int
// closedChannel is to fire exit event when there is no webRTC session running
closedChannel chan bool
// Done channel is to fire exit event when there is no webRTC session running
Done chan struct{}
rtcSessions []*webrtc.WebRTC
rtcSessions []*webrtc.WebRTC
sessionsLock *sync.Mutex
director *ui.Director
}
var rooms map[string]*Room
var rooms = map[string]*Room{}
// ID to peerconnection
var peerconnections = map[string]*webrtc.WebRTC{}
var serverID = ""
var oclient *Client
func main() {
fmt.Println("Usage: ./game [debug]")
if len(os.Args) > 1 {
flag.Parse()
log.Println("Usage: ./game [debug]")
if *config.IsDebug {
// debug
indexFN = debugIndex
fmt.Println("Use debug version")
log.Println("Use debug version")
}
if *config.OverlordHost == "overlord" {
log.Println("Running as overlord ")
IsOverlord = true
} else {
if strings.HasPrefix(*config.OverlordHost, "ws") && !strings.HasSuffix(*config.OverlordHost, "wso") {
log.Fatal("Overlord connection is invalid. Should have the form `ws://.../wso`")
}
log.Println("Running as slave ")
IsOverlord = false
}
rand.Seed(time.Now().UTC().UnixNano())
fmt.Println("http://localhost:8000")
rooms = map[string]*Room{}
// ignore origin
upgrader.CheckOrigin = func(r *http.Request) bool { return true }
http.HandleFunc("/ws", ws)
http.HandleFunc("/", getWeb)
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
http.ListenAndServe(":8000", nil)
}
http.HandleFunc("/ws", ws)
if !IsOverlord {
oclient = NewOverlordClient()
}
log.Println("oclient ", oclient)
if !IsOverlord {
log.Println("http://localhost:" + *config.Port)
http.ListenAndServe(":"+*config.Port, nil)
} else {
log.Println("http://localhost:9000")
// Overlord expose one more path for handle overlord connections
http.HandleFunc("/wso", wso)
http.ListenAndServe(":9000", nil)
}
}
func getWeb(w http.ResponseWriter, r *http.Request) {
bs, err := ioutil.ReadFile(indexFN)
@ -100,25 +123,26 @@ func initRoom(roomID, gameName string) string {
if roomID == "" {
roomID = generateRoomID()
}
log.Println("Init new room", roomID)
imageChannel := make(chan *image.RGBA, 100)
audioChannel := make(chan float32, 48000)
audioChannel := make(chan float32, ui.SampleRate)
inputChannel := make(chan int, 100)
closedChannel := make(chan bool)
// create director
director := ui.NewDirector(roomID, imageChannel, audioChannel, inputChannel, closedChannel)
rooms[roomID] = &Room{
imageChannel: imageChannel,
audioChanel: audioChannel,
inputChannel: inputChannel,
closedChannel: closedChannel,
rtcSessions: []*webrtc.WebRTC{},
director: director,
}
director := ui.NewDirector(roomID, imageChannel, audioChannel, inputChannel)
go fanoutScreen(imageChannel, roomID)
go fanoutAudio(audioChannel, roomID)
room := &Room{
imageChannel: imageChannel,
audioChannel: audioChannel,
inputChannel: inputChannel,
rtcSessions: []*webrtc.WebRTC{},
sessionsLock: &sync.Mutex{},
director: director,
Done: make(chan struct{}),
}
rooms[roomID] = room
go room.start()
go director.Start([]string{"games/" + gameName})
return roomID
@ -142,21 +166,35 @@ func isRoomRunning(roomID string) bool {
}
// startSession handles one session call
func startSession(webRTC *webrtc.WebRTC, gameName string, roomID string, playerIndex int) string {
func startSession(webRTC *webrtc.WebRTC, gameName string, roomID string, playerIndex int) (rRoomID string, isNewRoom bool) {
isNewRoom = false
cleanSession(webRTC)
// If the roomID is empty,
// or the roomID doesn't have any running sessions (room was closed)
// we spawn a new room
if roomID == "" || !isRoomRunning(roomID) {
roomID = initRoom(roomID, gameName)
isNewRoom = true
}
// TODO: Might have race condition
rooms[roomID].rtcSessions = append(rooms[roomID].rtcSessions, webRTC)
go faninInput(rooms[roomID].inputChannel, webRTC, playerIndex)
room := rooms[roomID]
return roomID
webRTC.AttachRoomID(roomID)
go startWebRTCSession(room, webRTC, playerIndex)
return roomID, isNewRoom
}
// Session represents a session connected from the browser to the current server
type Session struct {
client *Client
peerconnection *webrtc.WebRTC
ServerID string
}
// Handle normal traffic (from browser to host)
func ws(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
@ -164,223 +202,176 @@ func ws(w http.ResponseWriter, r *http.Request) {
return
}
defer c.Close()
log.Println("New ws connection")
webRTC := webrtc.NewWebRTC()
// streaming game
// start new games and webrtc stuff?
isDone := false
var gameName string
var roomID string
var playerIndex int
for !isDone {
c.SetReadDeadline(time.Now().Add(readWait))
mt, message, err := c.ReadMessage()
if err != nil {
log.Println("[!] read:", err)
break
}
req := WSPacket{}
res := WSPacket{}
err = json.Unmarshal(message, &req)
if err != nil {
log.Println("[!] json unmarshal:", err)
break
}
// SDP connection initializations follows WebRTC convention
// https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Protocols
switch req.ID {
case "ping":
gameName = req.Data
roomID = req.RoomID
playerIndex = req.PlayerIndex
log.Println("Ping from server with game:", gameName)
res.ID = "pong"
case "sdp":
log.Println("Received user SDP")
localSession, err := webRTC.StartClient(req.Data, width, height)
if err != nil {
log.Fatalln(err)
}
res.ID = "sdp"
res.Data = localSession
case "candidate":
// Unuse code
hi := pionRTC.ICECandidateInit{}
err = json.Unmarshal([]byte(req.Data), &hi)
if err != nil {
log.Println("[!] Cannot parse candidate: ", err)
} else {
// webRTC.AddCandidate(hi)
}
res.ID = "candidate"
case "start":
log.Println("Starting game")
roomID = startSession(webRTC, gameName, roomID, playerIndex)
res.ID = "start"
res.RoomID = roomID
// maybe we wont close websocket
// isDone = true
case "save":
log.Println("Saving game state")
res.ID = "save"
res.Data = "ok"
if roomID != "" {
err = rooms[roomID].director.SaveGame()
if err != nil {
log.Println("[!] Cannot save game state: ", err)
res.Data = "error"
}
} else {
res.Data = "error"
}
case "load":
log.Println("Loading game state")
res.ID = "load"
res.Data = "ok"
if roomID != "" {
err = rooms[roomID].director.LoadGame()
if err != nil {
log.Println("[!] Cannot load game state: ", err)
res.Data = "error"
}
} else {
res.Data = "error"
}
}
stRes, err := json.Marshal(res)
if err != nil {
log.Println("json marshal:", err)
}
c.SetWriteDeadline(time.Now().Add(writeWait))
err = c.WriteMessage(mt, []byte(stRes))
if err != nil {
log.Println("write:", err)
break
}
// Create connection to overlord
client := NewClient(c)
//sessionID := strconv.Itoa(rand.Int())
sessionID := uuid.Must(uuid.NewV4()).String()
wssession := &Session{
client: client,
peerconnection: webrtc.NewWebRTC(),
// The server session is maintaining
}
client.receive("heartbeat", func(resp WSPacket) WSPacket {
return resp
})
client.receive("initwebrtc", func(resp WSPacket) WSPacket {
log.Println("Received user SDP")
localSession, err := wssession.peerconnection.StartClient(resp.Data, width, height)
if err != nil {
log.Fatalln(err)
}
return WSPacket{
ID: "sdp",
Data: localSession,
SessionID: sessionID,
}
})
client.receive("save", func(resp WSPacket) (req WSPacket) {
log.Println("Saving game state")
req.ID = "save"
req.Data = "ok"
if roomID != "" {
err = rooms[roomID].director.SaveGame()
if err != nil {
log.Println("[!] Cannot save game state: ", err)
req.Data = "error"
}
} else {
req.Data = "error"
}
return req
})
client.receive("load", func(resp WSPacket) (req WSPacket) {
log.Println("Loading game state")
req.ID = "load"
req.Data = "ok"
if roomID != "" {
err = rooms[roomID].director.LoadGame()
if err != nil {
log.Println("[!] Cannot load game state: ", err)
req.Data = "error"
}
} else {
req.Data = "error"
}
return req
})
client.receive("start", func(resp WSPacket) (req WSPacket) {
gameName = resp.Data
roomID = resp.RoomID
playerIndex = resp.PlayerIndex
isNewRoom := false
log.Println("Starting game")
// If we are connecting to overlord, request serverID from roomID
if oclient != nil {
roomServerID := getServerIDOfRoom(oclient, roomID)
log.Println("Server of RoomID ", roomID, " is ", roomServerID)
if roomServerID != "" && wssession.ServerID != roomServerID {
// TODO: Re -register
go bridgeConnection(wssession, roomServerID, gameName, roomID, playerIndex)
return
}
}
roomID, isNewRoom = startSession(wssession.peerconnection, gameName, roomID, playerIndex)
// Register room to overlord if we are connecting to overlord
if isNewRoom && oclient != nil {
oclient.send(WSPacket{
ID: "registerRoom",
Data: roomID,
}, nil)
}
req.ID = "start"
req.RoomID = roomID
req.SessionID = sessionID
return req
})
client.receive("candidate", func(resp WSPacket) (req WSPacket) {
// Unuse code
hi := pionRTC.ICECandidateInit{}
err = json.Unmarshal([]byte(resp.Data), &hi)
if err != nil {
log.Println("[!] Cannot parse candidate: ", err)
} else {
// webRTC.AddCandidate(hi)
}
req.ID = "candidate"
return req
})
client.listen()
}
// generateRoomID generate a unique room ID containing 16 digits
func generateRoomID() string {
roomID := strconv.FormatInt(rand.Int63(), 16)
//roomID := uuid.Must(uuid.NewV4()).String()
return roomID
}
// fanoutScreen fanout outputs to all webrtc in the same room
func fanoutScreen(imageChannel chan *image.RGBA, roomID string) {
for image := range imageChannel {
isRoomRunning := false
yuv := util.RgbaToYuv(image)
for _, webRTC := range rooms[roomID].rtcSessions {
// Client stopped
if webRTC.IsClosed() {
continue
}
// encode frame
// fanout imageChannel
if webRTC.IsConnected() {
// NOTE: can block here
webRTC.ImageChannel <- yuv
}
isRoomRunning = true
}
if isRoomRunning == false {
log.Println("Closed room from screen routine", roomID)
rooms[roomID].closedChannel <- true
return
}
}
}
// fanoutAudio fanout outputs to all webrtc in the same room
func fanoutAudio(audioChannel chan float32, roomID string) {
log.Println("Enter fan audio")
enc, err := opus.NewEncoder(ui.SampleRate, ui.Channels, opus.AppAudio)
maxBufferSize := ui.TimeFrame * ui.SampleRate / 1000
pcm := make([]float32, maxBufferSize) // 640 * 1000 / 16000 == 40 ms
idx := 0
if err != nil {
log.Println("[!] Cannot create audio encoder")
return
}
var count byte = 0
func (r *Room) start() {
// fanout Screen
for {
pcm[idx] = <- audioChannel
idx ++
select {
case <-r.Done:
r.remove()
return
case image := <-r.imageChannel:
//isRoomRunning := false
if idx == len(pcm) {
data := make([]byte, 640)
n, err := enc.EncodeFloat32(pcm, data)
if err != nil {
log.Println("[!] Failed to decode")
continue
}
data = data[:n]
// data = append(data, count)
isRoomRunning := false
for _, webRTC := range rooms[roomID].rtcSessions {
yuv := util.RgbaToYuv(image)
r.sessionsLock.Lock()
for _, webRTC := range r.rtcSessions {
// Client stopped
if webRTC.IsClosed() {
continue
}
// encode frame
// fanout imageChannel
if webRTC.IsConnected() {
// NOTE: can block here
webRTC.AudioChannel <- data
webRTC.ImageChannel <- yuv
}
isRoomRunning = true
//isRoomRunning = true
}
if isRoomRunning == false {
log.Println("Closed room from audio routine", roomID)
rooms[roomID].closedChannel <- true
return
}
idx = 0
count = (count + 1) & 0xff
r.sessionsLock.Unlock()
}
}
}
func (r *Room) remove() {
log.Println("Closing room", r)
r.director.Done <- struct{}{}
}
// faninInput fan-in of the same room to inputChannel
func faninInput(inputChannel chan int, webRTC *webrtc.WebRTC, playerIndex int) {
// startWebRTCSession fan-in of the same room to inputChannel
func startWebRTCSession(room *Room, webRTC *webrtc.WebRTC, playerIndex int) {
inputChannel := room.inputChannel
log.Println("room, inputChannel", room, inputChannel)
for {
select {
case <-webRTC.Done:
removeSession(webRTC, room)
default:
}
// Client stopped
if webRTC.IsClosed() {
return
@ -397,3 +388,157 @@ func faninInput(inputChannel chan int, webRTC *webrtc.WebRTC, playerIndex int) {
}
}
}
func cleanSession(w *webrtc.WebRTC) {
room, ok := rooms[w.RoomID]
if !ok {
return
}
removeSession(w, room)
}
func removeSession(w *webrtc.WebRTC, room *Room) {
room.sessionsLock.Lock()
defer room.sessionsLock.Unlock()
for i, s := range room.rtcSessions {
if s == w {
room.rtcSessions = append(room.rtcSessions[:i], room.rtcSessions[i+1:]...)
break
}
}
// If room has no sessions, close room
if len(room.rtcSessions) == 0 {
room.Done <- struct{}{}
}
}
func getServerIDOfRoom(oc *Client, roomID string) string {
log.Println("Request overlord roomID")
packet := oc.syncSend(
WSPacket{
ID: "getRoom",
Data: roomID,
},
)
log.Println("Received roomID from overlord")
return packet.Data
}
func bridgeConnection(session *Session, serverID string, gameName string, roomID string, playerIndex int) {
log.Println("Bridging connection to other Host ", serverID)
client := session.client
// Ask client to init
log.Println("Requesting offer to browser", serverID)
resp := client.syncSend(WSPacket{
ID: "requestOffer",
Data: "",
})
log.Println("Sending offer to overlord to relay message to target host", resp.TargetHostID)
// Ask overlord to relay SDP packet to serverID
resp.TargetHostID = serverID
remoteTargetSDP := oclient.syncSend(resp)
log.Println("Got back remote host SDP, sending to browser")
// Send back remote SDP of remote server to browser
//client.syncSend(WSPacket{
//ID: "sdp",
//Data: remoteTargetSDP.Data,
//})
client.send(WSPacket{
ID: "sdp",
Data: remoteTargetSDP.Data,
}, nil)
log.Println("Init session done, start game on target host")
oclient.syncSend(WSPacket{
ID: "start",
Data: gameName,
TargetHostID: serverID,
RoomID: roomID,
PlayerIndex: playerIndex,
})
log.Println("Game is started on remote host")
}
func createOverlordConnection() (*websocket.Conn, error) {
c, _, err := websocket.DefaultDialer.Dial(*config.OverlordHost, nil)
if err != nil {
return nil, err
}
return c, nil
}
func NewOverlordClient() *Client {
oc, err := createOverlordConnection()
if err != nil {
log.Println("Cannot connect to overlord")
log.Println("Run as a single server")
return nil
}
oclient := NewClient(oc)
// Received from overlord the serverID
oclient.receive(
"serverID",
func(response WSPacket) (request WSPacket) {
// Stick session with serverID got from overlord
log.Println("Received serverID ", response.Data)
serverID = response.Data
return EmptyPacket
},
)
// Received from overlord the sdp. This is happens when bridging
// TODO: refactor
oclient.receive(
"initwebrtc",
func(resp WSPacket) (req WSPacket) {
log.Println("Received a sdp request from overlord")
log.Println("Start peerconnection from the sdp")
peerconnection := webrtc.NewWebRTC()
// init new peerconnection from sessionID
localSession, err := peerconnection.StartClient(resp.Data, width, height)
peerconnections[resp.SessionID] = peerconnection
if err != nil {
log.Fatalln(err)
}
return WSPacket{
ID: "sdp",
Data: localSession,
}
},
)
// Received start from overlord. This is happens when bridging
// TODO: refactor
oclient.receive(
"start",
func(resp WSPacket) (req WSPacket) {
log.Println("Received a start request from overlord")
log.Println("Add the connection to current room on the host")
peerconnection := peerconnections[resp.SessionID]
roomID, isNewRoom := startSession(peerconnection, resp.Data, resp.RoomID, resp.PlayerIndex)
// Bridge always access to old room
// TODO: log warn
if isNewRoom == true {
log.Fatal("Bridge should not spawn new room")
}
req.ID = "start"
req.RoomID = roomID
return req
},
)
// heartbeat to keep pinging overlord. We not ping from server to browser, so we don't call heartbeat in browserClient
go oclient.heartbeat()
go oclient.listen()
return oclient
}

123
overlord.go Normal file
View file

@ -0,0 +1,123 @@
package main
import (
"fmt"
"log"
"math/rand"
"net/http"
"strconv"
"github.com/giongto35/cloud-game/webrtc"
)
var roomToServer = map[string]string{}
// servers are the map serverID to server Client
var servers = map[string]*Client{}
// If it's overlord, handle overlord connection (from host to overlord)
func wso(w http.ResponseWriter, r *http.Request) {
fmt.Println("Connected")
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Print("[!] WS upgrade:", err)
return
}
defer c.Close()
// register new server
serverID := strconv.Itoa(rand.Int())
log.Println("A new server connected ", serverID)
client := NewClient(c)
servers[serverID] = client
wssession := &Session{
client: client,
peerconnection: webrtc.NewWebRTC(),
// The server session is maintaining
}
client.send(
WSPacket{
ID: "serverID",
Data: serverID,
},
nil,
)
client.receive("registerRoom", func(resp WSPacket) WSPacket {
log.Println("Received registerRoom ", resp.Data, serverID)
roomToServer[resp.Data] = serverID
return WSPacket{
ID: "registerRoom",
}
})
client.receive("getRoom", func(resp WSPacket) WSPacket {
log.Println("Received a getroom request")
return WSPacket{
ID: "getRoom",
Data: roomToServer[resp.Data],
}
})
client.receive("initwebrtc", func(resp WSPacket) WSPacket {
log.Println("Received a relay sdp request from a host")
// TODO: Abstract
if resp.TargetHostID != serverID {
log.Println("sending relay sdp to target host", resp)
// relay SDP to target host and get back sdp
// TODO: Async
sdp := servers[resp.TargetHostID].syncSend(
resp,
)
return sdp
}
log.Println("Target host is overlord itself: start peerconnection")
// If the target is in master
// start by its old
localSession, err := wssession.peerconnection.StartClient(resp.Data, width, height)
if err != nil {
log.Fatalln(err)
}
return WSPacket{
ID: "sdp",
Data: localSession,
}
})
// TODO: use relay ID type
// TODO: Merge sdp and start
client.receive("start", func(resp WSPacket) WSPacket {
log.Println("Received a relay start request from a host")
// TODO: Abstract
if resp.TargetHostID != serverID {
// relay SDP to target host and get back sdp
// TODO: Async
resp := servers[resp.TargetHostID].syncSend(
resp,
)
return resp
}
log.Println("Target host is overlord itself: start game")
// If the target is in master
// start by its old
roomID, isNewRoom := startSession(wssession.peerconnection, resp.Data, resp.RoomID, resp.PlayerIndex)
// Bridge always access to old room
// TODO: log warn
if isNewRoom == true {
log.Fatal("Bridge should not spawn new room")
}
return WSPacket{
ID: "start",
RoomID: roomID,
}
})
client.listen()
}

1
overlord/overlord.go Normal file
View file

@ -0,0 +1 @@

2
run_local.sh vendored
View file

@ -2,4 +2,4 @@
docker build . -t cloud-game-local
docker stop cloud-game-local
docker rm cloud-game-local
docker run --privileged -d --name cloud-game-local -p 8000:8000 cloud-game
docker run --privileged -d --name cloud-game-local -p 8000:8000 cloud-game-local cloud-game

9
static/gameboy.html vendored
View file

@ -37,7 +37,8 @@
<div id="glass-gameboy-text">GAME BOY</div>
<div id="glass-color-text">C</div>
<div id="screen">
<video id="game-screen" autoplay=true poster="https://orig00.deviantart.net/cdcd/f/2017/276/a/a/october_2nd___gameboy_poltergeist_by_wanyo-dbpdmnd.gif">
<!--<video id="game-screen" autoplay=true poster="https://orig00.deviantart.net/cdcd/f/2017/276/a/a/october_2nd___gameboy_poltergeist_by_wanyo-dbpdmnd.gif">-->
<video id="game-screen" autoplay=true>
</video>
<div id="menu-screen">
@ -88,9 +89,9 @@
<br />
Game: <br />
Use Up, Down, Left, Right to Move <br />
Z to jump (A) <br />
X to sprint (B) <br />
C is start (in game) <br />
Z (A butotn) <br />
X (B button) <br />
C is start (or pause in some games) <br />
V is select game <br />
Q is super quit <br />
S to save <br />

View file

@ -13,7 +13,7 @@ textarea {
<button id="play" onclick="window.startGame()">Play</button>
<button id="play" onclick="pc.close()">Stop</button>
<button id="play" onclick="window.hihi()">Magic</button><br/><br/>
Your current room: <b><label id="currentRoomID" style="color:white"></b> <br />
Your current room: <b><label id="currentRoomID" style="color:blue"></b> <br />
You can join a remote game by roomID.<br />
Room ID: <input type="text" id="roomID">
Play as player(1,2): <select id="playerIndex">
@ -35,8 +35,8 @@ Play as player(1,2): <select id="playerIndex">
Player 1
Use Up, Down, Left, Right to Move <br />
Z to jump (A) <br />
X to sprint (B) <br />
Z to (A) <br />
X to (B) <br />
S to save <br />
L to load <br />
<!--Fullscreen media for better gaming experience<br /-->

1
static/js/const.js vendored
View file

@ -99,4 +99,5 @@ KEY_BIT = ["a", "b", "select", "start", "up", "down", "left", "right"];
INPUT_FPS = 100;
PINGPONGPS = 5;
INPUT_STATE_PACKET = 5;

View file

@ -3,23 +3,17 @@
function showMenuScreen() {
log("Clean up connection / frame");
// clean up before / after menu
try {
inputChannel.close();
} catch (err) {
log(`> [Warning] input channel: ${err}`);
}
//try {
//inputChannel.gameboyIndeoclose();
//} catch (err) {
//log(`> [Warning] peer connection: ${err}`);
//}
try {
pc.close();
} catch (err) {
log(`> [Warning] peer connection: ${err}`);
}
try {
conn.close();
} catch (err) {
log(`> [Warning] Websocket connection: ${err}`);
}
//try {
//conn.close();
//} catch (err) {
//log(`> [Warning] Websocket connection: ${err}`);
//}
$("#game-screen").hide();
if (!DEBUG) {
@ -69,7 +63,6 @@ function chooseGame(idx, force=false) {
function setState(e, bo) {
if (e.keyCode in KEY_MAP) {
keyState[KEY_MAP[e.keyCode]] = bo;
stateUnchange = false;
unchangePacket = INPUT_STATE_PACKET;
}
}
@ -90,7 +83,7 @@ document.body.onkeyup = function (e) {
}
} else if (screenState === "game") {
setState(e, false);
switch (KEY_MAP[e.keyCode]) {
case "save":
conn.send(JSON.stringify({"id": "save", "data": ""}));
@ -155,7 +148,7 @@ document.body.onkeydown = function (e) {
function sendInput() {
// prepare key
if (stateUnchange || unchangePacket > 0) {
if (unchangePacket > 0) {
st = "";
KEY_BIT.slice().reverse().forEach(elem => {
st += keyState[elem] ? 1 : 0;
@ -168,7 +161,6 @@ function sendInput() {
a[0] = ss;
inputChannel.send(a);
stateUnchange = false;
unchangePacket--;
}
}

3
static/js/global.js vendored
View file

@ -32,7 +32,6 @@ keyState = {
quit: false
}
stateUnchange = true;
unchangePacket = INPUT_STATE_PACKET;
inputTimer = null;
@ -53,4 +52,4 @@ function log(msg) {
document.getElementById('div').innerHTML += msg + '<br>'
console.log(msg);
}
}
}

168
static/js/ws.js vendored
View file

@ -1,69 +1,80 @@
var pc;
var curPacketID = "";
var curSessionID = "";
// web socket
function startGame() {
log("Starting game screen");
conn = new WebSocket(`ws://${location.host}/ws`);
// clear
endInput();
document.getElementById('div').innerHTML = "";
if (!DEBUG) {
$("#menu-screen").fadeOut(400, function() {
$("#game-screen").show();
});
// Clear old roomID
conn.onopen = () => {
log("WebSocket is opened. Send ping");
log("Send ping pong frequently")
// pingpongTimer = setInterval(sendPing, 1000 / PINGPONGPS)
startWebRTC();
}
conn.onerror = error => {
log(`Websocket error: ${error}`);
}
conn.onclose = () => {
log("Websocket closed");
}
conn.onmessage = e => {
d = JSON.parse(e.data);
switch (d["id"]) {
case "sdp":
log("Got remote sdp");
pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(atob(d["data"]))));
//conn.send(JSON.stringify({"id": "sdpdon", "packet_id": d["packet_id"]}));
break;
case "requestOffer":
curPacketID = d["packet_id"];
log("Received request offer ", curPacketID)
startWebRTC();
//pc.createOffer({offerToReceiveVideo: true, offerToReceiveAudio: false}).then(d => {
//pc.setLocalDescription(d).catch(log);
//})
//case "sdpremote":
//log("Got remote sdp");
//pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(atob(d["data"]))));
//conn.send(JSON.stringify({"id": "remotestart", "data": GAME_LIST[gameIdx].nes, "room_id": roomID.value, "player_index": parseInt(playerIndex.value, 10)}));inputTimer
//break;
case "heartbeat":
console.log("Ping: ", Date.now() - d["data"])
// TODO: Calc time
break;
case "start":
log("Got start");
roomID.value = ""
currentRoomID.innerText = d["room_id"]
break;
case "save":
log(`Got save response: ${d["data"]}`);
break;
case "load":
log(`Got load response: ${d["data"]}`);
break;
}
// end clear
}
conn = new WebSocket(`ws://${location.host}/ws`);
// Clear old roomID
conn.onopen = () => {
log("WebSocket is opened. Send ping");
conn.send(JSON.stringify({"id": "ping", "data": GAME_LIST[gameIdx].nes, "room_id": roomID.value, "player_index": parseInt(playerIndex.value, 10)}));
}
conn.onerror = error => {
log(`Websocket error: ${error}`);
}
conn.onclose = () => {
log("Websocket closed");
}
conn.onmessage = e => {
d = JSON.parse(e.data);
switch (d["id"]) {
case "pong":
log("Recv pong. Start webrtc");
startWebRTC();
break;
case "sdp":
log("Got remote sdp");
pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(atob(d["data"]))));
break;
case "start":
log("Got start");
roomID.value = ""
currentRoomID.innerText = d["room_id"]
break;
case "save":
log(`Got save response: ${d["data"]}`);
break;
case "load":
log(`Got load response: ${d["data"]}`);
break;
}
}
function sendPing() {
// TODO: format the package with time
conn.send(JSON.stringify({"id": "heartbeat", "data": Date.now().toString()}));
}
function startWebRTC() {
// webrtc
pc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.l.google.com:19302'}]})
// input channel
inputChannel = pc.createDataChannel('foo')
// input channel, ordered + reliable
inputChannel = pc.createDataChannel('a', {
ordered: true,
});
inputChannel.onclose = () => log('inputChannel has closed');
inputChannel.onopen = () => log('inputChannel has opened');
inputChannel.onmessage = e => {
console.log(e);
log(`Message '${e.data}'`);
}
window.AudioContext = window.AudioContext || window.webkitAudioContext;
var context = new AudioContext();
@ -123,14 +134,11 @@ function startGame() {
}
}
pc.oniceconnectionstatechange = e => {
log(`iceConnectionState: ${pc.iceConnectionState}`);
if (pc.iceConnectionState === "connected") {
conn.send(JSON.stringify({"id": "start", "data": ""}));
startInput();
screenState = "game";
//conn.send(JSON.stringify({"id": "start", "data": ""}));
}
else if (pc.iceConnectionState === "disconnected") {
endInput();
@ -165,20 +173,38 @@ function startGame() {
session = btoa(JSON.stringify(pc.localDescription));
localSessionDescription = session;
log("Send SDP to remote peer");
conn.send(JSON.stringify({"id": "sdp", "data": session}));
// TODO: Fix curPacketID
conn.send(JSON.stringify({"id": "initwebrtc", "data": session, "packet_id": curPacketID}));
} else {
console.log(JSON.stringify(event.candidate));
}
}
function startWebRTC() {
// receiver only tracks
pc.addTransceiver('video', {'direction': 'recvonly'});
pc.addTransceiver('audio', {'direction': 'recvonly'});
// receiver only tracks
pc.addTransceiver('video', {'direction': 'recvonly'});
// create SDP
pc.createOffer({offerToReceiveVideo: true, offerToReceiveAudio: false}).then(d => {
pc.setLocalDescription(d).catch(log);
})
// create SDP
pc.createOffer({offerToReceiveVideo: true, offerToReceiveAudio: true}).then(d => {
pc.setLocalDescription(d).catch(log);
})
}
}
function startGame() {
log("Starting game screen");
screenState = "game";
conn.send(JSON.stringify({"id": "start", "data": GAME_LIST[gameIdx].nes, "room_id": roomID.value, "player_index": parseInt(playerIndex.value, 10)}));
// clear menu screen
endInput();
document.getElementById('div').innerHTML = "";
if (!DEBUG) {
$("#menu-screen").fadeOut(400, function() {
$("#game-screen").show();
});
}
// end clear
// startInput();
}

View file

@ -10,24 +10,26 @@ import (
)
type Director struct {
view *GameView
timestamp float64
imageChannel chan *image.RGBA
audioChanel chan float32
inputChannel chan int
closedChannel chan bool
roomID string
hash string
// audio *Audio
view *GameView
timestamp float64
imageChannel chan *image.RGBA
audioChannel chan float32
inputChannel chan int
Done chan struct{}
roomID string
hash string
}
const FPS = 60
func NewDirector(roomID string, imageChannel chan *image.RGBA, audioChanel chan float32, inputChannel chan int, closedChannel chan bool) *Director {
func NewDirector(roomID string, imageChannel chan *image.RGBA, audioChannel chan float32, inputChannel chan int) *Director {
director := Director{}
director.Done = make(chan struct{})
director.audioChannel = audioChannel
director.imageChannel = imageChannel
director.audioChanel = audioChanel
director.inputChannel = inputChannel
director.closedChannel = closedChannel
director.roomID = roomID
director.hash = ""
return &director
@ -44,6 +46,10 @@ func (d *Director) SetView(view *GameView) {
d.timestamp = float64(time.Now().Nanosecond()) / float64(time.Second)
}
func (d *Director) UpdateInput(input int) {
d.view.UpdateInput(input)
}
func (d *Director) Step() {
timestamp := float64(time.Now().Nanosecond()) / float64(time.Second)
dt := timestamp - d.timestamp
@ -76,7 +82,9 @@ L:
select {
// if there is event from close channel => the game is ended
case <-d.closedChannel:
case input := <-d.inputChannel:
d.UpdateInput(input)
case <-d.Done:
break L
default:
}
@ -98,12 +106,13 @@ func (d *Director) PlayGame(path string) {
log.Fatalln(err)
}
// Set GameView as current view
d.SetView(NewGameView(console, path, hash, d.imageChannel, d.audioChanel, d.inputChannel))
d.SetView(NewGameView(console, path, hash, d.imageChannel, d.audioChannel, d.inputChannel))
}
func (d *Director) SaveGame() error {
if d.hash != "" {
return d.view.console.SaveState(savePath(d.hash))
d.view.Save(d.hash)
return nil
} else {
return nil
}
@ -111,8 +120,9 @@ func (d *Director) SaveGame() error {
func (d *Director) LoadGame() error {
if d.hash != "" {
return d.view.console.LoadState(savePath(d.hash))
d.view.Load(d.hash)
return nil
} else {
return nil
}
}
}

View file

@ -3,6 +3,7 @@ package ui
import (
"image"
"github.com/giongto35/cloud-game/nes"
)
@ -27,55 +28,60 @@ const (
right2
)
const NumKeys = 8
const SampleRate = 16000
const Channels = 1
const TimeFrame = 60
// Audio consts
const (
SampleRate = 16000
Channels = 1
TimeFrame = 60
)
type GameView struct {
console *nes.Console
title string
hash string
console *nes.Console
title string
hash string
// equivalent to the list key pressed const above
keyPressed [NumKeys * 2]bool
savingPath string
loadingPath string
imageChannel chan *image.RGBA
audioChanel chan float32
audioChannel chan float32
inputChannel chan int
}
func NewGameView(console *nes.Console, title, hash string, imageChannel chan *image.RGBA, audioChanel chan float32, inputChannel chan int) *GameView {
func NewGameView(console *nes.Console, title, hash string, imageChannel chan *image.RGBA, audioChannel chan float32, inputChannel chan int) *GameView {
gameview := &GameView{
console: console,
title: title,
hash: hash,
keyPressed: [NumKeys * 2]bool{false},
imageChannel: imageChannel,
audioChanel: audioChanel,
audioChannel: audioChannel,
inputChannel: inputChannel,
}
go gameview.ListenToInputChannel()
return gameview
}
// ListenToInputChannel listen from input channel streamm, which is exposed to WebRTC session
func (view *GameView) ListenToInputChannel() {
for {
keysInBinary := <-view.inputChannel
for i := 0; i < NumKeys*2; i++ {
b := ((keysInBinary & 1) == 1)
view.keyPressed[i] = (view.keyPressed[i] && b) || b
keysInBinary = keysInBinary >> 1
}
func (view *GameView) UpdateInput(keysInBinary int) {
for i := 0; i < NumKeys*2; i++ {
b := ((keysInBinary & 1) == 1)
view.keyPressed[i] = (view.keyPressed[i] && b) || b
keysInBinary = keysInBinary >> 1
}
}
// Enter enter the game view.
func (view *GameView) Enter() {
view.console.SetAudioSampleRate(SampleRate)
view.console.SetAudioChannel(view.audioChanel)
view.console.SetAudioChannel(view.audioChannel)
// load state
if err := view.console.LoadState(savePath(view.hash)); err == nil {
@ -112,12 +118,36 @@ func (view *GameView) Update(t, dt float64) {
}
console := view.console
view.updateControllers()
view.UpdateEvents()
console.StepSeconds(dt)
// fps to set frame
view.imageChannel <- console.Buffer()
}
func (view *GameView) Save(hash string) {
// put saving event to queue, process in updateEvent
view.savingPath = savePath(view.hash)
}
func (view *GameView) Load(path string) {
// put saving event to queue, process in updateEvent
view.loadingPath = savePath(view.hash)
}
func (view *GameView) UpdateEvents() {
// If there is saving event, save and discard the save event
if view.savingPath != "" {
view.console.SaveState(view.savingPath)
view.savingPath = ""
}
// If there is loading event, save and discard the load event
if view.loadingPath != "" {
view.console.LoadState(view.loadingPath)
view.loadingPath = ""
}
}
func (view *GameView) updateControllers() {
// Divide keyPressed to player 1 and player 2
// First 8 keys are player 1

View file

@ -2,7 +2,11 @@ package vpxencoder
import (
"fmt"
"log"
"time"
"unsafe"
"github.com/giongto35/cloud-game/config"
)
// https://chromium.googlesource.com/webm/libvpx/+/master/examples/simple_encoder.c
@ -113,6 +117,8 @@ func (v *VpxEncoder) init() error {
func (v *VpxEncoder) startLooping() {
go func() {
for {
beginEncoding := time.Now()
yuv := <-v.Input
// Add Image
v.vpxCodexIter = nil
@ -140,6 +146,10 @@ func (v *VpxEncoder) startLooping() {
}
v.Output <- bs
}
if *config.IsMonitor {
log.Println("Encoding time: ", time.Now().Sub(beginEncoding))
}
}
}()
}

View file

@ -6,17 +6,19 @@ import (
"compress/gzip"
"encoding/base64"
"encoding/json"
"log"
"fmt"
"io/ioutil"
"log"
"math/rand"
"time"
"github.com/giongto35/cloud-game/config"
vpxEncoder "github.com/giongto35/cloud-game/vpx-encoder"
"github.com/pion/webrtc"
"github.com/pion/webrtc/pkg/media"
)
var config = webrtc.Configuration{ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}}
var webrtcconfig = webrtc.Configuration{ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}}
// Allows compressing offer/answer to bypass terminal input limits.
const compress = false
@ -99,6 +101,11 @@ func NewWebRTC() *WebRTC {
return w
}
type InputDataPair struct {
data int
time time.Time
}
// WebRTC connection
type WebRTC struct {
connection *webrtc.PeerConnection
@ -109,6 +116,12 @@ type WebRTC struct {
ImageChannel chan []byte
AudioChannel chan []byte
InputChannel chan int
Done chan struct{}
lastTime time.Time
curFPS int
RoomID string
}
// StartClient start webrtc
@ -134,11 +147,7 @@ func (w *WebRTC) StartClient(remoteSession string, width, height int) (string, e
log.Println("=== StartClient ===")
webrtc.NewRTPOpusCodec(webrtc.DefaultPayloadTypeOpus, 48000)
webrtc.NewRTPVP8Codec(webrtc.DefaultPayloadTypeVP8, 90000)
w.connection, err = webrtc.NewPeerConnection(config)
w.connection, err = webrtc.NewPeerConnection(webrtcconfig)
if err != nil {
return "", err
}
@ -152,10 +161,13 @@ func (w *WebRTC) StartClient(remoteSession string, width, height int) (string, e
return "", err
}
// dd := false
// audioTrack, err := w.connection.CreateDataChannel("foo2", &webrtc.DataChannelInit{Ordered: &dd})
audioTrack, err := w.connection.CreateDataChannel("foo2", nil)
// audio track
dfalse := false
var d0 uint16 = 0
audioTrack, err := w.connection.CreateDataChannel("b", &webrtc.DataChannelInit{
Ordered: &dfalse,
MaxRetransmits: &d0,
})
// WebRTC state callback
w.connection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
@ -190,11 +202,18 @@ func (w *WebRTC) StartClient(remoteSession string, width, height int) (string, e
// Register text message handling
d.OnMessage(func(msg webrtc.DataChannelMessage) {
//layout .:= "2006-01-02T15:04:05.000Z"
//if t, err := time.Parse(layout, string(msg.Data[1])); err == nil {
//fmt.Println("Delay ", time.Now().Sub(t))
//} else {
w.InputChannel <- int(msg.Data[0])
//}
})
d.OnClose(func() {
w.isClosed = true
fmt.Println("closed webrtc")
w.Done <- struct{}{}
close(w.Done)
})
})
@ -216,6 +235,10 @@ func (w *WebRTC) StartClient(remoteSession string, width, height int) (string, e
return localSession, nil
}
func (w *WebRTC) AttachRoomID(roomID string) {
w.RoomID = roomID
}
// TODO: Take a look at this
func (w *WebRTC) AddCandidate(candidate webrtc.ICECandidateInit) {
err := w.connection.AddICECandidate(candidate)
@ -264,6 +287,9 @@ func (w *WebRTC) startStreaming(vp8Track *webrtc.Track, audioTrack *webrtc.DataC
go func() {
for w.isConnected {
bs := <-w.encoder.Output
if *config.IsMonitor {
log.Println("FPS : ", w.calculateFPS())
}
vp8Track.WriteSample(media.Sample{Data: bs, Samples: 1})
}
}()
@ -279,3 +305,11 @@ func (w *WebRTC) startStreaming(vp8Track *webrtc.Track, audioTrack *webrtc.DataC
}()
}
func (w *WebRTC) calculateFPS() int {
elapsedTime := time.Now().Sub(w.lastTime)
w.lastTime = time.Now()
curFPS := time.Second / elapsedTime
w.curFPS = int(float32(w.curFPS)*0.9 + float32(curFPS)*0.1)
return w.curFPS
}

150
ws.go Normal file
View file

@ -0,0 +1,150 @@
package main
import (
"encoding/json"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
uuid "github.com/satori/go.uuid"
)
type Client struct {
conn *websocket.Conn
sendLock sync.Mutex
// sendCallback is callback based on packetID
sendCallback map[string]func(req WSPacket)
sendCallbackLock sync.Mutex
// recvCallback is callback when receive based on ID of the packet
recvCallback map[string]func(req WSPacket)
}
type WSPacket struct {
ID string `json:"id"`
Data string `json:"data"`
RoomID string `json:"room_id"`
PlayerIndex int `json:"player_index"`
TargetHostID string `json:"target_id"`
PacketID string `json:"packet_id"`
// Globally ID of a session
SessionID string `json:"session_id"`
}
var EmptyPacket = WSPacket{}
func NewClient(conn *websocket.Conn) *Client {
sendCallback := map[string]func(WSPacket){}
recvCallback := map[string]func(WSPacket){}
return &Client{
conn: conn,
sendCallback: sendCallback,
recvCallback: recvCallback,
}
}
// send sends a packet and trigger callback when the packet comes back
func (c *Client) send(request WSPacket, callback func(response WSPacket)) {
request.PacketID = uuid.Must(uuid.NewV4()).String()
data, err := json.Marshal(request)
if err != nil {
return
}
// TODO: Consider using lock free
c.sendLock.Lock()
c.conn.WriteMessage(websocket.TextMessage, data)
c.sendLock.Unlock()
wrapperCallback := func(resp WSPacket) {
resp.PacketID = request.PacketID
resp.SessionID = request.SessionID
callback(resp)
}
if callback == nil {
return
}
c.sendCallbackLock.Lock()
c.sendCallback[request.PacketID] = wrapperCallback
c.sendCallbackLock.Unlock()
}
// receive receive and response back
func (c *Client) receive(id string, f func(response WSPacket) (request WSPacket)) {
c.recvCallback[id] = func(response WSPacket) {
req := f(response)
// Add Meta data
req.PacketID = response.PacketID
req.SessionID = response.SessionID
// Skip rqeuest if it is EmptyPacket
if req == EmptyPacket {
return
}
resp, err := json.Marshal(req)
if err != nil {
log.Println("[!] json marshal error:", err)
}
//c.conn.SetWriteDeadline(time.Now().Add(writeWait))
c.sendLock.Lock()
c.conn.WriteMessage(websocket.TextMessage, resp)
c.sendLock.Unlock()
}
}
// syncSend sends a packet and wait for callback till the packet comes back
func (c *Client) syncSend(request WSPacket) (response WSPacket) {
res := make(chan WSPacket)
f := func(resp WSPacket) {
res <- resp
}
c.send(request, f)
return <-res
}
// heartbeat maintains connection to server
func (c *Client) heartbeat() {
// send heartbeat every 1s
timer := time.Tick(time.Second)
for range timer {
c.send(WSPacket{ID: "heartbeat"}, nil)
}
}
func (c *Client) listen() {
for {
// log.Println("Waiting for message ...")
_, rawMsg, err := c.conn.ReadMessage()
if err != nil {
log.Println("[!] read:", err)
break
}
wspacket := WSPacket{}
err = json.Unmarshal(rawMsg, &wspacket)
if err != nil {
continue
}
// Check if some async send is waiting for the response based on packetID
// TODO: Change to read lock
c.sendCallbackLock.Lock()
callback, ok := c.sendCallback[wspacket.PacketID]
c.sendCallbackLock.Unlock()
if ok {
go callback(wspacket)
c.sendCallbackLock.Lock()
delete(c.sendCallback, wspacket.PacketID)
c.sendCallbackLock.Unlock()
// Skip receiveCallback to avoid duplication
continue
}
// Check if some receiver with the ID is registered
if callback, ok := c.recvCallback[wspacket.ID]; ok {
go callback(wspacket)
}
}
}