415 lines
13 KiB
JavaScript
Executable File
415 lines
13 KiB
JavaScript
Executable File
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
import {
|
|
BrowserRouter as Router,
|
|
Route,
|
|
Routes,
|
|
useParams
|
|
} from "react-router-dom";
|
|
|
|
import Paper from '@material-ui/core/Paper';
|
|
import Button from '@material-ui/core/Button';
|
|
|
|
import { GlobalContext } from "./GlobalContext.js";
|
|
//import { PingPong } from "./PingPong.js";
|
|
import { PlayerList } from "./PlayerList.js";
|
|
import { Chat } from "./Chat.js";
|
|
import { Board } from "./Board.js";
|
|
import { Actions } from "./Actions.js";
|
|
import { base, gamesPath } from './Common.js';
|
|
import { GameOrder } from "./GameOrder.js";
|
|
import { Activities } from "./Activities.js";
|
|
import { SelectPlayer } from "./SelectPlayer.js";
|
|
import { PlayersStatus } from "./PlayersStatus.js";
|
|
import { ViewCard } from "./ViewCard.js";
|
|
import { ChooseCard } from "./ChooseCard.js";
|
|
import { Hand } from "./Hand.js";
|
|
import { Trade } from "./Trade.js";
|
|
import { Winner } from "./Winner.js";
|
|
import { HouseRules } from "./HouseRules.js";
|
|
|
|
import history from "./history.js";
|
|
import "./App.css";
|
|
import equal from "fast-deep-equal";
|
|
|
|
const Table = () => {
|
|
const params = useParams();
|
|
const [ gameId, setGameId ] = useState(params.gameId ? params.gameId : undefined);
|
|
const [ ws, setWs ] = useState(); /* tracks full websocket lifetime */
|
|
const [connection, setConnection] = useState(undefined); /* set after ws is in OPEN */
|
|
const [retryConnection, setRetryConnection] = useState(true); /* set when connection should be re-established */
|
|
const [ name, setName ] = useState("");
|
|
const [ error, setError ] = useState(undefined);
|
|
const [ warning, setWarning ] = useState(undefined);
|
|
const [loaded, setLoaded] = useState(false);
|
|
|
|
const [state, setState] = useState(undefined);
|
|
const [color, setColor] = useState(undefined);
|
|
const [priv, setPriv] = useState(undefined);
|
|
const [buildActive, setBuildActive] = useState(false);
|
|
const [tradeActive, setTradeActive] = useState(false);
|
|
const [cardActive, setCardActive] = useState(undefined);
|
|
const [houseRulesActive, setHouseRulesActive] = useState(undefined);
|
|
const [winnerDismissed, setWinnerDismissed] = useState(undefined);
|
|
const [global, setGlobal] = useState({});
|
|
const [count, setCount] = useState(0);
|
|
const fields = [ 'id', 'state', 'color', 'name', 'private' ];
|
|
|
|
const onWsOpen = (event) => {
|
|
console.log(`ws: open`);
|
|
setError("");
|
|
|
|
/* We do not set the socket as connected until the 'open' message
|
|
* comes through */
|
|
setConnection(ws);
|
|
|
|
/* Request a full game-update
|
|
* We only need gameId and name for App.js, however in the event
|
|
* of a network disconnect, we need to refresh the entire game
|
|
* state on reload so all bound components reflect the latest
|
|
* state */
|
|
event.target.send(JSON.stringify({
|
|
type: 'game-update'
|
|
}));
|
|
event.target.send(JSON.stringify({
|
|
type: 'get',
|
|
fields
|
|
}));
|
|
};
|
|
|
|
const onWsMessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
switch (data.type) {
|
|
case 'error':
|
|
console.error(`App - error`, data.error);
|
|
setError(data.error);
|
|
break;
|
|
case 'warning':
|
|
console.warn(`App - warning`, data.warning);
|
|
setWarning(data.warning);
|
|
setTimeout(() => {
|
|
console.log(`todo: stack warnings in a window and have them disappear one at a time.`);
|
|
console.log(`app - clearing warning`);
|
|
setWarning("");
|
|
}, 3000);
|
|
break;
|
|
case 'game-update':
|
|
if (!loaded) {
|
|
setLoaded(true);
|
|
}
|
|
console.log(`ws: message - ${data.type}`, data.update);
|
|
|
|
if ('private' in data.update && !equal(priv, data.update.private)) {
|
|
const priv = data.update.private;
|
|
if (priv.name !== name) {
|
|
console.log(`App - setting name (via private): ${priv.name}`);
|
|
setName(priv.name);
|
|
}
|
|
if (priv.color !== color) {
|
|
console.log(`App - setting color (via private): ${priv.color}`);
|
|
setColor(priv.color);
|
|
}
|
|
setPriv(priv);
|
|
}
|
|
|
|
if ('name' in data.update) {
|
|
if (data.update.name) {
|
|
console.log(`App - setting name: ${data.update.name}`);
|
|
setName(data.update.name);
|
|
} else {
|
|
setWarning("");
|
|
setError("");
|
|
setPriv(undefined);
|
|
}
|
|
}
|
|
if ('id' in data.update && data.update.id !== gameId) {
|
|
console.log(`App - setting gameId ${data.update.id}`);
|
|
setGameId(data.update.id);
|
|
}
|
|
if ('state' in data.update && data.update.state !== state) {
|
|
console.log(`App - setting game state: ${data.update.state}`);
|
|
if (data.update.state !== 'winner' && winnerDismissed) {
|
|
setWinnerDismissed(false);
|
|
}
|
|
setState(data.update.state);
|
|
}
|
|
if ('color' in data.update && data.update.color !== color) {
|
|
console.log(`App - setting color: ${color}`);
|
|
setColor(data.update.color);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
const sendUpdate = (update) => {
|
|
ws.send(JSON.stringify(update));
|
|
};
|
|
|
|
const cbResetConnection = useCallback(() => {
|
|
let timer = 0;
|
|
function reset() {
|
|
timer = 0;
|
|
setRetryConnection(true);
|
|
};
|
|
return _ => {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
timer = setTimeout(reset, 5000);
|
|
};
|
|
}, [setRetryConnection]);
|
|
|
|
const resetConnection = cbResetConnection();
|
|
|
|
if (global.ws !== connection
|
|
|| global.name !== name
|
|
|| global.gameId !== gameId) {
|
|
console.log(`board - (app) - setting global`, global, {
|
|
ws: connection,
|
|
name,
|
|
gameId
|
|
});
|
|
setGlobal({
|
|
ws: connection,
|
|
name,
|
|
gameId
|
|
});
|
|
}
|
|
|
|
const onWsError = (event) => {
|
|
console.error(`ws: error`, event);
|
|
const error = `Connection to Ketr Ketran game server failed! ` +
|
|
`Connection attempt will be retried every 5 seconds.`;
|
|
setError(error);
|
|
setGlobal(Object.assign({}, global, { ws: undefined }));
|
|
setWs(undefined); /* clear the socket */
|
|
setConnection(undefined); /* clear the connection */
|
|
resetConnection();
|
|
};
|
|
|
|
const onWsClose = (event) => {
|
|
const error = `Connection to Ketr Ketran game was lost. ` +
|
|
`Attempting to reconnect...`;
|
|
console.warn(`ws: close`);
|
|
setError(error);
|
|
setGlobal(Object.assign({}, global, { ws: undefined }));
|
|
setWs(undefined); /* clear the socket */
|
|
setConnection(undefined); /* clear the connection */
|
|
resetConnection();
|
|
};
|
|
|
|
/* 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 your browser in a few seconds.`;
|
|
console.error(error);
|
|
setError(error);
|
|
throw new Error(error);
|
|
}
|
|
return res.json();
|
|
}).then((update) => {
|
|
if (update.id !== gameId) {
|
|
console.log(`Game available: ${update.id}`);
|
|
history.push(`${gamesPath}/${update.id}`);
|
|
setGameId(update.id);
|
|
}
|
|
}).catch((error) => {
|
|
console.error(error);
|
|
});
|
|
}, [ 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;
|
|
}
|
|
|
|
const unbind = () => {
|
|
console.log(`table - unbind`);
|
|
}
|
|
|
|
console.log(`table - bind`);
|
|
|
|
if (!ws && !connection && retryConnection) {
|
|
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}?${count}`;
|
|
console.log(`Attempting WebSocket connection to ${new_uri}`);
|
|
setWs(new WebSocket(new_uri));
|
|
setConnection(undefined);
|
|
setRetryConnection(false);
|
|
setCount(count + 1);
|
|
return unbind;
|
|
}
|
|
|
|
if (!ws) {
|
|
return unbind;
|
|
}
|
|
|
|
const cbOpen = e => refWsOpen.current(e);
|
|
const cbMessage = e => refWsMessage.current(e);
|
|
const cbClose = e => refWsClose.current(e);
|
|
const cbError = e => refWsError.current(e);
|
|
|
|
ws.addEventListener('open', cbOpen);
|
|
ws.addEventListener('close', cbClose);
|
|
ws.addEventListener('error', cbError);
|
|
ws.addEventListener('message', cbMessage);
|
|
|
|
return () => {
|
|
unbind();
|
|
ws.removeEventListener('open', cbOpen);
|
|
ws.removeEventListener('close', cbClose);
|
|
ws.removeEventListener('error', cbError);
|
|
ws.removeEventListener('message', cbMessage);
|
|
}
|
|
}, [ ws, setWs, connection, setConnection,
|
|
retryConnection, setRetryConnection, gameId,
|
|
refWsOpen, refWsMessage, refWsClose, refWsError, count, setCount
|
|
]);
|
|
|
|
console.log(`board - (app) - Render with ws: ${ws ? '!' : ''}NULL, connection: ${connection ? '!' : ''}NULL`);
|
|
|
|
return <GlobalContext.Provider value={global}>
|
|
{ /* <PingPong/> */ }
|
|
<div className="Table">
|
|
<Activities/>
|
|
|
|
<div className="Game">
|
|
<div className="Dialogs">
|
|
{ error && <div className="Dialog ErrorDialog">
|
|
<Paper className="Error">
|
|
<div>{ error }</div>
|
|
<Button onClick={() => { setError("")}}>dismiss</Button>
|
|
</Paper>
|
|
</div> }
|
|
{ priv && priv.turnNotice && <div className="Dialog TurnNoticeDialog">
|
|
<Paper className="TurnNotice">
|
|
<div>{ priv.turnNotice }</div>
|
|
<Button onClick={() => { sendUpdate({type: 'turn-notice'}) }}>dismiss</Button>
|
|
</Paper>
|
|
</div> }
|
|
{ warning && <div className="Dialog WarningDialog">
|
|
<Paper className="Warning">
|
|
<div>{ warning }</div>
|
|
<Button onClick={() => { setWarning("")}}>dismiss</Button>
|
|
</Paper>
|
|
</div> }
|
|
{ state === 'normal' && <SelectPlayer/> }
|
|
{ color && state === 'game-order' && <GameOrder/> }
|
|
{ !winnerDismissed && <Winner {...{winnerDismissed, setWinnerDismissed}}/> }
|
|
{ houseRulesActive && <HouseRules {...{houseRulesActive, setHouseRulesActive}}/> }
|
|
<ViewCard {...{cardActive, setCardActive }}/>
|
|
<ChooseCard/>
|
|
</div>
|
|
|
|
<Board/>
|
|
<PlayersStatus/>
|
|
<PlayersStatus active={true}/>
|
|
<Hand {...{buildActive, setBuildActive, setCardActive}}/>
|
|
</div>
|
|
<div className="Sidebar">
|
|
{ name !== "" && <PlayerList/> }
|
|
<Trade {...{tradeActive, setTradeActive}}/>
|
|
{ name !== "" && <Chat/> }
|
|
{ /* name !== "" && <VideoFeeds/> */ }
|
|
{ loaded && <Actions {
|
|
...{buildActive, setBuildActive,
|
|
tradeActive, setTradeActive,
|
|
houseRulesActive, setHouseRulesActive
|
|
}}/> }
|
|
</div>
|
|
</div>
|
|
</GlobalContext.Provider>;
|
|
};
|
|
|
|
const App = () => {
|
|
const [playerId, setPlayerId] = useState(undefined);
|
|
const [error, setError] = useState(undefined);
|
|
|
|
useEffect(() => {
|
|
if (playerId) {
|
|
return;
|
|
}
|
|
window.fetch(`${base}/api/v1/games/`, {
|
|
method: 'GET',
|
|
cache: 'no-cache',
|
|
credentials: 'same-origin', /* include cookies */
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
}).then((res) => {
|
|
if (res.status >= 400) {
|
|
const error = `Unable to connect to Ketr Ketran game server! ` +
|
|
`Try refreshing your browser in a few seconds.`;
|
|
console.error(error);
|
|
setError(error);
|
|
}
|
|
console.log(res.headers);
|
|
return res.json();
|
|
}).then((data) => {
|
|
setPlayerId(data.player);
|
|
}).catch((error) => {
|
|
});
|
|
}, [playerId, setPlayerId]);
|
|
|
|
if (!playerId) {
|
|
return <>{ error }</>;
|
|
}
|
|
|
|
return (
|
|
<Router>
|
|
<Routes>
|
|
<Route exact element={<Table/>} path={`${base}/:gameId`}/>
|
|
<Route exact element={<Table/>} path={`${base}`}/>
|
|
</Routes>
|
|
</Router>
|
|
);
|
|
}
|
|
|
|
export default App;
|