From fc76e3690ae3ba7ae82754ee4a5c501ab0032522 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Thu, 16 Jul 2020 17:27:39 +1000 Subject: [PATCH] Refactored session management to allow for decoupled network and game routes --- src/helpers/{Peer.js => Connection.js} | 4 +- src/helpers/Session.js | 232 ++++++++++ src/helpers/useSession.js | 239 ---------- src/network/NetworkedMapAndTokens.js | 413 +++++++++++++++++ src/network/NetworkedParty.js | 146 ++++++ src/routes/Game.js | 607 +++---------------------- 6 files changed, 857 insertions(+), 784 deletions(-) rename src/helpers/{Peer.js => Connection.js} (98%) create mode 100644 src/helpers/Session.js delete mode 100644 src/helpers/useSession.js create mode 100644 src/network/NetworkedMapAndTokens.js create mode 100644 src/network/NetworkedParty.js diff --git a/src/helpers/Peer.js b/src/helpers/Connection.js similarity index 98% rename from src/helpers/Peer.js rename to src/helpers/Connection.js index 2e3d654..3a61f1b 100644 --- a/src/helpers/Peer.js +++ b/src/helpers/Connection.js @@ -8,7 +8,7 @@ import blobToBuffer from "./blobToBuffer"; // http://viblast.com/blog/2015/2/5/webrtc-data-channel-message-size/ const MAX_BUFFER_SIZE = 16000; -class Peer extends SimplePeer { +class Connection extends SimplePeer { constructor(props) { super(props); this.currentChunks = {}; @@ -126,4 +126,4 @@ class Peer extends SimplePeer { } } -export default Peer; +export default Connection; diff --git a/src/helpers/Session.js b/src/helpers/Session.js new file mode 100644 index 0000000..c8630b6 --- /dev/null +++ b/src/helpers/Session.js @@ -0,0 +1,232 @@ +import io from "socket.io-client"; +import { EventEmitter } from "events"; + +import Connection from "./Connection"; + +/** + * @typedef {object} SessionPeer + * @property {string} id - The socket id of the peer + * @property {Connection} connection - The actual peer connection + * @property {boolean} initiator - Is this peer the initiator of the connection + * @property {boolean} sync - Should this connection sync other connections + */ + +/** + * + * Handles connections to multiple peers + * + * Events: + * - connect: A party member has connected + * - data + * - trackAdded + * - trackRemoved + * - disconnect: A party member has disconnected + * - error + * - authenticationSuccess + * - authenticationError + * - connected: You have connected + * - disconnected: You have disconnected + */ +class Session extends EventEmitter { + /** + * The socket io connection + * + * @type {SocketIOClient.Socket} + */ + socket; + + /** + * A mapping of socket ids to session peers + * + * @type {Object.} + */ + peers; + + get id() { + return this.socket.id; + } + + _iceServers; + + // Store party id and password for reconnect + _partyId; + _password; + + constructor() { + super(); + this.socket = io(process.env.REACT_APP_BROKER_URL); + + this.socket.on( + "party member joined", + this._handlePartyMemberJoined.bind(this) + ); + this.socket.on("party member left", this._handlePartyMemberLeft.bind(this)); + this.socket.on("joined party", this._handleJoinedParty.bind(this)); + this.socket.on("signal", this._handleSignal.bind(this)); + this.socket.on("auth error", this._handleAuthError.bind(this)); + this.socket.on("disconnect", this._handleSocketDisconnect.bind(this)); + this.socket.on("reconnect", this._handleSocketReconnect.bind(this)); + + this.peers = {}; + + // Signal connected peers of a closure on refresh + window.addEventListener("beforeunload", this._handleUnload.bind(this)); + } + + /** + * Send data to all connected peers + * + * @param {string} id - the id of the event to send + * @param {object} data + * @param {string} channel + */ + send(id, data, channel) { + for (let peer of Object.values(this.peers)) { + peer.connection.send({ id, data }, channel); + } + } + + /** + * Join a party + * + * @param {string} partyId - the id of the party to join + * @param {string} password - the password of the party + */ + async joinParty(partyId, password) { + this._partyId = partyId; + this._password = password; + try { + const response = await fetch(process.env.REACT_APP_ICE_SERVERS_URL); + const data = await response.json(); + this._iceServers = data.iceServers; + this.socket.emit("join party", partyId, password); + } catch (e) { + console.error("Unable to join party:", e.message); + this.emit("disconnected"); + } + } + + _addPeer(id, initiator, sync) { + try { + const connection = new Connection({ + initiator, + trickle: true, + config: { iceServers: this._iceServers }, + }); + if (initiator) { + connection.createDataChannel("map", { iceServers: this._iceServers }); + connection.createDataChannel("token", { iceServers: this._iceServers }); + } + const peer = { id, connection, initiator, sync }; + + function sendPeer(id, data) { + peer.connection.send({ id, data }); + } + + function handleSignal(signal) { + this.socket.emit("signal", JSON.stringify({ to: peer.id, signal })); + } + + function handleConnect() { + this.emit("connect", { peer, reply: sendPeer }); + if (peer.sync) { + peer.connection.send({ id: "sync" }); + } + } + + function handleDataComplete(data) { + if (data.id === "close") { + // Close connection when signaled to close + peer.connection.destroy(); + } + this.emit("data", { + peer, + id: data.id, + data: data.data, + reply: sendPeer, + }); + } + + function handleDataProgress({ id, count, total }) { + this.emit("dataProgress", { peer, id, count, total, reply: sendPeer }); + } + + function handleTrack(track, stream) { + this.emit("trackAdded", { peer, track, stream }); + track.addEventListener("mute", () => { + this.emit("trackRemoved", { peer, track, stream }); + }); + } + + function handleClose() { + peer.connection.destroy(); + this.emit("disconnect", { peer }); + } + + function handleError(error) { + this.emit("error", { peer, error }); + } + + peer.connection.on("signal", handleSignal.bind(this)); + peer.connection.on("connect", handleConnect.bind(this)); + peer.connection.on("dataComplete", handleDataComplete.bind(this)); + peer.connection.on("dataProgress", handleDataProgress.bind(this)); + peer.connection.on("track", handleTrack.bind(this)); + peer.connection.on("close", handleClose.bind(this)); + peer.connection.on("error", handleError.bind(this)); + + this.peers[id] = peer; + } catch (error) { + this.emit("error", { error }); + } + } + + _handlePartyMemberJoined(id) { + this._addPeer(id, false, false); + } + + _handlePartyMemberLeft(id) { + if (id in this.peers) { + this.peers[id].connection.destroy(); + delete this.peers[id]; + } + } + + _handleJoinedParty(otherIds) { + for (let [index, id] of otherIds.entries()) { + // Send a sync request to the first member of the party + const sync = index === 0; + this._addPeer(id, true, sync); + } + this.emit("authenticationSuccess"); + this.emit("connected"); + } + + _handleSignal(data) { + const { from, signal } = JSON.parse(data); + if (from in this.peers) { + this.peers[from].connection.signal(signal); + } + } + + _handleAuthError() { + this.emit("authenticationError"); + } + + _handleUnload() { + for (let peer of Object.values(this.peers)) { + peer.connection.send({ id: "close" }); + } + } + + _handleSocketDisconnect() { + this.emit("disconnected"); + } + + _handleSocketReconnect() { + this.emit("connected"); + this.joinParty(this._partyId, this._password); + } +} + +export default Session; diff --git a/src/helpers/useSession.js b/src/helpers/useSession.js deleted file mode 100644 index 24251c9..0000000 --- a/src/helpers/useSession.js +++ /dev/null @@ -1,239 +0,0 @@ -import { useEffect, useState, useContext, useCallback } from "react"; -import io from "socket.io-client"; - -import { omit } from "../helpers/shared"; -import Peer from "../helpers/Peer"; - -import AuthContext from "../contexts/AuthContext"; - -const socket = io(process.env.REACT_APP_BROKER_URL); - -function useSession( - partyId, - onPeerConnected, - onPeerDisconnected, - onPeerData, - onPeerDataProgress, - onPeerTrackAdded, - onPeerTrackRemoved, - onPeerError -) { - const { password, setAuthenticationStatus } = useContext(AuthContext); - const [iceServers, setIceServers] = useState([]); - const [connected, setConnected] = useState(false); - - const joinParty = useCallback(async () => { - try { - const response = await fetch(process.env.REACT_APP_ICE_SERVERS_URL); - const data = await response.json(); - setIceServers(data.iceServers); - socket.emit("join party", partyId, password); - } catch (e) { - console.error("Unable to join party:", e.message); - setConnected(false); - } - }, [partyId, password]); - - useEffect(() => { - joinParty(); - }, [partyId, password, joinParty]); - - const [peers, setPeers] = useState({}); - - // Signal connected peers of a closure on refresh - useEffect(() => { - function handleUnload() { - for (let peer of Object.values(peers)) { - peer.connection.send({ id: "close" }); - } - } - window.addEventListener("beforeunload", handleUnload); - - return () => { - window.removeEventListener("beforeunload", handleUnload); - }; - }, [peers]); - - // Setup event listeners for peers - useEffect(() => { - let peerEvents = []; - for (let peer of Object.values(peers)) { - function handleSignal(signal) { - socket.emit("signal", JSON.stringify({ to: peer.id, signal })); - } - - function handleConnect() { - onPeerConnected && onPeerConnected(peer); - if (peer.sync) { - peer.connection.send({ id: "sync" }); - } - } - - function handleDataComplete(data) { - if (data.id === "close") { - // Close connection when signaled to close - peer.connection.destroy(); - } - onPeerData && onPeerData({ peer, data }); - } - - function handleDataProgress({ id, count, total }) { - onPeerDataProgress && onPeerDataProgress({ id, count, total }); - } - - function handleTrack(track, stream) { - onPeerTrackAdded && onPeerTrackAdded({ peer, track, stream }); - track.addEventListener("mute", () => { - onPeerTrackRemoved && onPeerTrackRemoved({ peer, track, stream }); - }); - } - - function handleClose() { - onPeerDisconnected && onPeerDisconnected(peer); - peer.connection.destroy(); - setPeers((prevPeers) => omit(prevPeers, [peer.id])); - } - - function handleError(error) { - onPeerError && onPeerError({ peer, error }); - } - - peer.connection.on("signal", handleSignal); - peer.connection.on("connect", handleConnect); - peer.connection.on("dataComplete", handleDataComplete); - peer.connection.on("dataProgress", handleDataProgress); - peer.connection.on("track", handleTrack); - peer.connection.on("close", handleClose); - peer.connection.on("error", handleError); - // Save events for cleanup - peerEvents.push({ - peer, - handleSignal, - handleConnect, - handleDataComplete, - handleDataProgress, - handleTrack, - handleClose, - handleError, - }); - } - - // Cleanup events - return () => { - for (let { - peer, - handleSignal, - handleConnect, - handleDataComplete, - handleDataProgress, - handleTrack, - handleClose, - handleError, - } of peerEvents) { - peer.connection.off("signal", handleSignal); - peer.connection.off("connect", handleConnect); - peer.connection.off("dataComplete", handleDataComplete); - peer.connection.off("dataProgress", handleDataProgress); - peer.connection.off("track", handleTrack); - peer.connection.off("close", handleClose); - peer.connection.off("error", handleError); - } - }; - }, [ - peers, - onPeerConnected, - onPeerDisconnected, - onPeerData, - onPeerDataProgress, - onPeerTrackAdded, - onPeerTrackRemoved, - onPeerError, - ]); - - // Setup event listeners for the socket - useEffect(() => { - function addPeer(id, initiator, sync) { - try { - const connection = new Peer({ - initiator, - trickle: true, - config: { iceServers }, - }); - if (initiator) { - connection.createDataChannel("map", { iceServers }); - connection.createDataChannel("token", { iceServers }); - } - setPeers((prevPeers) => ({ - ...prevPeers, - [id]: { id, connection, initiator, sync }, - })); - } catch (error) { - onPeerError && onPeerError({ error }); - } - } - - function handlePartyMemberJoined(id) { - addPeer(id, false, false); - } - - function handlePartyMemberLeft(id) { - if (id in peers) { - peers[id].connection.destroy(); - setPeers((prevPeers) => omit(prevPeers, [id])); - } - } - - function handleJoinedParty(otherIds) { - for (let [index, id] of otherIds.entries()) { - // Send a sync request to the first member of the party - const sync = index === 0; - addPeer(id, true, sync); - } - setAuthenticationStatus("authenticated"); - setConnected(true); - } - - function handleSignal(data) { - const { from, signal } = JSON.parse(data); - if (from in peers) { - peers[from].connection.signal(signal); - } - } - - function handleAuthError() { - setAuthenticationStatus("unauthenticated"); - } - - function handleSocketDisconnect() { - setConnected(false); - } - - function handleSocketReconnect() { - setConnected(true); - joinParty(); - } - - socket.on("disconnect", handleSocketDisconnect); - socket.on("reconnect", handleSocketReconnect); - - socket.on("party member joined", handlePartyMemberJoined); - socket.on("party member left", handlePartyMemberLeft); - socket.on("joined party", handleJoinedParty); - socket.on("signal", handleSignal); - socket.on("auth error", handleAuthError); - return () => { - socket.off("disconnect", handleSocketDisconnect); - socket.off("reconnect", handleSocketReconnect); - - socket.off("party member joined", handlePartyMemberJoined); - socket.off("party member left", handlePartyMemberLeft); - socket.off("joined party", handleJoinedParty); - socket.off("signal", handleSignal); - socket.off("auth error", handleAuthError); - }; - }, [peers, setAuthenticationStatus, iceServers, joinParty, onPeerError]); - - return { peers, socket, connected }; -} - -export default useSession; diff --git a/src/network/NetworkedMapAndTokens.js b/src/network/NetworkedMapAndTokens.js new file mode 100644 index 0000000..e705dc6 --- /dev/null +++ b/src/network/NetworkedMapAndTokens.js @@ -0,0 +1,413 @@ +import React, { useState, useContext, useEffect, useCallback } from "react"; + +import TokenDataContext from "../contexts/TokenDataContext"; +import MapDataContext from "../contexts/MapDataContext"; +import MapLoadingContext from "../contexts/MapLoadingContext"; +import AuthContext from "../contexts/AuthContext"; +import DatabaseContext from "../contexts/DatabaseContext"; + +import { omit } from "../helpers/shared"; +import useDebounce from "../helpers/useDebounce"; +// Load session for auto complete +// eslint-disable-next-line no-unused-vars +import Session from "../helpers/Session"; + +import Map from "../components/map/Map"; +import Tokens from "../components/token/Tokens"; + +/** + * @typedef {object} NetworkedMapProps + * @property {Session} session + */ + +/** + * @param {NetworkedMapProps} props + */ +function NetworkedMapAndTokens({ session }) { + const { userId } = useContext(AuthContext); + const { assetLoadStart, assetLoadFinish, assetProgressUpdate } = useContext( + MapLoadingContext + ); + + const { putToken, getToken } = useContext(TokenDataContext); + const { putMap, getMap, updateMap } = useContext(MapDataContext); + + const [currentMap, setCurrentMap] = useState(null); + const [currentMapState, setCurrentMapState] = useState(null); + + /** + * Map state + */ + + 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); + session.send("map", null, "map"); + session.send("mapState", newMapState, "map"); + session.send("map", getMapDataToSend(newMap), "map"); + const tokensToSend = getMapTokensToSend(newMapState); + for (let token of tokensToSend) { + session.send("token", token); + } + } + + function getMapDataToSend(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; + return rest; + } else { + return mapData; + } + } + + function handleMapStateChange(newMapState) { + setCurrentMapState(newMapState); + session.send("mapState", 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"); + session.send("mapDraw", [action]); + } + + function handleMapDrawUndo() { + const index = updateDrawActionIndex( + -1, + "mapDrawActionIndex", + "mapDrawActions" + ); + session.send("mapDrawIndex", index); + } + + function handleMapDrawRedo() { + const index = updateDrawActionIndex( + 1, + "mapDrawActionIndex", + "mapDrawActions" + ); + session.send("mapDrawIndex", index); + } + + function handleFogDraw(action) { + addMapDrawActions([action], "fogDrawActionIndex", "fogDrawActions"); + session.send("mapFog", [action]); + } + + function handleFogDrawUndo() { + const index = updateDrawActionIndex( + -1, + "fogDrawActionIndex", + "fogDrawActions" + ); + session.send("mapFogIndex", index); + } + + function handleFogDrawRedo() { + const index = updateDrawActionIndex( + 1, + "fogDrawActionIndex", + "fogDrawActions" + ); + session.send("mapFogIndex", index); + } + + /** + * Token state + */ + + // Get all tokens from a token state + const getMapTokensToSend = useCallback( + (state) => { + let sentTokens = {}; + const tokens = []; + 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; + tokens.add(rest); + } + } + return tokens; + }, + [getToken] + ); + + 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; + session.send("token", rest); + } + handleMapTokenStateChange({ [tokenState.id]: tokenState }); + } + + function handleMapTokenStateChange(change) { + if (currentMapState === null) { + return; + } + setCurrentMapState((prevMapState) => ({ + ...prevMapState, + tokens: { + ...prevMapState.tokens, + ...change, + }, + })); + session.send("tokenStateEdit", change); + } + + function handleMapTokenStateRemove(tokenState) { + setCurrentMapState((prevMapState) => { + const { [tokenState.id]: old, ...rest } = prevMapState.tokens; + return { ...prevMapState, tokens: rest }; + }); + session.send("tokenStateRemove", { [tokenState.id]: tokenState }); + } + + useEffect(() => { + async function handlePeerData({ id, data, reply }) { + if (id === "sync") { + if (currentMapState) { + reply("mapState", currentMapState); + const tokensToSend = getMapTokensToSend(currentMapState); + for (let token of tokensToSend) { + reply("token", token); + } + } + if (currentMap) { + reply("map", getMapDataToSend(currentMap)); + } + } + if (id === "map") { + const newMap = data; + if (newMap && newMap.type === "file") { + const cachedMap = getMap(newMap.id); + if (cachedMap && cachedMap.lastModified === newMap.lastModified) { + setCurrentMap(cachedMap); + } else { + await putMap(newMap); + reply("mapRequest", newMap.id, "map"); + } + } else { + setCurrentMap(newMap); + } + } + if (id === "mapRequest") { + const map = getMap(data); + function replyWithFile(file) { + reply("mapResponse", { id: map.id, file }, "map"); + } + + switch (map.quality) { + case "low": + replyWithFile(map.resolutions.low.file); + break; + case "medium": + replyWithFile(map.resolutions.low.file); + replyWithFile(map.resolutions.medium.file); + break; + case "high": + replyWithFile(map.resolutions.medium.file); + replyWithFile(map.resolutions.high.file); + break; + case "ultra": + replyWithFile(map.resolutions.medium.file); + replyWithFile(map.resolutions.ultra.file); + break; + case "original": + replyWithFile(map.resolutions.medium.file); + replyWithFile(map.file); + break; + default: + replyWithFile(map.file); + } + } + if (id === "mapResponse") { + let update = { file: data.file }; + const map = getMap(data.id); + updateMap(map.id, update).then(() => { + setCurrentMap({ ...map, ...update }); + }); + } + if (id === "mapState") { + setCurrentMapState(data); + } + if (id === "token") { + const newToken = data; + if (newToken && newToken.type === "file") { + const cachedToken = getToken(newToken.id); + if ( + !cachedToken || + cachedToken.lastModified !== newToken.lastModified + ) { + reply("tokenRequest", newToken.id); + } + } + } + if (id === "tokenRequest") { + const token = getToken(data); + reply("tokenResponse", token); + } + if (id === "tokenResponse") { + const newToken = data; + if (newToken && newToken.type === "file") { + putToken(newToken); + } + } + if (id === "tokenStateEdit") { + setCurrentMapState((prevMapState) => ({ + ...prevMapState, + tokens: { ...prevMapState.tokens, ...data }, + })); + } + if (id === "tokenStateRemove") { + setCurrentMapState((prevMapState) => ({ + ...prevMapState, + tokens: omit(prevMapState.tokens, Object.keys(data)), + })); + } + if (id === "mapDraw") { + addMapDrawActions(data, "mapDrawActionIndex", "mapDrawActions"); + } + if (id === "mapDrawIndex") { + setCurrentMapState((prevMapState) => ({ + ...prevMapState, + mapDrawActionIndex: data, + })); + } + if (id === "mapFog") { + addMapDrawActions(data, "fogDrawActionIndex", "fogDrawActions"); + } + if (id === "mapFogIndex") { + setCurrentMapState((prevMapState) => ({ + ...prevMapState, + fogDrawActionIndex: data, + })); + } + } + + function handlePeerDataProgress({ id, total, count }) { + if (count === 1) { + assetLoadStart(); + } + if (total === count) { + assetLoadFinish(); + } + assetProgressUpdate({ id, total, count }); + } + + session.on("data", handlePeerData); + session.on("dataProgress", handlePeerDataProgress); + + return () => { + session.off("data", handlePeerData); + session.off("dataProgress", handlePeerDataProgress); + }; + }); + + 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; + } + } + } + + return ( + <> + + + + ); +} + +export default NetworkedMapAndTokens; diff --git a/src/network/NetworkedParty.js b/src/network/NetworkedParty.js new file mode 100644 index 0000000..68bd59a --- /dev/null +++ b/src/network/NetworkedParty.js @@ -0,0 +1,146 @@ +import React, { useContext, useState, useEffect, useCallback } from "react"; + +// Load session for auto complete +// eslint-disable-next-line no-unused-vars +import Session from "../helpers/Session"; +import { isStreamStopped, omit } from "../helpers/shared"; + +import AuthContext from "../contexts/AuthContext"; + +import Party from "../components/party/Party"; + +/** + * @typedef {object} NetworkedPartyProps + * @property {string} gameId + * @property {Session} session + */ + +/** + * @param {NetworkedPartyProps} props + */ +function NetworkedParty({ gameId, session }) { + const { nickname, setNickname } = useContext(AuthContext); + const [partyNicknames, setPartyNicknames] = useState({}); + const [stream, setStream] = useState(null); + const [partyStreams, setPartyStreams] = useState({}); + + function handleNicknameChange(nickname) { + setNickname(nickname); + session.send("nickname", { [session.id]: nickname }); + } + + 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(session.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(session.peers)) { + peer.connection.removeTrack(track, localStream); + } + } + } + }, + [session] + ); + + useEffect(() => { + function handlePeerConnect({ peer, reply }) { + reply("nickname", { [session.id]: nickname }); + if (stream) { + peer.connection.addStream(stream); + } + } + + function handlePeerDisconnect({ peer }) { + setPartyNicknames((prevNicknames) => omit(prevNicknames, [peer.id])); + } + + function handlePeerData({ id, data }) { + if (id === "nickname") { + setPartyNicknames((prevNicknames) => ({ + ...prevNicknames, + ...data, + })); + } + } + + 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, + })); + } + } + + session.on("connect", handlePeerConnect); + session.on("disconnect", handlePeerDisconnect); + session.on("data", handlePeerData); + session.on("trackAdded", handlePeerTrackAdded); + session.on("trackRemoved", handlePeerTrackRemoved); + + return () => { + session.off("connect", handlePeerConnect); + session.off("disconnect", handlePeerDisconnect); + session.off("data", handlePeerData); + session.off("trackAdded", handlePeerTrackAdded); + session.off("trackRemoved", handlePeerTrackRemoved); + }; + }, [session, nickname, stream]); + + 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, handleStreamEnd]); + + return ( + + ); +} + +export default NetworkedParty; diff --git a/src/routes/Game.js b/src/routes/Game.js index 257c0b8..b89c71a 100644 --- a/src/routes/Game.js +++ b/src/routes/Game.js @@ -1,20 +1,7 @@ -import React, { - useState, - useEffect, - useCallback, - useContext, - useRef, -} from "react"; +import React, { useState, useEffect, 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"; @@ -22,520 +9,80 @@ 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"; +import NetworkedMapAndTokens from "../network/NetworkedMapAndTokens"; +import NetworkedParty from "../network/NetworkedParty"; + +import Session from "../helpers/Session"; + function Game() { const { id: gameId } = useParams(); - const { authenticationStatus, userId, nickname, setNickname } = useContext( - AuthContext - ); - const { assetLoadStart, assetLoadFinish, assetProgressUpdate } = useContext( - MapLoadingContext - ); + const { + authenticationStatus, + password, + setAuthenticationStatus, + } = useContext(AuthContext); + const [session] = useState(new Session()); - 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); + // Handle authentication status 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); + function handleAuthSuccess() { + setAuthenticationStatus("authenticated"); } - }, [currentMap, debouncedMapState, userId, database]); + function handleAuthError() { + setAuthenticationStatus("unauthenticated"); + } + session.on("authenticationSuccess", handleAuthSuccess); + session.on("authenticationError", handleAuthError); - 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 }, "map"); - 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 } }, "map"); - } else { - peer.connection.send({ id: "map", data: mapData }, "map"); - } - } - - 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 }, "map"); - }); - } - } else { - setCurrentMap(newMap); - } - } - // Send full map data including file - if (data.id === "mapRequest") { - const map = getMap(data.data); - - function respond(file) { - peer.connection.send( - { - id: "mapResponse", - data: { id: map.id, file }, - }, - "map" - ); - } - - switch (map.quality) { - case "low": - respond(map.resolutions.low.file); - break; - case "medium": - respond(map.resolutions.low.file); - respond(map.resolutions.medium.file); - break; - case "high": - respond(map.resolutions.medium.file); - respond(map.resolutions.high.file); - break; - case "ultra": - respond(map.resolutions.medium.file); - respond(map.resolutions.ultra.file); - break; - case "original": - respond(map.resolutions.medium.file); - respond(map.file); - break; - default: - respond(map.file); - } - } - // A new map response with a file attached - if (data.id === "mapResponse") { - let update = { file: data.data.file }; - 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])); - } + return () => { + session.off("authenticationSuccess", handleAuthSuccess); + session.off("authenticationError", handleAuthError); + }; + }, [setAuthenticationStatus, session]); + // Handle session errors 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); - }; - } + function handlePeerError({ error }) { + 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."); } } - }, [stream, peers, handleStreamEnd]); + session.on("error", handlePeerError); + return () => { + session.off("error", handlePeerError); + }; + }, [session]); + + // Handle connection + const [connected, setConnected] = useState(false); + useEffect(() => { + function handleConnected() { + setConnected(true); + } + + function handleDisconnected() { + setConnected(false); + } + + session.on("connected", handleConnected); + session.on("disconnected", handleDisconnected); + + return () => { + session.off("connected", handleConnected); + session.off("disconnected", handleDisconnected); + }; + }, [session]); + + // Join game + useEffect(() => { + session.joinParty(gameId, password); + }, [gameId, password, session]); // A ref to the Konva stage // the ref will be assigned in the MapInteraction component @@ -551,34 +98,8 @@ function Game() { height: "100%", }} > - - - + + setPeerError(null)}>