Skip to content
Snippets Groups Projects
Unverified Commit 4e0c44e6 authored by Ben Eltschig's avatar Ben Eltschig
Browse files

Merge branch 'custom-server' into json-schema

parents 65127c55 c8d436ae
Branches
1 merge request!5Custom server
export const baseURL = `ws://${document.location.hostname}:443/skribbl`;
/**
* @typedef {{connection:RTCPeerConnection,dataChannel:RTCDataChannel}} ConnectionData
*/
/**
* A connection to the signaling server used to start and join games.
*/
export default class Signaler {
/**
* Starts a new game, returning a promise with its ID. After that, calls the given callback with a new RTCPeerConnection whenever someone joins the game.
* @param {(connectionData:ConnectionData)=>void} onConnect
*/
static async host(onConnect){
const socket = new CustomSocket(baseURL+"/host");
//await socket.waitUntilReady();
let response = socket.next(message=>{
console.log("possible response:",message);
return message.startsWith("started ");
});
let id = (await response).split(" ")[1];
socket.onMessage(async message=>{
if (message.startsWith("join ")){
let playerID = message.split(" ",2)[1];
console.log("new player joined: ",playerID);
/** @type {((message:string)=>void)[]} */
let callbacks = [];
socket.onMessage(message=>{
if (message.startsWith("message "+playerID+" ")){
let data = message.split(" ").slice(2).join(" ");
console.log("message from player "+playerID+": ",data);
callbacks.forEach(callback=>callback(data));
}
});
onConnect(await perfectNegotiation(callback=>callbacks.push(callback),message=>{
console.log("message to player "+playerID+": ",message);
socket.send("message "+playerID+" "+message);
},false));
}
});
return id;
}
/**
* Joins a new game, returning an RTCPeerConnection to its host.
* @param {string} id
*/
static async join(id){
const socket = new CustomSocket(baseURL+"/join/"+id);
await socket.waitUntilReady();
/** @type {((message:string)=>void)[]} */
let callbacks = [];
socket.onMessage(message=>{
console.log("new message: ",message);
callbacks.forEach(callback=>callback(message.split(" ").slice(1).join(" ")));
});
return await perfectNegotiation(callback=>callbacks.push(callback),message=>{
console.log("send message:",message);
socket.send("message "+message);
},true);
}
}
/**
* An implementation of "perfect negotiaton" as described [here](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation).
* @param {(callback:(message:string)=>void)=>void} onMessage
* @param {(message:string)=>void} sendMessage
* @param {boolean} polite
* @returns {Promise<ConnectionData>}
*/
export async function perfectNegotiation(onMessage,sendMessage,polite){
const connection = new RTCPeerConnection();
const dataChannel = connection.createDataChannel("channel",{id:0,negotiated:true});
let makingOffer = false;
connection.onnegotiationneeded = async()=>{
makingOffer = true;
// @ts-ignore
await connection.setLocalDescription();
//console.log("local description:",connection.localDescription)
sendMessage(JSON.stringify({description:connection.localDescription}));
makingOffer = false;
};
connection.onicecandidate = ({candidate})=>{
//console.log("candidate:",candidate);
sendMessage(JSON.stringify({candidate}));
};
let ignoreOffer = false;
onMessage(async message=>{
console.log("message to negotiator:",message);
/** @type {{description:RTCSessionDescriptionInit,candidate:undefined}|{candidate:RTCIceCandidateInit,description:undefined}} */
let data = JSON.parse(message);
if (data.description){
let description = data.description;
ignoreOffer = !polite&&(description.type=="offer"&&(makingOffer||connection.signalingState!="stable"));
if(!ignoreOffer){
await connection.setRemoteDescription(description);
if (description.type=="offer"){
// @ts-ignore
await connection.setLocalDescription();
sendMessage(JSON.stringify({description:connection.localDescription}));
}
}
}else if(data.candidate){
try {
await connection.addIceCandidate(data.candidate);
}catch(e){
if (!ignoreOffer){
throw e;
}
}
}
});
/** @type {Promise<ConnectionData>} */
return new Promise(resolve=>{
connection.onsignalingstatechange = ()=>{
console.log("Signaling state changed to:",connection.signalingState);
if (connection.signalingState=="stable"){
resolve({connection,dataChannel});
}
};
});
}
/**
* Custom socket class to pass strings to and from a server.
*
* Originally, the idea was to let this pass json data instead, but since json is difficult to work with in java I've stuck with normal strings for now.
*/
export class CustomSocket {
/**
* @callback MessageCallback
* @param {string} message
* @param {()=>void} remove removes this callback again
* @returns {void}
*/
/**
* @callback ErrorCallback
* @param {Event} error
* @param {()=>void} remove removes this callback again
* @returns {void}
*/
/**
* Creates a new custom socket.
* @param {string} url
*/
constructor(url){
/** @type {MessageCallback[]} */
this._onMessageCallbacks = [];
/** @type {ErrorCallback[]} */
this._onErrorCallbacks = [];
this._webSocket = new WebSocket(url);
/** @type {Promise<void>} */
this._readyPromise = new Promise((resolve,reject)=>{
this._webSocket.onopen = ()=>resolve();
this._webSocket.addEventListener("error",reject,{once:true});
});
this._webSocket.onmessage = (e)=>{
console.log(`onmessage event! e.data: "${e.data}", registered callbacks: ${this._onMessageCallbacks.length}`);
this._onMessageCallbacks.slice().forEach(callback=>callback(e.data+"",()=>this.removeMessageCallback(callback)));
};
this._webSocket.onerror = (e)=>{
this._onErrorCallbacks.slice().forEach(callback=>callback(e,()=>this.removeErrorCallback(callback)));
};
}
async waitUntilReady(){
return this._readyPromise;
}
/**
* Sends the given object as json to the server.
* @param {string} data
*/
send(data){
this._webSocket.send(data);
}
/**
* Registers a callback to be called whenever a message is received.
* @param {MessageCallback} callback
*/
onMessage(callback){
this._onMessageCallbacks.push(callback);
}
/**
* Removes a callback registered using `onMessage(callback)`.
* @param {MessageCallback} callback
*/
removeMessageCallback(callback){
let index = this._onMessageCallbacks.indexOf(callback);
if (index!==-1){
this._onMessageCallbacks.splice(index,1);
}
}
/**
* Returns a Promise that resolves to the next message received that passen the given filter, or `null` if no such message is received in the specified timeframe.
* If an error occurs before, the promise will instead be rejected with that.
* @param {(message:string)=>boolean} filter
* @param {number} time time to wait before just returning `null`, in seconds. Defaults to 30. If the given number is smaller than or equal to 0, it gets treated as `Infinity`.
* @return {Promise<string>}
*/
async next(filter=null,time=30){
return new Promise((resolve,reject)=>{
this.onMessage((message,remove)=>{
if (!filter||filter(message)){
resolve(message);
remove();
}
});
this.onError((error,remove)=>{
reject(error);
remove();
});
if (time>0){
setTimeout(()=>resolve(null),time*1000);
}
});
}
/**
* Registers a callback to be called whenever there's an error.
* @param {ErrorCallback} callback
*/
onError(callback){
this._onErrorCallbacks.push(callback);
}
/**
* Removes a callback registered using `onError(callback)`.
* @param {ErrorCallback} callback
*/
removeErrorCallback(callback){
let index = this._onErrorCallbacks.indexOf(callback);
if (index!==-1){
this._onErrorCallbacks.splice(index,1);
}
}
/**
* Closes the connection.
*/
close(){
this._webSocket.close();
}
}
\ No newline at end of file
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>signaling server test</title>
<script type="module" src="script.js"></script>
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
#button {
padding: 10px 30px;
font-size: 30px;
background: orange;
color: white;
border: none;
border-radius: 4px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%,-50%);
cursor: pointer;
}
#button:disabled {
cursor: default;
background: #dfa580;
}
</style>
</head>
<body>
<button id="button">Host game</button>
</body>
</html>
\ No newline at end of file
{
"compilerOptions": {
"checkJs": true,
"target": "esnext",
"module": "esnext"
}
}
\ No newline at end of file
import Signaler from "./Signaler.js";
document.addEventListener("DOMContentLoaded",async()=>{
if (document.location.hash){
document.body.innerHTML = "";
const gameID = document.location.hash.substring(1);
const {connection,dataChannel} = await Signaler.join(gameID);
console.group("Joined game "+gameID+"!");
console.log("connection:",connection);
console.log("dataChannel:",dataChannel);
console.groupEnd();
dataChannel.onmessage = (e)=>{
console.log("Message through DataChannel:",e.data);
dataChannel.send("Got ya message, many thanks <3");
}
}else{
/** @type {HTMLButtonElement} *///@ts-ignore
const button = document.getElementById("button");
button.addEventListener("click",async e=>{
button.disabled = true;
let id = await Signaler.host(async({connection,dataChannel})=>{
console.group("New connection!");
console.log("connection:",connection);
console.log("dataChannel:",dataChannel);
console.groupEnd();
dataChannel.onmessage = (e)=>{
console.log("Message through DataChannel:",e.data);
}
if (dataChannel.readyState!="open"){
await new Promise(resolve=>{
dataChannel.onopen = resolve;
});
}
dataChannel.send("Oh hello there :3");
});
alert(document.location.host+document.location.pathname+"#"+id);
});
}
});
\ No newline at end of file
cd client
php -S localhost:8080
pause
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" path="src"/>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER">
<attributes>
<attribute name="module" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="lib" path="lib/Java-WebSocket-1.5.1-with-dependencies.jar">
<attributes>
<attribute name="javadoc_location" value="jar:file:/C:/Users/Ben%20Eltschig/Documents/Schule/Uni/Skribbl/server/lib/Java-WebSocket-1.5.1-javadoc.jar!/"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="bin"/>
</classpath>
/bin/
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>Mathe-Skribbl</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
</natures>
</projectDescription>
File added
File added
import java.util.HashMap;
import java.util.Map;
import org.java_websocket.WebSocket;
public class Game {
public final String id;
public final WebSocket host;
private final Map<String,WebSocket> clients;
public Game(String id, WebSocket host) {
this.id = id;
this.host = host;
this.clients = new HashMap<String,WebSocket>();
}
void handleMessage(WebSocket socket, String message){
if (socket==this.host) {
String[] parts = message.split(" ",3);
WebSocket client = clients.get(parts[1]);
client.send("message "+parts[2]);
System.out.println("message from host "+this.id+" to player "+parts[1]+": "+parts[2]);
}else {
this.host.send("message "+getClientID(socket)+" "+message.split(" ",2)[1]);
System.out.println("message from player "+getClientID(socket)+" to host "+this.id+": "+message.split(" ",2)[1]);
}
}
boolean hasClient(WebSocket client){
return clients.containsValue(client);
}
void addClient(WebSocket client) {
String id = genID(6);
while (clients.containsKey(id)) {
id = genID(6);
}
clients.put(id,client);
this.host.send("join "+id);
}
void removeClient(WebSocket client){
clients.values().remove(client);
}
private String getClientID(WebSocket client) {
for (String id:clients.keySet()) {
if (clients.get(id)==client) {
return id;
}
}
return null;
}
static String genID() {
return genID(8);
}
static String genID(int n) {
String id = "";
for (int i=0;i<8;i++) {
char c = (char)(Math.random()*62);
id += (char)(c+(c<10?'0':(c<36?'a'-10:'A'-36)));
}
return id;
}
}
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;
import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;
public class SignalingServer extends WebSocketServer {
private Map<String,Game> games = new HashMap<String,Game>();
public static void main(String[] args) {
InetSocketAddress address = new InetSocketAddress("localhost",443);
SignalingServer server = new SignalingServer(address);
server.run();
}
public SignalingServer(InetSocketAddress address) {
super(address);
}
public void onStart() {
System.out.println("started server");
}
public void onOpen(WebSocket socket, ClientHandshake handshake) {
String path = socket.getResourceDescriptor();
System.out.println("new connection to "+socket.getRemoteSocketAddress()+", path: "+path);
if (path.startsWith("/skribbl/host")) {
Game game = new Game(genUnusedID(),socket);
games.put(game.id,game);
socket.send("started "+game.id);
System.out.println("started game "+game.id);
}else if(path.startsWith("/skribbl/join/")) {
Game game = games.get(path.split("/",4)[3]);
if (game==null) {
socket.send("nope");
socket.close(4000,"invalid game code");
}else {
game.addClient(socket);
}
}else {
System.err.println("invalid connection attempt \""+path+"\"");
socket.send("nope");
socket.close(4000,"invalid request path");
}
}
public void onClose(WebSocket socket, int code, String reason, boolean remote) {
System.out.println("closed " + socket.getRemoteSocketAddress() + " with exit code " + code + " additional info: " + reason);
Game game = gameWithClient(socket);
if (game!=null) {
if (game.host==socket) {
games.remove(game.id);
System.out.println("closed game "+game.id);
}else {
game.removeClient(socket);
}
}
}
public void onMessage(WebSocket socket, String message) {
System.out.println("received message from "+socket.getRemoteSocketAddress()+": \""+message+"\"");
if(message.startsWith("message ")) {
Game game = gameWithClient(socket);
if (game==null) {
socket.close(4000,"not currently in any game");
}else {
game.handleMessage(socket,message);
}
}else {
System.err.println("can't interpret message: \""+message+"\"");
}
}
public void onMessage(WebSocket socket, ByteBuffer message) {
socket.close(1003);
}
public void onError(WebSocket conn, Exception ex) {
if (conn==null) {
System.err.print("an error occured: ");
}else {
System.err.print("an error occurred on connection " + conn.getRemoteSocketAddress() + ": ");
}
ex.printStackTrace();
}
private Game gameWithClient(WebSocket socket) {
for (Game game:games.values()) {
if (game.host==socket||game.hasClient(socket)) {
return game;
}
}
return null;
}
private String genUnusedID(){
String id = Game.genID();
while(games.containsKey(id)) {
id = Game.genID();
}
return id;
}
}
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment