diff --git a/client/src/App.css b/client/src/App.css index cf8afa0..8a57c7a 100755 --- a/client/src/App.css +++ b/client/src/App.css @@ -5,4 +5,40 @@ body { #root { width: 100vw; height: 100vh; -} \ No newline at end of file +} + +.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; +} + diff --git a/client/src/App.js b/client/src/App.js index 1cd5293..2f4de51 100755 --- a/client/src/App.js +++ b/client/src/App.js @@ -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
{ error }
; + } + + return + + +
+
board goes here
+
+ { name === "" && } + { name !== "" && } + { name !== "" && } +
+
+
; +}; + +const App = () => { console.log(`Base: ${base}`); return ( - } path={`${base}/games/:id`}/> + } path={`${base}/games/:gameId`}/> } path={`${base}`}/> diff --git a/client/src/Chat.css b/client/src/Chat.css index b133e22..3c9f0f0 100644 --- a/client/src/Chat.css +++ b/client/src/Chat.css @@ -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 { diff --git a/client/src/Chat.js b/client/src/Chat.js index 819e428..0f95544 100644 --- a/client/src/Chat.js +++ b/client/src/Chat.js @@ -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 chatInput = (event) => { +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 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 ( { messages } } variant="outlined"/> + label={startTime !== 0 && } + variant="outlined"/> ); } -export default Chat; \ No newline at end of file +export { Chat }; \ No newline at end of file diff --git a/client/src/GlobalContext.js b/client/src/GlobalContext.js new file mode 100644 index 0000000..479ac8a --- /dev/null +++ b/client/src/GlobalContext.js @@ -0,0 +1,12 @@ +import { createContext } from "react"; + +const global = { + gameId: undefined, + ws: undefined, + name: "", + chat: [] +}; + +const GlobalContext = createContext(global); + +export { GlobalContext, global }; \ No newline at end of file diff --git a/client/src/MediaControl.css b/client/src/MediaControl.css index 5b37014..d7f6afe 100644 --- a/client/src/MediaControl.css +++ b/client/src/MediaControl.css @@ -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; +} diff --git a/client/src/MediaControl.js b/client/src/MediaControl.js index 8b1225a..287cc68 100644 --- a/client/src/MediaControl.js +++ b/client/src/MediaControl.js @@ -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