Audio almost working; mic is doing the right thing
Signed-off-by: James Ketrenos <james_eikona@ketrenos.com>
This commit is contained in:
parent
4909647e75
commit
f6b2ada2ee
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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 };
|
12
client/src/GlobalContext.js
Normal file
12
client/src/GlobalContext.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
const global = {
|
||||
gameId: undefined,
|
||||
ws: undefined,
|
||||
name: "",
|
||||
chat: []
|
||||
};
|
||||
|
||||
const GlobalContext = createContext(global);
|
||||
|
||||
export { GlobalContext, global };
|
@ -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;
|
||||
}
|
||||
|
@ -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
7
client/src/PingPong.css
Normal file
@ -0,0 +1,7 @@
|
||||
.PingPong {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
}
|
41
client/src/PingPong.js
Normal file
41
client/src/PingPong.js
Normal 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
81
client/src/PlayerList.css
Normal 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
100
client/src/PlayerList.js
Normal 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
17
client/src/PlayerName.css
Normal 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
82
client/src/PlayerName.js
Normal 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 };
|
@ -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;
|
||||
|
@ -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) => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user