Refactored session management to allow for decoupled network and game routes

This commit is contained in:
Mitchell McCaffrey 2020-07-16 17:27:39 +10:00
parent 25aa24199b
commit fc76e3690a
6 changed files with 857 additions and 784 deletions

View File

@ -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;

232
src/helpers/Session.js Normal file
View File

@ -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.<string, SessionPeer>}
*/
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;

View File

@ -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;

View File

@ -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 (
<>
<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} />
</>
);
}
export default NetworkedMapAndTokens;

View File

@ -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 (
<Party
gameId={gameId}
onNicknameChange={handleNicknameChange}
onStreamStart={handleStreamStart}
onStreamEnd={handleStreamEnd}
nickname={nickname}
partyNicknames={partyNicknames}
stream={stream}
partyStreams={partyStreams}
/>
);
}
export default NetworkedParty;

View File

@ -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%",
}}
>
<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} />
<NetworkedParty session={session} gameId={gameId} />
<NetworkedMapAndTokens session={session} />
</Flex>
</Flex>
<Banner isOpen={!!peerError} onRequestClose={() => setPeerError(null)}>