diff --git a/src/components/map/Map.js b/src/components/map/Map.js index 368fcaa..2db34db 100644 --- a/src/components/map/Map.js +++ b/src/components/map/Map.js @@ -440,6 +440,7 @@ function Map({ return ( {mapControls} diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js index 0e4c377..1e6b749 100644 --- a/src/components/map/MapInteraction.js +++ b/src/components/map/MapInteraction.js @@ -21,6 +21,7 @@ import KeyboardContext from "../../contexts/KeyboardContext"; function MapInteraction({ map, + mapState, children, controls, selectedToolId, @@ -32,12 +33,16 @@ function MapInteraction({ // Map loaded taking in to account different resolutions const [mapLoaded, setMapLoaded] = useState(false); useEffect(() => { - if (map === null) { + if ( + !map || + (map.type === "file" && !map.file && !map.resolutions) || + mapState.mapId !== map.id + ) { setMapLoaded(false); } else if (mapImageSourceStatus === "loaded") { setMapLoaded(true); } - }, [mapImageSourceStatus, map]); + }, [mapImageSourceStatus, map, mapState]); const [stageWidth, setStageWidth] = useState(1); const [stageHeight, setStageHeight] = useState(1); diff --git a/src/contexts/PlayerContext.js b/src/contexts/PlayerContext.js index 921d191..e70a163 100644 --- a/src/contexts/PlayerContext.js +++ b/src/contexts/PlayerContext.js @@ -2,12 +2,14 @@ import React, { useState, useEffect, useContext } from "react"; import useNetworkedState from "../helpers/useNetworkedState"; import DatabaseContext from "./DatabaseContext"; +import AuthContext from "./AuthContext"; import { getRandomMonster } from "../helpers/monsters"; const PlayerContext = React.createContext(); export function PlayerProvider({ session, children }) { + const { userId } = useContext(AuthContext); const { database, databaseStatus } = useContext(DatabaseContext); const [playerState, setPlayerState] = useNetworkedState( @@ -16,6 +18,8 @@ export function PlayerProvider({ session, children }) { timer: null, dice: { share: false, rolls: [] }, pointer: {}, + sessionId: null, + userId, }, session, "player_state" @@ -56,9 +60,16 @@ export function PlayerProvider({ session, children }) { }, [playerState, database, databaseStatus]); useEffect(() => { - function handleConnected() { + setPlayerState((prevState) => ({ + ...prevState, + userId, + })); + }, [userId]); + + useEffect(() => { + function handleSocketConnect() { // Set the player state to trigger a sync - setPlayerState(playerState); + setPlayerState({ ...playerState, sessionId: session.id }); } function handleSocketPartyState(partyState) { @@ -70,14 +81,20 @@ export function PlayerProvider({ session, children }) { } } + session.on("connected", handleSocketConnect); + if (session.socket) { - session.on("connected", handleConnected); + session.socket.on("connect", handleSocketConnect); + session.socket.on("reconnect", handleSocketConnect); session.socket.on("party_state", handleSocketPartyState); } return () => { + session.off("connected", handleSocketConnect); + if (session.socket) { - session.off("connected", handleConnected); + session.socket.off("connect", handleSocketConnect); + session.socket.off("reconnect", handleSocketConnect); session.socket.off("party_state", handleSocketPartyState); } }; diff --git a/src/helpers/useDataSource.js b/src/helpers/useDataSource.js index 6dc39b8..6feb5a0 100644 --- a/src/helpers/useDataSource.js +++ b/src/helpers/useDataSource.js @@ -11,7 +11,28 @@ function useDataSource(data, defaultSources, unknownSource) { } let url = unknownSource; if (data.type === "file") { - url = URL.createObjectURL(new Blob([data.file])); + if (data.resolutions) { + // Check is a resolution is specified + if (data.quality && data.resolutions[data.quality]) { + url = URL.createObjectURL( + new Blob([data.resolutions[data.quality].file]) + ); + } + // If no file available fallback to the highest resolution + else if (!data.file) { + const resolutionArray = Object.keys(data.resolutions); + url = URL.createObjectURL( + new Blob([ + data.resolutions[resolutionArray[resolutionArray.length - 1]] + .file, + ]) + ); + } else { + url = URL.createObjectURL(new Blob([data.file])); + } + } else { + url = URL.createObjectURL(new Blob([data.file])); + } } else if (data.type === "default") { url = defaultSources[data.key]; } @@ -19,7 +40,10 @@ function useDataSource(data, defaultSources, unknownSource) { return () => { if (data.type === "file" && url) { - URL.revokeObjectURL(url); + // Remove file url after 5 seconds as we still may be using it while the next image loads + setTimeout(() => { + URL.revokeObjectURL(url); + }, 5000); } }; }, [data, defaultSources, unknownSource]); diff --git a/src/helpers/useMapImage.js b/src/helpers/useMapImage.js index b6f408e..db28781 100644 --- a/src/helpers/useMapImage.js +++ b/src/helpers/useMapImage.js @@ -3,49 +3,10 @@ import useImage from "use-image"; import useDataSource from "./useDataSource"; -import { isEmpty } from "./shared"; - import { mapSources as defaultMapSources } from "../maps"; function useMapImage(map) { - const [mapSourceMap, setMapSourceMap] = useState({}); - // Update source map data when either the map or map quality changes - useEffect(() => { - function updateMapSource() { - if (map && map.type === "file" && map.resolutions) { - // If quality is set and the quality is available - if (map.quality !== "original" && map.resolutions[map.quality]) { - setMapSourceMap({ - ...map.resolutions[map.quality], - id: map.id, - quality: map.quality, - }); - } else if (!map.file) { - // If no file fallback to the highest resolution - const resolutionArray = Object.keys(map.resolutions); - setMapSourceMap({ - ...map.resolutions[resolutionArray[resolutionArray.length - 1]], - id: map.id, - }); - } else { - setMapSourceMap(map); - } - } else { - setMapSourceMap(map); - } - } - if (map && map.id !== mapSourceMap.id) { - updateMapSource(); - } else if (map && map.type === "file") { - if (map.file && map.quality !== mapSourceMap.quality) { - updateMapSource(); - } - } else if (!map && !isEmpty(mapSourceMap)) { - setMapSourceMap({}); - } - }, [map, mapSourceMap]); - - const mapSource = useDataSource(mapSourceMap, defaultMapSources); + const mapSource = useDataSource(map, defaultMapSources); const [mapSourceImage, mapSourceImageStatus] = useImage(mapSource); // Create a map source that only updates when the image is fully loaded diff --git a/src/helpers/useNetworkedState.js b/src/helpers/useNetworkedState.js index dd2f03a..b421a5e 100644 --- a/src/helpers/useNetworkedState.js +++ b/src/helpers/useNetworkedState.js @@ -18,6 +18,21 @@ function useNetworkedState(defaultState, session, eventName) { } }, [session.socket, dirty, eventName, state]); + useEffect(() => { + function handleSocketEvent(data) { + _setState(data); + } + + if (session.socket) { + session.socket.on(eventName, handleSocketEvent); + } + return () => { + if (session.socket) { + session.socket.off(eventName, handleSocketEvent); + } + }; + }, [session.socket]); + return [state, setState]; } diff --git a/src/network/NetworkedMapAndTokens.js b/src/network/NetworkedMapAndTokens.js index b342959..4e412d2 100644 --- a/src/network/NetworkedMapAndTokens.js +++ b/src/network/NetworkedMapAndTokens.js @@ -1,10 +1,11 @@ -import React, { useState, useContext, useEffect, useCallback } from "react"; +import React, { useState, useContext, useEffect } 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 PlayerContext from "../contexts/PlayerContext"; import { omit } from "../helpers/shared"; import useDebounce from "../helpers/useDebounce"; @@ -26,6 +27,7 @@ import Tokens from "../components/token/Tokens"; */ function NetworkedMapAndTokens({ session }) { const { userId } = useContext(AuthContext); + const { partyState } = useContext(PlayerContext); const { assetLoadStart, assetLoadFinish, @@ -44,6 +46,97 @@ function NetworkedMapAndTokens({ session }) { session, "map_state" ); + const [assetManifest, setAssetManifest] = useNetworkedState( + [], + session, + "manifest" + ); + + function loadAssetManifestFromMap(map, mapState) { + const assets = []; + if (map.type === "file") { + const { id, lastModified, owner } = map; + assets.push({ type: "map", id, lastModified, owner }); + } + let processedTokens = new Set(); + for (let tokenState of Object.values(mapState.tokens)) { + const token = getToken(tokenState.tokenId); + if ( + token && + token.type === "file" && + !processedTokens.has(tokenState.tokenId) + ) { + processedTokens.add(tokenState.tokenId); + // Omit file from token peer will request file if needed + const { id, lastModified, owner } = token; + assets.push({ type: "token", id, lastModified, owner }); + } + } + setAssetManifest(assets); + } + + function compareAssets(a, b) { + return a.type === b.type && a.id === b.id; + } + + // Return true if an asset is out of date + function assetNeedsUpdate(oldAsset, newAsset) { + return ( + compareAssets(oldAsset, newAsset) && + oldAsset.lastModified > newAsset.lastModified + ); + } + + function addAssetIfNeeded(asset) { + // Asset needs updating + const exists = assetManifest.some((oldAsset) => + compareAssets(oldAsset, asset) + ); + const needsUpdate = assetManifest.some((oldAsset) => + assetNeedsUpdate(oldAsset, asset) + ); + if (!exists || needsUpdate) { + setAssetManifest((prevAssets) => [ + ...prevAssets.filter((prevAsset) => compareAssets(prevAsset, asset)), + asset, + ]); + } + } + + useEffect(() => { + if (!assetManifest) { + return; + } + + async function requestAssetsIfNeeded() { + for (let asset of assetManifest) { + if (asset.owner === userId) { + continue; + } + + const owner = Object.values(partyState).find( + (player) => player.userId === asset.owner + ); + if (!owner) { + continue; + } + + if (asset.type === "map") { + const cachedMap = await getMapFromDB(asset.id); + if (cachedMap && cachedMap.lastModified >= asset.lastModified) { + // Update last used for cache invalidation + const lastUsed = Date.now(); + await updateMap(cachedMap.id, { lastUsed }); + setCurrentMap({ ...cachedMap, lastUsed }); + } else { + session.sendTo(owner.sessionId, "mapRequest", asset.id, "map"); + } + } + } + } + + requestAssetsIfNeeded(); + }, [assetManifest, partyState, session]); /** * Map state @@ -68,28 +161,19 @@ function NetworkedMapAndTokens({ session }) { function handleMapChange(newMap, newMapState) { setCurrentMapState(newMapState); setCurrentMap(newMap); - session.send("map", null, "map"); + + if (newMap && newMap.type === "file") { + const { file, resolutions, ...rest } = newMap; + session.socket.emit("map", rest); + } else { + session.socket.emit("map", newMap); + } if (!newMap || !newMapState) { return; } - session.send("map", getMapDataToSend(newMap), "map"); - const tokensToSend = getMapTokensToSend(newMapState); - for (let token of tokensToSend) { - session.send("token", 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; - } + loadAssetManifestFromMap(newMap, newMapState); } function handleMapStateChange(newMapState) { @@ -169,35 +253,12 @@ function NetworkedMapAndTokens({ session }) { * 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.push(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); + const { id, lastModified, owner } = token; + addAssetIfNeeded({ type: "token", id, lastModified, owner }); } handleMapTokenStateChange({ [tokenState.id]: tokenState }); } @@ -224,112 +285,76 @@ function NetworkedMapAndTokens({ session }) { useEffect(() => { async function handlePeerData({ id, data, reply }) { - if (id === "sync") { - if (currentMapState) { - const tokensToSend = getMapTokensToSend(currentMapState); - for (let token of tokensToSend) { - reply("token", token, "token"); - } - } - if (currentMap) { - reply("map", getMapDataToSend(currentMap), "map"); - } - } - if (id === "map") { - const newMap = data; - if (newMap && newMap.type === "file") { - const cachedMap = await getMapFromDB(newMap.id); - if (cachedMap && cachedMap.lastModified >= newMap.lastModified) { - // Update last used for cache invalidation - const lastUsed = Date.now(); - await updateMap(cachedMap.id, { lastUsed }); - setCurrentMap({ ...cachedMap, lastUsed }); - } else { - // Save map data but remove last modified so if there is an error - // during the map request the cache is invalid. Also add last used - // for cache invalidation - await putMap({ ...newMap, lastModified: 0, lastUsed: Date.now() }); - reply("mapRequest", newMap.id, "map"); - } - } else { - setCurrentMap(newMap); - } - } if (id === "mapRequest") { const map = await getMapFromDB(data); - function replyWithPreview(preview) { + function replyWithMap(preview, resolution) { + let response = { + ...map, + resolutions: undefined, + file: undefined, + // Remove last modified so if there is an error + // during the map request the cache is invalid + lastModified: 0, + // Add last used for cache invalidation + lastUsed: Date.now(), + }; + // Send preview if available if (map.resolutions[preview]) { - reply( - "mapResponse", - { - id: map.id, - resolutions: { [preview]: map.resolutions[preview] }, - }, - "map" - ); + response.resolutions = { [preview]: map.resolutions[preview] }; + reply("mapResponse", response, "map"); } - } - - function replyWithFile(resolution) { - let file; - // If the resolution exists send that + // Send full map at the desired resolution if available if (map.resolutions[resolution]) { - file = map.resolutions[resolution].file; + response.file = map.resolutions[resolution].file; } else if (map.file) { // The resolution might not exist for other users so send the file instead - file = map.file; + response.file = map.file; } else { return; } - reply( - "mapResponse", - { - id: map.id, - file, - // Add last modified back to file to set cache as valid - lastModified: map.lastModified, - }, - "map" - ); + // Add last modified back to file to set cache as valid + response.lastModified = map.lastModified; + reply("mapResponse", response, "map"); } switch (map.quality) { case "low": - replyWithFile("low"); + replyWithMap(undefined, "low"); break; case "medium": - replyWithPreview("low"); - replyWithFile("medium"); + replyWithMap("low", "medium"); break; case "high": - replyWithPreview("medium"); - replyWithFile("high"); + replyWithMap("medium", "high"); break; case "ultra": - replyWithPreview("medium"); - replyWithFile("ultra"); + replyWithMap("medium", "ultra"); break; case "original": if (map.resolutions) { if (map.resolutions.medium) { - replyWithPreview("medium"); + replyWithMap("medium"); } else if (map.resolutions.low) { - replyWithPreview("low"); + replyWithMap("low"); + } else { + replyWithMap(); } + } else { + replyWithMap(); } - replyWithFile(); break; default: - replyWithFile(); + replyWithMap(); } } + if (id === "mapResponse") { - const { id, ...update } = data; - await updateMap(id, update); - const updatedMap = await getMapFromDB(data.id); - setCurrentMap(updatedMap); + const newMap = data; + await putMap(newMap); + setCurrentMap(newMap); } + if (id === "token") { const newToken = data; if (newToken && newToken.type === "file") { @@ -369,21 +394,25 @@ function NetworkedMapAndTokens({ session }) { assetProgressUpdate({ id, total, count }); } - function handleSocketMapState(mapState) { - setCurrentMapState(mapState, false); + function handleSocketMap(map) { + if (map) { + setCurrentMap(map); + } else { + setCurrentMap(null); + } } session.on("data", handlePeerData); session.on("dataProgress", handlePeerDataProgress); if (session.socket) { - session.socket.on("map_state", handleSocketMapState); + session.socket.on("map", handleSocketMap); } return () => { session.off("data", handlePeerData); session.off("dataProgress", handlePeerDataProgress); if (session.socket) { - session.socket.off("map_state", handleSocketMapState); + session.socket.off("map", handleSocketMap); } }; }); diff --git a/src/network/Session.js b/src/network/Session.js index d6d50ac..8ad4787 100644 --- a/src/network/Session.js +++ b/src/network/Session.js @@ -111,6 +111,25 @@ class Session extends EventEmitter { } } + /** + * Send data to a single peer + * + * @param {string} sessionId - the socket id of the player to send to + * @param {string} eventId - the id of the event to send + * @param {object} data + * @param {string} channel + */ + sendTo(sessionId, eventId, data, channel) { + if (!(sessionId in this.peers)) { + this._addPeer(sessionId, true); + this.peers[sessionId].connection.once("connect", () => { + this.peers[sessionId].connection.send({ id: eventId, data }, channel); + }); + } else { + this.peers[sessionId].connection.send({ id: eventId, data }, channel); + } + } + /** * Join a party * @@ -132,7 +151,7 @@ class Session extends EventEmitter { this.socket.emit("join_game", gameId, password); } - _addPeer(id, initiator, sync) { + _addPeer(id, initiator) { try { const connection = new Connection({ initiator, @@ -143,7 +162,7 @@ class Session extends EventEmitter { connection.createDataChannel("map", { iceServers: this._iceServers }); connection.createDataChannel("token", { iceServers: this._iceServers }); } - const peer = { id, connection, initiator, sync }; + const peer = { id, connection, initiator }; function sendPeer(id, data, channel) { peer.connection.send({ id, data }, channel); @@ -155,9 +174,6 @@ class Session extends EventEmitter { function handleConnect() { this.emit("connect", { peer, reply: sendPeer }); - if (peer.sync) { - peer.connection.send({ id: "sync" }); - } } function handleDataComplete(data) { @@ -220,22 +236,17 @@ class Session extends EventEmitter { } } - _handleJoinedGame(otherIds) { - for (let i = 0; i < otherIds.length; i++) { - const id = otherIds[i]; - // Send a sync request to the first member of the party - const sync = i === 0; - this._addPeer(id, true, sync); - } + _handleJoinedGame() { this.emit("authenticationSuccess"); this.emit("connected"); } _handlePlayerJoined(id) { - this._addPeer(id, false, false); + this.emit("playerJoined", id); } _handlePlayerLeft(id) { + this.emit("playerLeft", id); if (id in this.peers) { this.peers[id].connection.destroy(); delete this.peers[id]; @@ -244,9 +255,10 @@ class Session extends EventEmitter { _handleSignal(data) { const { from, signal } = data; - if (from in this.peers) { - this.peers[from].connection.signal(signal); + if (!(from in this.peers)) { + this._addPeer(from, false); } + this.peers[from].connection.signal(signal); } _handleAuthError() {