Files
grungnet/src/routes/Game.js
2020-07-13 21:49:40 +10:00

590 lines
17 KiB
JavaScript

import React, {
useState,
useEffect,
useCallback,
useContext,
useRef,
} from "react";
import { Flex, Box, Text } from "theme-ui";
import { useParams } from "react-router-dom";
import { omit, isStreamStopped } from "../helpers/shared";
import useSession from "../helpers/useSession";
import useDebounce from "../helpers/useDebounce";
import Party from "../components/party/Party";
import Tokens from "../components/token/Tokens";
import Map from "../components/map/Map";
import Banner from "../components/Banner";
import LoadingOverlay from "../components/LoadingOverlay";
import Link from "../components/Link";
import AuthModal from "../modals/AuthModal";
import AuthContext from "../contexts/AuthContext";
import DatabaseContext from "../contexts/DatabaseContext";
import TokenDataContext from "../contexts/TokenDataContext";
import MapDataContext from "../contexts/MapDataContext";
import MapLoadingContext from "../contexts/MapLoadingContext";
import { MapStageProvider } from "../contexts/MapStageContext";
function Game() {
const { id: gameId } = useParams();
const { authenticationStatus, userId, nickname, setNickname } = useContext(
AuthContext
);
const { assetLoadStart, assetLoadFinish, assetProgressUpdate } = useContext(
MapLoadingContext
);
const { peers, socket, connected } = useSession(
gameId,
handlePeerConnected,
handlePeerDisconnected,
handlePeerData,
handlePeerDataProgress,
handlePeerTrackAdded,
handlePeerTrackRemoved,
handlePeerError
);
const { putToken, getToken } = useContext(TokenDataContext);
const { putMap, getMap, updateMap } = useContext(MapDataContext);
/**
* Map state
*/
const [currentMap, setCurrentMap] = useState(null);
const [currentMapState, setCurrentMapState] = useState(null);
const canEditMapDrawing =
currentMap !== null &&
currentMapState !== null &&
(currentMapState.editFlags.includes("drawing") ||
currentMap.owner === userId);
const canEditFogDrawing =
currentMap !== null &&
currentMapState !== null &&
(currentMapState.editFlags.includes("fog") || currentMap.owner === userId);
const disabledMapTokens = {};
// If we have a map and state and have the token permission disabled
// and are not the map owner
if (
currentMapState !== null &&
currentMap !== null &&
!currentMapState.editFlags.includes("tokens") &&
currentMap.owner !== userId
) {
for (let token of Object.values(currentMapState.tokens)) {
if (token.owner !== userId) {
disabledMapTokens[token.id] = true;
}
}
}
const { database } = useContext(DatabaseContext);
// Sync the map state to the database after 500ms of inactivity
const debouncedMapState = useDebounce(currentMapState, 500);
useEffect(() => {
if (
debouncedMapState &&
debouncedMapState.mapId &&
currentMap &&
currentMap.owner === userId &&
database
) {
// Update the database directly to avoid re-renders
database
.table("states")
.update(debouncedMapState.mapId, debouncedMapState);
}
}, [currentMap, debouncedMapState, userId, database]);
function handleMapChange(newMap, newMapState) {
setCurrentMapState(newMapState);
setCurrentMap(newMap);
for (let peer of Object.values(peers)) {
// Clear the map so the new map state isn't shown on an old map
peer.connection.send({ id: "map", data: null });
peer.connection.send({ id: "mapState", data: newMapState });
sendMapDataToPeer(peer, newMap);
sendTokensToPeer(peer, newMapState);
}
}
function sendMapDataToPeer(peer, mapData) {
// Omit file from map change, receiver will request the file if
// they have an outdated version
if (mapData.type === "file") {
const { file, resolutions, ...rest } = mapData;
peer.connection.send({ id: "map", data: { ...rest, resolutions: [] } });
} else {
peer.connection.send({ id: "map", data: mapData });
}
}
function handleMapStateChange(newMapState) {
setCurrentMapState(newMapState);
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "mapState", data: newMapState });
}
}
function addMapDrawActions(actions, indexKey, actionsKey) {
setCurrentMapState((prevMapState) => {
const newActions = [
...prevMapState[actionsKey].slice(0, prevMapState[indexKey] + 1),
...actions,
];
const newIndex = newActions.length - 1;
return {
...prevMapState,
[actionsKey]: newActions,
[indexKey]: newIndex,
};
});
}
function updateDrawActionIndex(change, indexKey, actionsKey) {
const newIndex = Math.min(
Math.max(currentMapState[indexKey] + change, -1),
currentMapState[actionsKey].length - 1
);
setCurrentMapState((prevMapState) => ({
...prevMapState,
[indexKey]: newIndex,
}));
return newIndex;
}
function handleMapDraw(action) {
addMapDrawActions([action], "mapDrawActionIndex", "mapDrawActions");
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "mapDraw", data: [action] });
}
}
function handleMapDrawUndo() {
const index = updateDrawActionIndex(
-1,
"mapDrawActionIndex",
"mapDrawActions"
);
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "mapDrawIndex", data: index });
}
}
function handleMapDrawRedo() {
const index = updateDrawActionIndex(
1,
"mapDrawActionIndex",
"mapDrawActions"
);
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "mapDrawIndex", data: index });
}
}
function handleFogDraw(action) {
addMapDrawActions([action], "fogDrawActionIndex", "fogDrawActions");
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "mapFog", data: [action] });
}
}
function handleFogDrawUndo() {
const index = updateDrawActionIndex(
-1,
"fogDrawActionIndex",
"fogDrawActions"
);
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "mapFogIndex", data: index });
}
}
function handleFogDrawRedo() {
const index = updateDrawActionIndex(
1,
"fogDrawActionIndex",
"fogDrawActions"
);
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "mapFogIndex", data: index });
}
}
/**
* Token state
*/
// Get all tokens from a token state and send it to a peer
function sendTokensToPeer(peer, state) {
let sentTokens = {};
for (let tokenState of Object.values(state.tokens)) {
const token = getToken(tokenState.tokenId);
if (
token &&
token.type === "file" &&
!(tokenState.tokenId in sentTokens)
) {
sentTokens[tokenState.tokenId] = true;
// Omit file from token peer will request file if needed
const { file, ...rest } = token;
peer.connection.send({ id: "token", data: rest });
}
}
}
async function handleMapTokenStateCreate(tokenState) {
// If file type token send the token to the other peers
const token = getToken(tokenState.tokenId);
if (token && token.type === "file") {
const { file, ...rest } = token;
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "token", data: rest });
}
}
handleMapTokenStateChange({ [tokenState.id]: tokenState });
}
function handleMapTokenStateChange(change) {
if (currentMapState === null) {
return;
}
setCurrentMapState((prevMapState) => ({
...prevMapState,
tokens: {
...prevMapState.tokens,
...change,
},
}));
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "tokenStateEdit", data: change });
}
}
function handleMapTokenStateRemove(tokenState) {
setCurrentMapState((prevMapState) => {
const { [tokenState.id]: old, ...rest } = prevMapState.tokens;
return { ...prevMapState, tokens: rest };
});
for (let peer of Object.values(peers)) {
const data = { [tokenState.id]: tokenState };
peer.connection.send({ id: "tokenStateRemove", data });
}
}
/**
* Party state
*/
const [partyNicknames, setPartyNicknames] = useState({});
function handleNicknameChange(nickname) {
setNickname(nickname);
for (let peer of Object.values(peers)) {
const data = { [socket.id]: nickname };
peer.connection.send({ id: "nickname", data });
}
}
const [stream, setStream] = useState(null);
const [partyStreams, setPartyStreams] = useState({});
function handlePeerConnected(peer) {
peer.connection.send({ id: "nickname", data: { [socket.id]: nickname } });
if (stream) {
peer.connection.addStream(stream);
}
}
/**
* Peer handlers
*/
function handlePeerData({ data, peer }) {
if (data.id === "sync") {
if (currentMapState) {
peer.connection.send({ id: "mapState", data: currentMapState });
sendTokensToPeer(peer, currentMapState);
}
if (currentMap) {
sendMapDataToPeer(peer, currentMap);
}
}
if (data.id === "map") {
const newMap = data.data;
// If is a file map check cache and request the full file if outdated
if (newMap && newMap.type === "file") {
const cachedMap = getMap(newMap.id);
if (cachedMap && cachedMap.lastModified === newMap.lastModified) {
setCurrentMap(cachedMap);
} else {
putMap(newMap).then(() => {
peer.connection.send({ id: "mapRequest", data: newMap.id });
});
}
} else {
setCurrentMap(newMap);
}
}
// Send full map data including file
if (data.id === "mapRequest") {
const map = getMap(data.data);
peer.connection.send({
id: "mapResponse",
data: { id: map.id, resolutions: map.resolutions },
});
peer.connection.send({
id: "mapResponse",
data: { id: map.id, file: map.file },
});
}
// A new map response with a file attached
if (data.id === "mapResponse") {
let update = {};
if (data.data.file) {
update.file = data.data.file;
}
if (data.data.resolutions) {
update.resolutions = data.data.resolutions;
}
const map = getMap(data.data.id);
updateMap(map.id, update).then(() => {
setCurrentMap({ ...map, ...update });
});
}
if (data.id === "mapState") {
setCurrentMapState(data.data);
}
if (data.id === "token") {
const newToken = data.data;
if (newToken && newToken.type === "file") {
const cachedToken = getToken(newToken.id);
if (
!cachedToken ||
cachedToken.lastModified !== newToken.lastModified
) {
peer.connection.send({
id: "tokenRequest",
data: newToken.id,
});
}
}
}
if (data.id === "tokenRequest") {
const token = getToken(data.data);
peer.connection.send({ id: "tokenResponse", data: token });
}
if (data.id === "tokenResponse") {
const newToken = data.data;
if (newToken && newToken.type === "file") {
putToken(newToken);
}
}
if (data.id === "tokenStateEdit") {
setCurrentMapState((prevMapState) => ({
...prevMapState,
tokens: { ...prevMapState.tokens, ...data.data },
}));
}
if (data.id === "tokenStateRemove") {
setCurrentMapState((prevMapState) => ({
...prevMapState,
tokens: omit(prevMapState.tokens, Object.keys(data.data)),
}));
}
if (data.id === "nickname") {
setPartyNicknames((prevNicknames) => ({
...prevNicknames,
...data.data,
}));
}
if (data.id === "mapDraw") {
addMapDrawActions(data.data, "mapDrawActionIndex", "mapDrawActions");
}
if (data.id === "mapDrawIndex") {
setCurrentMapState((prevMapState) => ({
...prevMapState,
mapDrawActionIndex: data.data,
}));
}
if (data.id === "mapFog") {
addMapDrawActions(data.data, "fogDrawActionIndex", "fogDrawActions");
}
if (data.id === "mapFogIndex") {
setCurrentMapState((prevMapState) => ({
...prevMapState,
fogDrawActionIndex: data.data,
}));
}
}
function handlePeerDataProgress({ id, total, count }) {
if (count === 1) {
assetLoadStart();
}
if (total === count) {
assetLoadFinish();
}
assetProgressUpdate({ id, total, count });
}
function handlePeerDisconnected(peer) {
setPartyNicknames((prevNicknames) => omit(prevNicknames, [peer.id]));
}
const [peerError, setPeerError] = useState(null);
function handlePeerError({ error, peer }) {
console.error(error.code);
if (error.code === "ERR_WEBRTC_SUPPORT") {
setPeerError("WebRTC not supported.");
} else if (error.code === "ERR_CREATE_OFFER") {
setPeerError("Unable to connect to party.");
}
}
function handlePeerTrackAdded({ peer, stream: remoteStream }) {
setPartyStreams((prevStreams) => ({
...prevStreams,
[peer.id]: remoteStream,
}));
}
function handlePeerTrackRemoved({ peer, stream: remoteStream }) {
if (isStreamStopped(remoteStream)) {
setPartyStreams((prevStreams) => omit(prevStreams, [peer.id]));
} else {
setPartyStreams((prevStreams) => ({
...prevStreams,
[peer.id]: remoteStream,
}));
}
}
/**
* Stream handler
*/
function handleStreamStart(localStream) {
setStream(localStream);
const tracks = localStream.getTracks();
for (let track of tracks) {
// Only add the audio track of the stream to the remote peer
if (track.kind === "audio") {
for (let peer of Object.values(peers)) {
peer.connection.addTrack(track, localStream);
}
}
}
}
const handleStreamEnd = useCallback(
(localStream) => {
setStream(null);
const tracks = localStream.getTracks();
for (let track of tracks) {
track.stop();
// Only sending audio so only remove the audio track
if (track.kind === "audio") {
for (let peer of Object.values(peers)) {
peer.connection.removeTrack(track, localStream);
}
}
}
},
[peers]
);
useEffect(() => {
if (stream) {
const tracks = stream.getTracks();
// Detect when someone has ended the screen sharing
// by looking at the streams video track onended
// the audio track doesn't seem to trigger this event
for (let track of tracks) {
if (track.kind === "video") {
track.onended = function () {
handleStreamEnd(stream);
};
}
}
}
}, [stream, peers, handleStreamEnd]);
// A ref to the Konva stage
// the ref will be assigned in the MapInteraction component
const mapStageRef = useRef();
return (
<MapStageProvider value={mapStageRef}>
<Flex sx={{ flexDirection: "column", height: "100%" }}>
<Flex
sx={{
justifyContent: "space-between",
flexGrow: 1,
height: "100%",
}}
>
<Party
nickname={nickname}
partyNicknames={partyNicknames}
gameId={gameId}
onNicknameChange={handleNicknameChange}
stream={stream}
partyStreams={partyStreams}
onStreamStart={handleStreamStart}
onStreamEnd={handleStreamEnd}
/>
<Map
map={currentMap}
mapState={currentMapState}
onMapTokenStateChange={handleMapTokenStateChange}
onMapTokenStateRemove={handleMapTokenStateRemove}
onMapChange={handleMapChange}
onMapStateChange={handleMapStateChange}
onMapDraw={handleMapDraw}
onMapDrawUndo={handleMapDrawUndo}
onMapDrawRedo={handleMapDrawRedo}
onFogDraw={handleFogDraw}
onFogDrawUndo={handleFogDrawUndo}
onFogDrawRedo={handleFogDrawRedo}
allowMapDrawing={canEditMapDrawing}
allowFogDrawing={canEditFogDrawing}
disabledTokens={disabledMapTokens}
/>
<Tokens onMapTokenStateCreate={handleMapTokenStateCreate} />
</Flex>
</Flex>
<Banner isOpen={!!peerError} onRequestClose={() => setPeerError(null)}>
<Box p={1}>
<Text as="p" variant="body2">
{peerError} See <Link to="/faq#connection">FAQ</Link> for more
information.
</Text>
</Box>
</Banner>
<Banner
isOpen={!connected && authenticationStatus === "authenticated"}
onRequestClose={() => {}}
allowClose={false}
>
<Box p={1}>
<Text as="p" variant="body2">
Disconnected. Attempting to reconnect...
</Text>
</Box>
</Banner>
<AuthModal isOpen={authenticationStatus === "unauthenticated"} />
{authenticationStatus === "unknown" && <LoadingOverlay />}
</MapStageProvider>
);
}
export default Game;