1
0

Audio almost working; mic is doing the right thing

Signed-off-by: James Ketrenos <james_eikona@ketrenos.com>
This commit is contained in:
James Ketrenos 2022-03-10 23:08:56 -08:00
parent 4909647e75
commit f6b2ada2ee
15 changed files with 1353 additions and 505 deletions

View File

@ -6,3 +6,39 @@ body {
width: 100vw;
height: 100vh;
}
.Table {
display: flex;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100vh;
min-height: 100%;
flex-direction: row;
justify-content: space-between; /* left-justify 'board', right-justify 'game' */
background-image: url("./assets/tabletop.png");
}
.Table .Sidebar {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 30rem;
max-width: 30rem;
overflow: hidden;
}
.Table .Board {
display: flex;
flex-grow: 1;
margin: 1rem;
border: 1px solid purple;
}
.Table .Sidebar .Chat {
display: flex;
position: relative;
flex-grow: 1;
}

View File

@ -1,31 +1,201 @@
/* Polyfills for IE */
import 'react-app-polyfill/ie11';
import 'core-js/features/array/find';
import 'core-js/features/array/includes';
import 'core-js/features/number/is-nan';
/* App starts here */
import React from "react";
import React, { useCallback, useState,
useReducer, useContext, useEffect,
useRef } from "react";
import {
BrowserRouter as Router,
Route,
Routes
Routes,
useParams
} from "react-router-dom";
import { base, gamesPath } from './Common.js';
import history from "./history.js";
//import 'typeface-roboto';
import { GlobalContext } from "./GlobalContext.js";
import { PingPong } from "./PingPong.js";
import { PlayerList } from "./PlayerList.js";
import { PlayerName } from "./PlayerName.js";
import { Chat } from "./Chat.js";
import { MediaAgent, MediaContext } from "./MediaControl.js";
//import history from "./history.js";
import Table from "./Table.js";
import "./App.css";
let base = process.env.PUBLIC_URL;
const Table = () => {
const params = useParams();
const global = useContext(GlobalContext);
const [ gameId, setGameId ] = useState(params.gameId ? params.gameId : undefined);
const [ ws, setWs ] = useState(global.ws);
const [ name, setName ] = useState(global.name);
const [ error, setError ] = useState(undefined);
const [ peers, setPeers ] = useState({});
function App() {
useEffect(() => {
console.log(peers);
}, [peers]);
const onWsOpen = (event) => {
console.log(`ws: open`);
setError("");
/* We do not set the socket as bound until the 'open' message
* comes through */
setWs(event.target);
event.target.send(JSON.stringify({ type: 'game-update' }));
};
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'game-update':
if ('name' in data.update && data.update.name !== name) {
console.log(`Updating name to ${data.update.name}`);
setName(data.update.name);
}
if ('id' in data.update && data.update.id !== gameId) {
console.log(`Updating id to ${data.update.id}`);
setGameId(data.update.id);
}
break;
default:
break;
}
};
const onWsError = (event) => {
console.log(`ws: error`, event);
const error = `Connection to Ketr Ketran game server failed! ` +
`Try refreshing in a few seconds.`;
console.error(error);
setError(error);
};
const onWsClose = (event) => {
console.log(`ws: close`);
setWs(undefined);
global.ws = undefined;
};
/* callback refs are used to provide correct state reference
* in the callback handlers, while also preventing rebinding
* of event handlers on every render */
const refWsOpen = useRef(onWsOpen);
useEffect(() => { refWsOpen.current = onWsOpen; });
const refWsMessage = useRef(onWsMessage);
useEffect(() => { refWsMessage.current = onWsMessage; });
const refWsClose = useRef(onWsClose);
useEffect(() => { refWsClose.current = onWsClose; });
const refWsError = useRef(onWsError);
useEffect(() => { refWsError.current = onWsError; });
/* This effect is responsible for triggering a new game load if a
* game id is not provided in the URL. If the game is provided
* in the URL, the backend will create a new game if necessary
* during the WebSocket connection sequence.
*
* This should be the only HTTP request made from the game.
*/
useEffect(() => {
if (gameId) {
console.log(`Game in use ${gameId}`)
return;
}
console.log("Requesting new game.");
window.fetch(`${base}/api/v1/games/`, {
method: 'POST',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
}).then((res) => {
if (res.status >= 400) {
const error = `Unable to connect to Ketr Ketran game server!` +
`Try refreshing in a few seconds.`;
console.error(error);
setError(error);
throw error;
}
return res.json();
}).then((update) => {
if (update.id !== gameId) {
console.log(`New game started: ${update.id}`);
history.push(`${gamesPath}/${update.id}`);
setGameId(update.id);
}
});
}, [ gameId, setGameId ]);
/* Once a game id is known, create the sole WebSocket connection
* to the backend. This WebSocket is then shared with any component
* that performs game state updates. Those components should
* bind to the 'message:game-update' WebSocket event and parse
* their update information from those messages
*/
useEffect(() => {
if (!gameId) {
return;
}
let socket = ws;
if (!ws) {
let loc = window.location, new_uri;
if (loc.protocol === "https:") {
new_uri = "wss";
} else {
new_uri = "ws";
}
new_uri = `${new_uri}://${loc.host}${base}/api/v1/games/ws/${gameId}`;
console.log(`Attempting WebSocket connection to ${new_uri}`);
socket = new WebSocket(new_uri);
}
console.log('table - bind');
const cbOpen = e => refWsOpen.current(e);
const cbMessage = e => refWsMessage.current(e);
const cbClose = e => refWsClose.current(e);
const cbError = e => refWsError.current(e);
socket.addEventListener('open', cbOpen);
socket.addEventListener('close', cbClose);
socket.addEventListener('error', cbError);
socket.addEventListener('message', cbMessage);
return () => {
if (socket) {
console.log('table - unbind');
socket.removeEventListener('open', cbOpen);
socket.removeEventListener('close', cbClose);
socket.removeEventListener('error', cbError);
socket.removeEventListener('message', cbMessage);
}
}
}, [ setWs, gameId, ws, refWsOpen, refWsMessage, refWsClose, refWsError ]);
if (error) {
return <div>{ error }</div>;
}
return <GlobalContext.Provider value={{ ws, name, gameId, peers, setPeers }}>
<MediaAgent/>
<PingPong/>
<div className="Table">
<div className="Board">board goes here</div>
<div className="Sidebar">
{ name === "" && <PlayerName/> }
{ name !== "" && <PlayerList/> }
{ name !== "" && <Chat/> }
</div>
</div>
</GlobalContext.Provider>;
};
const App = () => {
console.log(`Base: ${base}`);
return (
<Router>
<Routes>
<Route exact element={<Table/>} path={`${base}/games/:id`}/>
<Route exact element={<Table/>} path={`${base}/games/:gameId`}/>
<Route exact element={<Table/>} path={`${base}`}/>
</Routes>
</Router>

View File

@ -2,19 +2,21 @@
.Chat {
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 0.5em;
position: relative;
overflow: hidden;
}
.ChatList {
flex-direction: column;
position: relative;
flex-grow: 1;
/* for Firefox */
min-height: 0;
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
scroll-behavior: smooth;
align-items: flex-start;
overflow-x: hidden;
overflow-y: scroll;
}
.ChatList .System {

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useContext, useRef } from "react";
import "./Chat.css";
import PlayerColor from './PlayerColor.js';
import Paper from '@material-ui/core/Paper';
@ -7,18 +7,63 @@ import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import Moment from 'react-moment';
import TextField from '@material-ui/core/TextField';
import 'moment-timezone';
import Resource from './Resource.js';
import Dice from './Dice.js';
import { GlobalContext } from "./GlobalContext.js";
const Chat = ({ table, game }) => {
const [lastTop, setLastTop] = useState(0),
[autoScroll, setAutoscroll] = useState(true),
[latest, setLatest] = useState(''),
[scrollTime, setScrollTime] = useState(0);
const Chat = () => {
const [lastTop, setLastTop] = useState(0);
const [autoScroll, setAutoscroll] = useState(true);
const [latest, setLatest] = useState('');
const [scrollTime, setScrollTime] = useState(0);
const [chat, setChat] = useState([]);
const [startTime, setStartTime] = useState(0);
const chatInput = (event) => {
const global = useContext(GlobalContext);
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'game-update':
console.log(`chat - game update`);
if (data.update.chat) {
console.log(`chat - game update - ${data.update.chat.length} lines`);
setChat(data.update.chat);
}
if (data.update.startTime && data.update.startTime !== startTime) {
setStartTime(data.update.startTime);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => { refWsMessage.current = onWsMessage; });
useEffect(() => {
if (!global.ws) {
return;
}
const cbMessage = e => refWsMessage.current(e);
global.ws.addEventListener('message', cbMessage);
return () => {
global.ws.removeEventListener('message', cbMessage);
}
}, [global.ws, refWsMessage]);
useEffect(() => {
if (!global.ws) {
return;
}
global.ws.send(JSON.stringify({
type: 'get',
fields: ['chat', 'startTime' ]
}));
}, [global.ws]);
const chatKeyPress = (event) => {
if (event.key === "Enter") {
@ -26,8 +71,7 @@ const Chat = ({ table, game }) => {
setAutoscroll(true);
}
table.sendChat(event.target.value);
global.ws.send(JSON.stringify({ type: 'chat', message: event.target.value }));
event.target.value = "";
}
};
@ -75,12 +119,7 @@ const Chat = ({ table, game }) => {
}
});
//const timeDelta = game.timestamp - Date.now();
if (!game.id) {
console.log("Why no game id?");
}
const messages = game && game.chat.map((item, index) => {
const messages = chat.map((item, index) => {
const punctuation = item.message.match(/(\.+$)/);
let period;
if (punctuation) {
@ -130,27 +169,26 @@ const Chat = ({ table, game }) => {
);
});
if (game.chat &&
game.chat.length &&
game.chat[game.chat.length - 1].date !== latest) {
setLatest(game.chat[game.chat.length - 1].date);
if (chat.length && chat[chat.length - 1].date !== latest) {
setLatest(chat[chat.length - 1].date);
setAutoscroll(true);
}
const name = game ? game.name : "Why no game?";
const elapsed = game ? (game.timestamp - game.startTime) : undefined;
return (
<Paper className="Chat">
<List className="ChatList" id="ChatList" onScroll={chatScroll}>
{ messages }
</List>
<TextField className="ChatInput"
disabled={!name}
onChange={chatInput}
disabled={!global.name}
onKeyPress={chatKeyPress}
label={elapsed && <Moment tz={"Etc/GMT"} format="h:mm:ss" durationFromNow interval={1000} date={game.startTime}></Moment>} variant="outlined"/>
label={startTime !== 0 && <Moment tz={"Etc/GMT"}
format="h:mm:ss"
durationFromNow interval={1000}
date={startTime}/>}
variant="outlined"/>
</Paper>
);
}
export default Chat;
export { Chat };

View File

@ -0,0 +1,12 @@
import { createContext } from "react";
const global = {
gameId: undefined,
ws: undefined,
name: "",
chat: []
};
const GlobalContext = createContext(global);
export { GlobalContext, global };

View File

@ -1,10 +1,10 @@
.MediaAgent {
display: none;
position: absolute;
display: flex;
top: 0;
left: 0;
z-index: 50000;
flex-direction: column;
}
.MediaControl {
@ -19,3 +19,7 @@
.MediaControl > div {
display: flex;
}
.MediaAgent .Local {
border: 3px solid red;
}

View File

@ -1,16 +1,15 @@
import React, { useState, useEffect, useRef, useCallback,
useContext, createContext } from "react";
useContext } from "react";
import "./MediaControl.css";
import VolumeOff from '@mui/icons-material/VolumeOff';
import VolumeUp from '@mui/icons-material/VolumeUp';
import MicOff from '@mui/icons-material/MicOff';
import Mic from '@mui/icons-material/Mic';
const MediaContext = createContext();
import { GlobalContext } from "./GlobalContext.js";
/* Proxy object so we can pass in srcObject to <audio> */
const Audio = ({ srcObject, ...props }) => {
const Audio = ({ srcObject, paused, muted, ...props }) => {
const refAudio = useRef(null);
useEffect(() => {
if (!refAudio.current) {
@ -20,41 +19,213 @@ const Audio = ({ srcObject, ...props }) => {
refAudio.current.srcObject = srcObject;
return () => {
console.log('<audio> unbind');
refAudio.current.srcObject = undefined;
if (refAudio.current) {
refAudio.current.srcObject = undefined;
}
};
}, [srcObject]);
}, [srcObject, paused, muted]);
return <audio ref={refAudio} {...props} />;
}
const MediaAgent = ({ name, ws }) => {
const MediaAgent = () => {
const { name, ws, peers, setPeers } = useContext(GlobalContext);
const [stream, setStream] = useState(undefined);
const { peers, setPeers } = useContext(MediaContext);
const onClose = useCallback((event) => {
console.log(`${name} Disconnected from signaling server`);
/* Tear down all of our peer connections and remove all the
* media divs when we disconnect */
for (let peer_id in peers) {
peers[peer_id].close();
const onTrack = useCallback((event) => {
const connection = event.target;
console.log("ontrack", event);
let isLocal = true;
for (let key in peers) {
if (peers[key].connection === connection) {
isLocal = false;
Object.assign(peers[key].attributes, {
srcObject: event.streams[0]
});
}
}
for (let id in peers) {
delete peers[id];
if (isLocal) {
throw new Error('Should not be local!');
}
console.log(`MediaAgent - ontrack - remote`, peers);
setPeers(Object.assign({}, peers));
}, [peers, setPeers, name]);
}, [peers, setPeers]);
const refOnTrack = useRef(onTrack);
const onOpen = (event) => {
console.log(`MediaAgent - WebSocket open request. Attempting to create local media.`)
setup_local_media().then(() => {
/* once the user has given us access to their
* microphone/camcorder, join the channel and start peering up */
join_chat(ws);
});
};
const onMessage = (event) => {
const onWsMessage = useCallback((event) => {
const data = JSON.parse(event.data);
console.log(`MediaAgent - onWsMessage`, peers);
const addPeer = (config) => {
console.log('Signaling server said to add peer:', config);
if (!stream) {
console.log(`No local media stream`);
return;
}
const peer_id = config.peer_id;
if (peer_id in peers) {
return;
}
const connection = new RTCPeerConnection({
/*configuration: {
offerToReceiveAudio: true,
offerToReceiveVideo: false
},*/
iceServers: [ { urls: "stun:stun.l.google.com:19302" } ]
}, {
/* this will no longer be needed by chrome
* eventually (supposedly), but is necessary
* for now to get firefox to talk to chrome */
optional: [{DtlsSrtpKeyAgreement: true}]
});
peers[peer_id] = {
connection,
attributes: {
local: false,
muted: false
}
};
console.log(`MediaAgent - addPeer - remote`, peers);
setPeers(Object.assign({}, peers));
connection.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
ws.send(JSON.stringify({
type: 'relayICECandidate',
config: {
peer_id: peer_id,
ice_candidate: {
sdpMLineIndex: event.candidate.sdpMLineIndex,
candidate: event.candidate.candidate
}
}
}));
};
connection.ontrack = e => refOnTrack.current(e);;
/* Add our local stream */
connection.addStream(stream);
/* Only one side of the peer connection should create the
* offer, the signaling server picks one to be the offerer.
* The other user will get a 'sessionDescription' event and will
* create an offer, then send back an answer 'sessionDescription'
* to us
*/
if (config.should_create_offer) {
console.log("MediaAgent - Creating RTC offer to ", peer_id);
return connection.createOffer()
.then((local_description) => {
console.log("Local offer description is: ", local_description);
return connection.setLocalDescription(local_description)
.then(() => {
ws.send(JSON.stringify({
type: 'relaySessionDescription',
config: {
'peer_id': peer_id,
'session_description': local_description
}
}));
console.log("Offer setLocalDescription succeeded");
})
.catch((error) => {
console.error("Offer setLocalDescription failed!");
});
})
.catch((error) => {
console.log("Error sending offer: ", error);
});
}
}
const sessionDescription = ({ peer_id, session_description }) => {
/**
* Peers exchange session descriptions which contains information
* about their audio / video settings and that sort of stuff. First
* the 'offerer' sends a description to the 'answerer' (with type
* "offer"), then the answerer sends one back (with type "answer").
*/
// console.log('Remote description received: ', peer_id, session_description);
const peer = peers[peer_id];
if (!peer) {
console.error(`No peer for ${peer_id}`);
return;
}
// console.log(session_description);
const { connection } = peer;
const desc = new RTCSessionDescription(session_description);
return connection.setRemoteDescription(desc, () => {
console.log("setRemoteDescription succeeded");
if (session_description.type === "offer") {
console.log("Creating answer");
connection.createAnswer((local_description) => {
console.log("Answer description is: ", local_description);
connection.setLocalDescription(local_description, () => {
ws.send(JSON.stringify({
type: 'relaySessionDescription',
config: {
peer_id,
session_description: local_description
}
}));
console.log("Answer setLocalDescription succeeded");
}, () => {
console.error("Answer setLocalDescription failed!");
});
}, (error) => {
// console.log("Error creating answer: ", error);
console.error(peer);
});
}
}, (error) => {
console.log("setRemoteDescription error: ", error);
});
// console.log("Description Object: ", desc);
};
const removePeer = ({peer_id}) => {
console.log('Signaling server said to remove peer:', peer_id);
if (peer_id in peers) {
if (peers[peer_id].connection) {
peers[peer_id].connnection.close();
}
}
delete peers[peer_id];
console.log(`MediaAgent - removePeer - remote or local?`, peers);
setPeers(Object.assign({}, peers));
};
const iceCandidate = ({ peer_id, ice_candidate }) => {
/**
* The offerer will send a number of ICE Candidate blobs to the
* answerer so they can begin trying to find the best path to one
* another on the net.
*/
const peer = peers[peer_id];
if (!peer) {
console.error(`No peer for ${peer_id}`, peers);
return;
}
peer.connection.addIceCandidate(new RTCIceCandidate(ice_candidate))
.then(() => {
console.log(`Successfully added Ice Candidate for ${peer_id}`);
})
.catch((error) => {
console.error(error, peer, ice_candidate);
});
};
switch (data.type) {
case 'addPeer': addPeer(data.data); break;
case 'removePeer': removePeer(data.data); break;
@ -62,310 +233,199 @@ const MediaAgent = ({ name, ws }) => {
case 'sessionDescription': sessionDescription(data.data); break;
default: break;
}
};
}, [ peers, setPeers, stream, ws, refOnTrack ]);
const refWsMessage = useRef(onWsMessage);
const onError = (event) => {
console.error(`${name} WebSocket error`);
};
const setup_local_media = useCallback(async () => {
if (stream !== undefined) { /* ie, if we've already been initialized */
console.log(stream);
return;
const onWsClose = (event) => {
console.log(`${name} Disconnected from signaling server`);
/* Tear down all of our peer connections and remove all the
* media divs when we disconnect */
for (let peer_id in peers) {
if (peers[peer_id].local) {
continue;
}
peers[peer_id].connection.close();
}
/* Ask user for permission to use the computers microphone and/or camera,
* attach it to an <audio> or <video> tag if they give us access. */
console.log("Requesting access to local audio / video inputs");
for (let id in peers) {
delete peers[id];
}
console.log(`MediaAgent - close - local or remote?`, peers);
setPeers(Object.assign({}, peers));
}
const refWsClose = useRef(onWsClose);
navigator.getUserMedia = (navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia);
useEffect(() => {
refWsMessage.current = onWsMessage;
refWsClose.current = onWsClose;
refOnTrack.current = onTrack;
});
return await navigator.mediaDevices.getUserMedia({audio: true, video: false})//, "video": true})
.then((media) => { /* user accepted access to a/v */
console.log("Access granted to audio/video");
setStream(media);
})
.catch((error) => { /* user denied access to a/v */
console.error(error);
console.log("Access denied for audio/video");
window.alert("You chose not to provide access to the microphone!" +
"Ketran will have no audio :(");
});
}, [ setStream, stream ]);
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = e => refWsMessage.current(e);
ws.addEventListener('message', cbMessage);
const cbClose = e => refWsClose.current(e);
ws.addEventListener('close', cbClose);
return () => {
ws.removeEventListener('message', cbMessage);
ws.removeEventListener('close', cbClose);
}
}, [ws, refWsMessage, refWsClose ]);
useEffect(() => {
if (!name) {
return;
}
let update = false;
if (!(name in peers)) {
update = true;
peers[name] = {
local: true,
attributes: {
local: true,
muted: false
}
};
}
for (let key in peers) {
if (peers[key].local && key !== name) {
delete peers[key];
update = true;
}
}
if (update) {
console.log(`MediaAgent - Adding local`, peers);
setPeers(Object.assign({}, peers));
}
}, [peers, name, setPeers]);
useEffect(() => {
if (!ws) {
return;
}
console.log('MediaAgent bind');
const setup_local_media = async () => {
/* Ask user for permission to use the computers microphone and/or camera,
* attach it to an <audio> or <video> tag if they give us access. */
console.log("Requesting access to local audio / video inputs");
console.log('Media Agent peers: ', peers);
navigator.getUserMedia = (navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia);
ws.addEventListener('message', onMessage);
ws.addEventListener('error', onError);
ws.addEventListener('close', onClose);
ws.addEventListener('open', onOpen);
return () => {
console.log('MediaAgent unbind');
ws.removeEventListener('message', onMessage);
ws.removeEventListener('error', onError);
ws.removeEventListener('close', onClose);
ws.removeEventListener('open', onOpen);
return await navigator.mediaDevices.getUserMedia({audio: true, video: false})//, "video": true})
.then((media) => { /* user accepted access to a/v */
console.log("Access granted to audio/video");
setStream(media);
})
.catch((error) => { /* user denied access to a/v */
console.error(error);
console.log("Access denied for audio/video");
window.alert("You chose not to provide access to the microphone!" +
"Ketran will have no audio :(");
});
};
});
const join_chat = () => {
ws.send(JSON.stringify({ type: 'join' }));
}
console.log(`MediaAgent - WebSocket open request. Attempting to create local media.`)
setup_local_media().then(() => {
/* once the user has given us access to their
* microphone/camcorder, join the channel and start peering up */
join_chat();
});
}, [ws, setStream]);
if (!ws) {
return <></>;
}
console.log(`MediaAgent`);
const addPeer = (config) => {
console.log('Signaling server said to add peer:', config);
console.log(`MediaAgent`, peers);
if (!stream) {
console.log(`No local media stream`);
return;
}
const peer_id = config.peer_id;
if (peer_id in peers) {
/* This could happen if the user joins multiple channels where the
* other peer is also in. */
//console.log("Already connected to peer ", peer_id);
return;
}
const peer_connection = new RTCPeerConnection({
/*configuration: {
offerToReceiveAudio: true,
offerToReceiveVideo: false
},*/
iceServers: [ { urls: "stun:stun.l.google.com:19302" } ]
}, {
/* this will no longer be needed by chrome
* eventually (supposedly), but is necessary
* for now to get firefox to talk to chrome */
optional: [{DtlsSrtpKeyAgreement: true}]
});
peers[peer_id] = peer_connection;
setPeers(Object.assign({}, peers));
peer_connection.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
ws.send(JSON.stringify({
type: 'relayICECandidate',
config: {
peer_id: peer_id,
ice_candidate: {
sdpMLineIndex: event.candidate.sdpMLineIndex,
candidate: event.candidate.candidate
}
}
}));
};
peer_connection.ontrack = (event) => {
console.log("ontrack", event);
peer_connection.attributes = {
autoPlay: 'autoplay',
muted: true,
controls: true
};
if (window.URL) {
peer_connection.attributes.srcObject = event.streams[0];
} else {
peer_connection.attributes.src = event.streams[0];
}
setPeers(Object.assign({}, peers));
};
/* Add our local stream */
peer_connection.addStream(stream);
/* Only one side of the peer connection should create the
* offer, the signaling server picks one to be the offerer.
* The other user will get a 'sessionDescription' event and will
* create an offer, then send back an answer 'sessionDescription'
* to us
*/
if (config.should_create_offer) {
console.log("MediaAgent - Creating RTC offer to ", peer_id);
return peer_connection.createOffer()
.then((local_description) => {
console.log("Local offer description is: ", local_description);
return peer_connection.setLocalDescription(local_description)
.then(() => {
ws.send(JSON.stringify({
type: 'relaySessionDescription',
config: {
'peer_id': peer_id,
'session_description': local_description
}
}));
console.log("Offer setLocalDescription succeeded");
})
.catch((error) => {
console.error("Offer setLocalDescription failed!");
});
})
.catch((error) => {
console.log("Error sending offer: ", error);
});
}
}
const sessionDescription = ({ peer_id, session_description }) => {
/**
* Peers exchange session descriptions which contains information
* about their audio / video settings and that sort of stuff. First
* the 'offerer' sends a description to the 'answerer' (with type
* "offer"), then the answerer sends one back (with type "answer").
*/
// console.log('Remote description received: ', peer_id, session_description);
const peer = peers[peer_id];
if (!peer) {
console.error(`No peer for ${peer_id}`);
return;
}
// console.log(session_description);
const desc = new RTCSessionDescription(session_description);
return peer.setRemoteDescription(desc, () => {
console.log("setRemoteDescription succeeded");
if (session_description.type === "offer") {
console.log("Creating answer");
peer.createAnswer((local_description) => {
console.log("Answer description is: ", local_description);
peer.setLocalDescription(local_description, () => {
ws.send(JSON.stringify({
type: 'relaySessionDescription',
config: {
peer_id,
session_description: local_description
}
}));
console.log("Answer setLocalDescription succeeded");
}, () => {
console.error("Answer setLocalDescription failed!");
});
}, (error) => {
// console.log("Error creating answer: ", error);
console.error(peer);
});
}
}, (error) => {
console.log("setRemoteDescription error: ", error);
});
// console.log("Description Object: ", desc);
};
const removePeer = ({peer_id}) => {
console.log('Signaling server said to remove peer:', peer_id);
if (peer_id in peers) {
peers[peer_id].close();
}
delete peers[peer_id];
setPeers(Object.assign({}, peers));
};
const iceCandidate = ({ peer_id, ice_candidate }) => {
/**
* The offerer will send a number of ICE Candidate blobs to the
* answerer so they can begin trying to find the best path to one
* another on the net.
*/
const peer = peers[peer_id];
if (!peer) {
console.error(`No peer for ${peer_id}`);
return;
}
peer.addIceCandidate(new RTCIceCandidate(ice_candidate))
.then(() => {
console.log(`Successfully added Ice Candidate for ${peer_id}`);
})
.catch((error) => {
console.error(error, peer, ice_candidate);
});
};
const join_chat = (ws) => {
ws.send(JSON.stringify({ type: 'join' }));
}
/*
const part_chat_channel = (channel) => {
ws.send(JSON.stringify({ type: 'part' }));
}
*/
const audioPeers = [];
for (let id in peers) {
const peer = peers[id];
if (peer.attributes) {
if (peer.local) {
peer.attributes.srcObject = stream;
if (peer.attributes.srcObject) {
peer.attributes.srcObject.getAudioTracks().forEach((track) => {
track.enabled = !peer.attributes.muted;
});
}
audioPeers.push(
<Audio
className={peer.local ? 'Local' : 'Remote'}
key={`Peer-${id}`}
className="Remote"
autoPlay='autoplay'
controls
{...peer.attributes}/>
);
} else {
audioPeers.push(
<Audio
className={peer.local ? 'Local' : 'Remote'}
key={`Peer-${id}`}
autoPlay='autoplay'
controls
muted={peer.attributes.muted}
{...peer.attributes}/>
);
}
}
}
return <div className="MediaAgent">
<Audio className="local" srcObject={stream} autoPlay="autoplay" muted={true} controls/>
{ audioPeers }
</div>;
}
const MediaControl = ({isSelf, peer}) => {
const [mute, setMute] = useState(false);
const [mic, setMic] = useState(true);
const [noAudio, setNoAudio] = useState(false);
const peers = useContext(MediaContext);
/*
if (game.players[color].status !== 'Active') {
return <div className="MediaControl"/>;
}
*/
const { peers, setPeers } = useContext(GlobalContext);
const [control, setControl] = useState(undefined);
const toggleMic = (event) => {
if (peer in peers) {
console.log('found color in peers!');
}
setMic(!mic);
event.stopPropagation();
}
useEffect(() => {
// console.log(peer, peers);
setControl(peers[peer]);
}, [peer, peers, setControl]);
const toggleMute = (event) => {
if (peer in peers) {
console.log('found color in peers!');
if (control) {
control.muted = !control.muted;
}
setMute(!mute);
const update = Object.assign({}, peers);
if (isSelf) {
update[peer].attributes.muted = control.muted;
} else {
update[peer].attributes.muted = control.muted;
}
console.log(`MediaControl - toggleMute`, update);
setPeers(update);
event.stopPropagation();
}
return <MediaContext.Provider value={peers}>
<div className="MediaControl">
{ isSelf && <div onClick={toggleMic}>
{ mic && <MicOff color={noAudio ? 'disabled' : 'primary'}/> }
{ !mic && <Mic color={noAudio ? 'disabled' : 'primary'}/> }
if (!control) {
return <div className="MediaControl">
{ isSelf && <MicOff color={'disabled'}/> }
{ !isSelf && <VolumeOff color={'disabled'}/> }
</div>;
}
return <div className="MediaControl">
{ isSelf && <div onClick={toggleMute}>
{ control.muted && <MicOff color={'primary'}/> }
{ !control.muted && <Mic color={'primary'}/> }
</div> }
<div onClick={toggleMute}>
{ mute && <VolumeOff color={noAudio ? 'disabled' : 'primary'}/> }
{ !mute && <VolumeUp color={noAudio ? 'disabled' : 'primary'}/> }
</div>
</div>
</MediaContext.Provider>;
{ !isSelf && <div onClick={toggleMute}>
{ control.muted && <VolumeOff color={'primary'}/> }
{ !control.muted && <VolumeUp color={'primary'}/> }
</div> }
</div>;
};
export { MediaControl, MediaAgent, MediaContext };
export { MediaControl, MediaAgent };

7
client/src/PingPong.css Normal file
View File

@ -0,0 +1,7 @@
.PingPong {
position: absolute;
display: flex;
top: 0;
left: 0;
z-index: 100;
}

41
client/src/PingPong.js Normal file
View File

@ -0,0 +1,41 @@
import React, { useState, useContext, useEffect, useRef } from "react";
import { GlobalContext} from "./GlobalContext.js";
import "./PingPong.css";
const PingPong = () => {
const [ count, setCount ] = useState(0);
const global = useContext(GlobalContext);
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'ping':
global.ws.send(JSON.stringify({ type: 'pong', timestamp: data.ping }));
setCount(count + 1);
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => { refWsMessage.current = onWsMessage; });
useEffect(() => {
if (!global.ws) {
return;
}
const cbMessage = e => refWsMessage.current(e);
global.ws.addEventListener('message', cbMessage);
return () => {
global.ws.removeEventListener('message', cbMessage);
}
}, [global.ws, refWsMessage]);
return <div className="PingPong">
Game {global.gameId}: {global.name} {global.ws ? 'has socket' : 'no socket' } { count } pings
</div>;
}
export { PingPong };

81
client/src/PlayerList.css Normal file
View File

@ -0,0 +1,81 @@
.PlayerList {
display: flex;
position: relative;
padding: 0.5em;
user-select: none;
}
.PlayerList .PlayerSelector .PlayerColor {
width: 1em;
height: 1em;
}
.PlayerList .PlayerSelector {
display: inline-flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: space-between;
}
.PlayerList .PlayerSelector.MuiList-padding {
padding: 0;
}
.PlayerList .PlayerSelector .MuiTypography-body1 {
font-size: 0.8rem;
/* white-space: nowrap;*/
}
.PlayerList .PlayerSelector .MuiTypography-body2 {
font-size: 0.7rem;
white-space: nowrap;
}
.PlayerList .PlayerEntry {
border: 1px solid rgba(0,0,0,0);
border-radius: 0.5em;
min-width: 10em;
justify-content: space-between;
}
.PlayerList .PlayerSelector .PlayerEntry {
flex: 1 1 0px;
align-items: center;
display: inline-flex;
flex-direction: row;
min-width: 10em;
}
.PlayerList .PlayerEntry[data-selectable=true]:hover {
border-color: rgba(0,0,0,0.5);
cursor: pointer;
}
.PlayerList .PlayerEntry[data-selected=true] {
background-color: rgba(255, 255, 0, 0.5);
}
.PlayerList .PlayerEntry > *:last-child {
display: flex;
flex-grow: 1;
}
.PlayerList .Players .PlayerToggle {
min-width: 5em;
display: inline-flex;
align-items: flex-end;
flex-direction: column;
}
.PlayerList .PlayerName {
padding: 0.5em;
}
.PlayerList .Players > * {
width: 100%;
}
.PlayerList .Players .nameInput {
flex-grow: 1;
}

100
client/src/PlayerList.js Normal file
View File

@ -0,0 +1,100 @@
import React, { useState, useEffect, useContext, useRef } from "react";
import "./PlayerList.css";
import PlayerColor from './PlayerColor.js';
import Paper from '@material-ui/core/Paper';
import List from '@material-ui/core/List';
import { MediaControl } from "./MediaControl.js";
import { GlobalContext } from "./GlobalContext.js";
const PlayerList = () => {
const { ws, name } = useContext(GlobalContext);
const [players, setPlayers] = useState({});
const [state, setState] = useState('lobby');
const [color, setColor] = useState(undefined);
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'game-update':
if (data.update.players) {
for (let key in data.update.players) {
if (data.update.players[key].name === name) {
setColor(key);
break;
}
}
setPlayers(data.update.players);
}
if (data.update.state) {
setState(data.update.state);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => { refWsMessage.current = onWsMessage; });
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = e => refWsMessage.current(e);
ws.addEventListener('message', cbMessage);
return () => {
ws.removeEventListener('message', cbMessage);
}
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(JSON.stringify({
type: 'get',
fields: [ 'state', 'players' ]
}));
}, [ws]);
const toggleSelected = (key) => {
ws.send(JSON.stringify({
type: 'set',
field: 'color',
value: color === key ? "" : key
}));
}
const playerElements = [];
const inLobby = state === 'lobby';
for (let key in players) {
const item = players[key];
const name = item.name;
const selectable = inLobby && (item.status === 'Not active' || color === key);
playerElements.push(
<div
data-selectable={selectable}
data-selected={key === color}
className="PlayerEntry"
onClick={() => { inLobby && selectable && toggleSelected(key) }}
key={`player-${key}`}>
<PlayerColor color={key}/>{name ? name : 'Available' }
{ name && <MediaControl peer={name} isSelf={key === color}/> }
{ !name && <div></div> }
</div>
);
}
return (
<Paper className="PlayerList">
<List className="PlayerSelector">
{ playerElements }
</List>
</Paper>
);
}
export { PlayerList };

17
client/src/PlayerName.css Normal file
View File

@ -0,0 +1,17 @@
.PlayerName {
padding: 0.5em;
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
}
.PlayerName > .nameInput {
margin-right: 1em;
flex: 1;
max-width: 30em;
}
.PlayerName > Button {
background: lightblue;
}

82
client/src/PlayerName.js Normal file
View File

@ -0,0 +1,82 @@
import React, { useState, useEffect, useContext, useRef } from "react";
import "./PlayerName.css";
import Paper from '@material-ui/core/Paper';
import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button';
import { GlobalContext } from "./GlobalContext.js";
const PlayerName = () => {
const global = useContext(GlobalContext);
const [name, setName] = useState(global.name ? global.name : "");
const [error, setError] = useState("");
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'game-update':
if ('name' in data.update && data.update.name !== name) {
setName(data.update.name);
}
break;
case 'player-name':
if ('error' in data) {
setError(data.error);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => { refWsMessage.current = onWsMessage; });
useEffect(() => {
if (!global.ws) {
return;
}
const cbMessage = e => refWsMessage.current(e);
global.ws.addEventListener('message', cbMessage);
return () => {
global.ws.removeEventListener('message', cbMessage);
}
}, [global.ws, refWsMessage]);
const sendName = () => {
if (name !== global.name && name !== "") {
if (error) {
setError("");
}
global.ws.send(JSON.stringify({ type: 'player-name', name }));
}
}
const nameChange = (event) => {
setName(event.target.value);
}
const nameKeyPress = (event) => {
if (event.key === "Enter") {
sendName();
}
}
return (
<Paper className="PlayerName">
{ error !== "" && <div>Error: {error}</div> }
<TextField className="nameInput"
onChange={nameChange}
onKeyPress={nameKeyPress}
label="Enter your name"
variant="outlined"
value={name}
/>
<Button onClick={() => sendName()}>Set</Button>
</Paper>
);
};
export { PlayerName };

View File

@ -9,6 +9,13 @@
background-image: url("./assets/tabletop.png");
}
.Table .Chat {
top: 0;
right: 0;
bottom: 0;
width: 30rem;
}
.Loading {
position: absolute;
top: 1em;
@ -230,96 +237,6 @@
.Game.lobby {
}
/*
* Game
* Message
* Players
* Chat
* Action
*/
.Players {
flex: 1 0;
overflow: hidden;
padding: 0.5em;
user-select: none;
}
.PlayerSelector .PlayerColor {
width: 1em;
height: 1em;
}
.PlayerSelector {
display: inline-flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: space-between;
}
.PlayerSelector.MuiList-padding {
padding: 0;
}
.PlayerSelector .MuiTypography-body1 {
font-size: 0.8rem;
/* white-space: nowrap;*/
}
.PlayerSelector .MuiTypography-body2 {
font-size: 0.7rem;
white-space: nowrap;
}
.Players .PlayerEntry {
border: 1px solid rgba(0,0,0,0);
border-radius: 0.5em;
min-width: 10em;
justify-content: space-between;
}
.PlayerSelector .PlayerEntry {
flex: 1 1 0px;
align-items: center;
display: inline-flex;
flex-direction: row;
min-width: 10em;
}
.Players .PlayerEntry[data-selectable=true]:hover {
border-color: rgba(0,0,0,0.5);
cursor: pointer;
}
.Players .PlayerEntry[data-selected=true] {
background-color: rgba(255, 255, 0, 0.5);
}
.Players .PlayerEntry > *:last-child {
display: flex;
flex-grow: 1;
}
.Players .PlayerToggle {
min-width: 5em;
display: inline-flex;
align-items: flex-end;
flex-direction: column;
}
.PlayerName {
padding: 0.5em;
}
.Players > * {
width: 100%;
}
.Players .nameInput {
flex-grow: 1;
}
.Development {
position: relative;
display: inline-block;
@ -380,7 +297,9 @@ button {
display: inline-flex;
}
.PlayerName {
padding: 0.5em;
display: flex;
align-items: center;
justify-content: center;

View File

@ -466,42 +466,52 @@ const getPlayer = (game, color) => {
return game.players[color];
};
const getSession = (game, session) => {
const getSession = (game, reqSession) => {
if (!game.sessions) {
game.sessions = {};
}
if (!session.player_id) {
session.player_id = crypto.randomBytes(16).toString('hex');
if (!reqSession.player_id) {
reqSession.player_id = crypto.randomBytes(16).toString('hex');
}
const id = session.player_id;
const id = reqSession.player_id;
/* If this session is not yet in the game, add it and set the player's name */
if (!(id in game.sessions)) {
game.sessions[id] = {
name: undefined,
color: undefined,
player: undefined
player: undefined,
lastActive: Date.now()
};
}
/* Expire old unused sessions */
for (let id in game.sessions) {
const tmp = game.sessions[id];
if (tmp.color || tmp.name || tmp.player) {
for (let _id in game.sessions) {
const _session = game.sessions[_id];
if (_session.color || _session.name || _session.player) {
continue;
}
if (tmp.player_id === session.player_id) {
if (_id === id) {
continue;
}
/* 10 minutes */
if (tmp.lastActive && tmp.lastActive < Date.now() - 10 * 60 * 1000) {
console.log(`Expiring old session ${id}`);
delete game.sessions[id];
const age = Date.now() - _session.lastActive;
if (age > 10 * 60 * 1000) {
console.log(`Expiring old session ${_id}: ${age/(60 * 1000)} minutes`);
delete game.sessions[_id];
if (_id in game.sessions) {
console.log('delete DID NOT WORK!');
}
}
}
session.lastActive = Date.now();
if (session.player) {
session.player.lastActive = session.lastActive;
}
return game.sessions[id];
};
@ -514,6 +524,8 @@ const loadGame = async (id) => {
return games[id];
}
console.log(`Loading game from disk`);
let game = await readFile(`games/${id}`)
.catch(() => {
return;
@ -796,9 +808,11 @@ const setPlayerName = (game, session, name) => {
if (tmp.name && tmp.name.toLowerCase() === name.toLowerCase()) {
if (!tmp.player || (Date.now() - tmp.player.lastActive) > 60000) {
rejoin = true;
Object.assign(session, tmp);
/* Update the session object from tmp, but retain websocket
* from active session */
Object.assign(session, tmp, { ws: session.ws });
console.log(`${name} has been reallocated to a new session.`);
console.log({ old: game.sessions[key], new: session });
// console.log({ old: game.sessions[key], new: session });
delete game.sessions[key];
} else {
return `${name} is already taken and has been active in the last minute.`;
@ -917,6 +931,8 @@ const setPlayerColor = (game, session, color) => {
session.player.status = `Active`;
session.player.lastActive = Date.now();
session.color = color;
game.players[color].name = session.name;
addChatMessage(game, session, `${session.name} has chosen to play as ${colorToWord(color)}.`);
const afterActive = getActiveCount(game);
@ -937,10 +953,19 @@ const addActivity = (game, session, message) => {
}
const addChatMessage = (game, session, message) => {
let now = Date.now();
let lastTime = 0;
if (game.chat.length) {
lastTime = game.chat[game.chat.length - 1].date;
}
if (now === lastTime) {
now++;
}
game.chat.push({
from: session ? session.name : undefined,
color: session ? session.color : undefined,
date: Date.now(),
date: now,
message: message
});
};
@ -2752,7 +2777,7 @@ const join = (peers, session, id) => {
for (let peer in peers) {
peers[peer].send(JSON.stringify({
type: 'addPeer',
data: { 'peer_id': peer, 'should_create_offer': false }
data: { 'peer_id': session.name, 'should_create_offer': false }
}));
ws.send(JSON.stringify({
@ -2765,13 +2790,17 @@ const join = (peers, session, id) => {
peers[session.name] = ws;
};
const getName = (session) => {
return session.name ? session.name : "Unnamed";
}
const part = (peers, session, id) => {
const ws = session.ws;
console.log(`${id}:${session.name} - Audio part.`);
console.log(`${id}:${getName(session)} - Audio part.`);
if (!(session.name in peers)) {
console.log(`${id}:${session.name} - Does not exist in game audio.`);
console.log(`${id}:${getName(session)} - Does not exist in game audio.`);
return;
}
@ -2791,11 +2820,48 @@ const part = (peers, session, id) => {
}
};
const saveGame = async (game) => {
/* Shallow copy game, filling its sessions with a shallow copy of sessions so we can then
* delete the player field from them */
const reducedGame = Object.assign({}, game, { sessions: {} }),
reducedSessions = [];
for (let id in game.sessions) {
const reduced = Object.assign({}, game.sessions[id]);
if (reduced.player) {
delete reduced.player;
}
if (reduced.ws) {
delete reduced.ws;
}
if (reduced.keepAlive) {
delete reduced.keepAlive;
}
reducedGame.sessions[id] = reduced;
/* Do not send session-id as those are secrets */
reducedSessions.push(reduced);
}
/* Save per turn while debugging... */
await writeFile(`games/${game.id}.${game.turns}`, JSON.stringify(reducedGame, null, 2))
.catch((error) => {
console.error(`Unable to write to games/${game.id}`);
console.error(error);
});
await writeFile(`games/${game.id}`, JSON.stringify(reducedGame, null, 2))
.catch((error) => {
console.error(`Unable to write to games/${game.id}`);
console.error(error);
});
}
router.ws("/ws/:id", async (ws, req) => {
const { id } = req.params;
const gameId = id;
ws.id = req.session.player_id;
console.log(`${gameId} - New connection from client.`);
if (!(id in audio)) {
audio[id] = {}; /* List of peer sockets using session.name as index. */
console.log(`${id} - New Game Audio`);
@ -2807,7 +2873,7 @@ router.ws("/ws/:id", async (ws, req) => {
* we may miss the first messages from clients */
ws.on('error', async (event) => {
console.error(`WebSocket error: `, event.message);
const game = await loadGame(id);
const game = await loadGame(gameId);
if (game) {
const session = getSession(game, req.session);
if (session && session.ws) {
@ -2819,14 +2885,14 @@ router.ws("/ws/:id", async (ws, req) => {
ws.on('open', async (event) => {
console.log(`WebSocket open: `, event.message);
const game = await loadGame(id);
const game = await loadGame(gameId);
if (game) {
resetDisconnectCheck(game, req);
}
});
ws.on('close', async (event) => {
const game = await loadGame(id);
const game = await loadGame(gameId);
if (game) {
const session = getSession(game, req.session);
if (session && session.ws) {
@ -2836,7 +2902,7 @@ router.ws("/ws/:id", async (ws, req) => {
}
session.ws.close();
session.ws = undefined;
console.log(`WebSocket closed for ${session.name}`);
console.log(`WebSocket closed for ${getName(session)}`);
}
}
@ -2844,8 +2910,14 @@ router.ws("/ws/:id", async (ws, req) => {
});
ws.on('message', async (message) => {
const data = JSON.parse(message);
const game = await loadGame(id);
let data;
try {
data = JSON.parse(message);
} catch (error) {
console.error(error, message);
return;
}
const game = await loadGame(gameId);
if (!game) {
console.error(`Unable to load/create new game for WS request.`);
return;
@ -2860,6 +2932,8 @@ router.ws("/ws/:id", async (ws, req) => {
session.ws = ws;
}
let error = '', update;
switch (data.type) {
case 'join':
join(audio[id], session, id);
@ -2876,14 +2950,16 @@ router.ws("/ws/:id", async (ws, req) => {
}
const { peer_id, ice_candidate } = data.config;
console.log(`${id} - relayICECandidate ${session.name} to ${peer_id}`,
console.log(`${id} - relayICECandidate ${getName(session)} to ${peer_id}`,
ice_candidate);
message = JSON.stringify({
type: 'iceCandidate',
data: {'peer_id': getName(session), 'ice_candidate': ice_candidate }
});
if (peer_id in audio[id]) {
audio[id][peer_id].send(JSON.stringify({
type: 'iceCandidate',
data: {'peer_id': session.name, 'ice_candidate': ice_candidate }
}));
audio[id][peer_id].send(message);
}
} break;
@ -2893,37 +2969,164 @@ router.ws("/ws/:id", async (ws, req) => {
return;
}
const { peer_id, session_description } = data.config;
console.log(`${id} - relaySessionDescription ${session.name} to ${peer_id}`,
console.log(`${id} - relaySessionDescription ${getName(session)} to ${peer_id}`,
session_description);
message = JSON.stringify({
type: 'sessionDescription',
data: {'peer_id': getName(session), 'session_description': session_description }
});
if (peer_id in audio[id]) {
audio[id][peer_id].send(JSON.stringify({
type: 'sessionDescription',
data: {'peer_id': session.name, 'session_description': session_description }
}));
audio[id][peer_id].send(message);
}
} break;
case 'pong':
resetDisconnectCheck(game, req);
break;
case 'game-update':
console.log(`Player ${session.name ? session.name : 'Unnamed'} requested a game update.`);
resetDisconnectCheck(game, req);
sendGame(req, undefined, game, undefined, ws);
break;
console.log(`Player ${getName(session)} requested a game update.`);
message = JSON.stringify({
type: 'game-update',
update: filterGameForPlayer(game, session)
});
session.ws.send(message);
break;
case 'player-name':
console.log(`${id}:${getName(session)} - setPlayerName - ${data.name}`)
error = setPlayerName(game, session, data.name);
if (error) {
session.ws.send(JSON.stringify({ error }));
break;
}
update = {};
session.name = data.name;
update.name = session.name
if (session.color && session.color in game.players) {
game.players[session.color].name = session.name;
update.players = game.players;
}
for (let key in game.sessions) {
const _session = game.sessions[key];
if (!_session.ws) {
continue;
}
_session.ws.send(JSON.stringify({
type: 'game-update',
update: filterGameForPlayer(game, _session)
}));
}
console.log('TODO: support only change update. fire update to all players in game');
await saveGame(game);
break;
case 'set':
console.log(`${id}:${getName(session)} - ${data.type}`);
update = {};
switch (data.field) {
case 'color':
error = setPlayerColor(game, session, data.value);
if (error) {
session.ws.send(JSON.stringify({ error }));
break;
}
for (let key in game.sessions) {
const _session = game.sessions[key];
if (!_session.ws) {
continue;
}
_session.ws.send(JSON.stringify({
type: 'game-update',
update: filterGameForPlayer(game, _session)
}));
}
console.log('TODO: support only change update. fire update to all players in game');
await saveGame(game);
break;
default:
console.warn(`WARNING: Requested SET unsupported field: ${field}`);
break;
}
break;
case 'get':
console.log(`${id}:${getName(session)} - ${data.type}`);
update = {};
data.fields.forEach((field) => {
switch (field) {
case 'chat':
case 'startTime':
case 'state':
update[field] = game[field];
break;
case 'players':
update[field] = game[field];
for (let color in game.players) {
if (game.players[color].status !== 'Active') {
// continue;
}
update.players[color] = game.players[color];
}
break;
default:
console.warn(`WARNING: Requested GET unsupported field: ${field}`);
if (field in game) {
update[field] = game.field;
}
break;
}
});
message = JSON.stringify({
type: 'game-update',
update
});
session.ws.send(message);
break;
case 'chat':
console.log(`${id}:${session.id} - ${data.type} - ${data.message}`)
/* Update the chat array */
addChatMessage(game, session, `${session.name}: ${data.message}`);
/* Send the update to all players */
message = JSON.stringify({
type: 'game-update',
update: {
chat: game.chat
}
});
for (let key in game.sessions) {
const _session = game.sessions[key];
if (!_session.ws) {
continue;
}
_session.ws.send(message);
}
/* Save the current game state to disk */
await saveGame(game);
break;
}
});
/* This will result in the node tick moving forward; if we haven't already
* setup the event handlers, a 'message' could come through prior to this
* completing */
const game = await loadGame(id);
const game = await loadGame(gameId);
if (!game) {
console.error(`Unable to load/create new game for WS request.`);
return;
}
const session = getSession(game, req.session);
if (!session) {
console.error(`Session should never be empty after getSession`,
game, req.session);
}
resetDisconnectCheck(game, req);
@ -2941,20 +3144,6 @@ router.ws("/ws/:id", async (ws, req) => {
}
});
router.get("/:id", async (req, res/*, next*/) => {
const { id } = req.params;
// console.log("GET games/" + id);
let game = await loadGame(id);
if (game) {
return sendGame(req, res, game)
}
game = createGame(id);
return sendGame(req, res, game);
});
const debugChat = (game, preamble) => {
preamble = `Degug ${preamble.trim()}`;
@ -3026,7 +3215,7 @@ const sendGameToSession = (session, reducedSessions, game, reducedGame, error, r
if (!session.ws) {
console.error(`No WebSocket connection to ${session.name}`);
} else {
console.log(`Sending update to ${session.name}`);
console.log(`Sending update to ${session.id}:${session.name ? session.name : 'Unnamed'}`);
session.ws.send(JSON.stringify({
type: 'game-update',
update: playerGame
@ -3064,15 +3253,6 @@ const sendGame = async (req, res, game, error, wsUpdate) => {
};
}
/* Ensure chat messages have a unique date: stamp as it is used as the index key */
let lastTime = 0;
if (game.chat) game.chat.forEach((message) => {
if (message.date <= lastTime) {
message.date = lastTime + 1;
}
lastTime = message.date;
});
/* Calculate points and determine if there is a winner */
for (let key in game.players) {
const player = game.players[key];
@ -3177,6 +3357,110 @@ const sendGame = async (req, res, game, error, wsUpdate) => {
}
}
const filterGameForPlayer = (game, session) => {
const active = getActiveCount(game);
game.active = active;
/* Calculate points and determine if there is a winner */
for (let key in game.players) {
const player = game.players[key];
if (player.status === 'Not active') {
continue;
}
player.points = 0;
if (key === game.longestRoad) {
player.points += 2;
}
if (key === game.largestArmy) {
player.points += 2;
}
player.points += MAX_SETTLEMENTS - player.settlements;
player.points += 2 * (MAX_CITIES - player.cities);
player.unplayed = 0;
player.potential = 0;
player.development.forEach(card => {
if (card.type === 'vp') {
if (card.played) {
player.points++;
} else {
player.potential++;
}
}
if (!card.played) {
player.unplayed++;
}
});
console.log('TODO: Move game win state to card play section');
if (!game.winner && (player.points >= 10 && session.color === key)) {
game.winner = key;
game.state = 'winner';
delete game.turn.roll;
}
}
/* If the game isn't in a win state, do not share development card information
* with other players */
if (game.state !== 'winner') {
for (let key in game.players) {
const player = game.players[key];
if (player.status === 'Not active') {
continue;
}
delete player.potential;
}
}
/* Shallow copy game, filling its sessions with a shallow copy of
* sessions so we can then delete the player field from them */
const reducedGame = Object.assign({}, game, { sessions: {} }),
reducedSessions = [];
for (let id in game.sessions) {
const reduced = Object.assign({}, game.sessions[id]);
if (reduced.player) {
delete reduced.player;
}
if (reduced.ws) {
delete reduced.ws;
}
if (reduced.keepAlive) {
delete reduced.keepAlive;
}
reducedGame.sessions[id] = reduced;
/* Do not send session-id as those are secrets */
reducedSessions.push(reduced);
}
const player = session.player ? session.player : undefined;
if (player) {
player.haveResources = player.wheat > 0 ||
player.brick > 0 ||
player.sheep > 0 ||
player.stone > 0 ||
player.wood > 0;
}
/* Strip out data that should not be shared with players */
delete reducedGame.developmentCards;
return Object.assign(reducedGame, {
timestamp: Date.now(),
status: session.error ? session.error : "success",
name: session.name,
color: session.color,
order: (session.color in game.players) ? game.players[session.color].order : 0,
player: player,
sessions: reducedSessions,
layout: layout
});
}
const robberSteal = (game, color, type) => {
if (!game.stolen) {
game.stolen = {};
@ -3330,18 +3614,13 @@ const createGame = (id) => {
return game;
};
router.post("/:id?", (req, res/*, next*/) => {
router.post("/", (req, res/*, next*/) => {
console.log("POST games/");
const { id } = req.params;
if (id && id in games) {
const error = `Can not create new game for ${id} -- it already exists.`
console.error(error);
return res.status(400).send(error);
}
const game = createGame();
const game = createGame(id);
saveGame(game);
return sendGame(req, res, game);
return res.status(200).send(filterGameForPlayer(game, session));
});
const setBeginnerGame = (game) => {