567 lines
15 KiB
TypeScript
567 lines
15 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
|
import { useToasts } from "react-toast-notifications";
|
|
|
|
import { useMapData } from "../contexts/MapDataContext";
|
|
import { useMapLoading } from "../contexts/MapLoadingContext";
|
|
import { useUserId } from "../contexts/UserIdContext";
|
|
import { useDatabase } from "../contexts/DatabaseContext";
|
|
import { useParty } from "../contexts/PartyContext";
|
|
import { useAssets } from "../contexts/AssetsContext";
|
|
|
|
import { omit } from "../helpers/shared";
|
|
|
|
import useDebounce from "../hooks/useDebounce";
|
|
import useNetworkedState from "../hooks/useNetworkedState";
|
|
|
|
// Load session for auto complete
|
|
import Session from "./Session";
|
|
|
|
import Action from "../actions/Action";
|
|
|
|
import Map from "../components/map/Map";
|
|
import TokenBar from "../components/token/TokenBar";
|
|
|
|
import GlobalImageDrop from "../components/image/GlobalImageDrop";
|
|
|
|
import { Map as MapType } from "../types/Map";
|
|
import { MapState } from "../types/MapState";
|
|
import {
|
|
Asset,
|
|
AssetManifest,
|
|
AssetManifestAsset,
|
|
AssetManifestAssets,
|
|
} from "../types/Asset";
|
|
import { TokenState } from "../types/TokenState";
|
|
import { Drawing, DrawingState } from "../types/Drawing";
|
|
import { Fog, FogState } from "../types/Fog";
|
|
|
|
type MapActions = {
|
|
mapDrawActions: Action<Drawing>[];
|
|
mapDrawActionIndex: number;
|
|
fogDrawActions: Action<Fog>[];
|
|
fogDrawActionIndex: number;
|
|
};
|
|
|
|
type MapActionsKey = keyof Pick<
|
|
MapActions,
|
|
"mapDrawActions" | "fogDrawActions"
|
|
>;
|
|
type MapActionsIndexKey = keyof Pick<
|
|
MapActions,
|
|
"mapDrawActionIndex" | "fogDrawActionIndex"
|
|
>;
|
|
|
|
const defaultMapActions: MapActions = {
|
|
mapDrawActions: [],
|
|
mapDrawActionIndex: -1,
|
|
fogDrawActions: [],
|
|
fogDrawActionIndex: -1,
|
|
};
|
|
|
|
/**
|
|
* @typedef {object} NetworkedMapProps
|
|
* @property {Session} session
|
|
*/
|
|
|
|
/**
|
|
* @param {NetworkedMapProps} props
|
|
*/
|
|
function NetworkedMapAndTokens({ session }: { session: Session }) {
|
|
const { addToast } = useToasts();
|
|
const userId = useUserId();
|
|
const partyState = useParty();
|
|
const { assetLoadStart, assetProgressUpdate, isLoading } = useMapLoading();
|
|
|
|
const { updateMapState } = useMapData();
|
|
const { getAsset, putAsset } = useAssets();
|
|
|
|
const [currentMap, setCurrentMap] = useState<MapType | null>(null);
|
|
const [currentMapState, setCurrentMapState] =
|
|
useNetworkedState<MapState | null>(
|
|
null,
|
|
session,
|
|
"map_state",
|
|
500,
|
|
true,
|
|
"mapId"
|
|
);
|
|
const [assetManifest, setAssetManifest] =
|
|
useNetworkedState<AssetManifest | null>(
|
|
null,
|
|
session,
|
|
"manifest",
|
|
500,
|
|
true,
|
|
"mapId"
|
|
);
|
|
|
|
async function loadAssetManifestFromMap(map: MapType, mapState: MapState) {
|
|
const assets: AssetManifestAssets = {};
|
|
const { owner } = map;
|
|
let processedTokens = new Set();
|
|
for (let tokenState of Object.values(mapState.tokens)) {
|
|
if (tokenState.type === "file" && !processedTokens.has(tokenState.file)) {
|
|
processedTokens.add(tokenState.file);
|
|
assets[tokenState.file] = {
|
|
id: tokenState.file,
|
|
owner: tokenState.owner,
|
|
};
|
|
}
|
|
}
|
|
if (map.type === "file") {
|
|
assets[map.thumbnail] = { id: map.thumbnail, owner };
|
|
if (map.quality !== "original") {
|
|
const qualityId = map.resolutions[map.quality];
|
|
if (qualityId) {
|
|
assets[qualityId] = { id: qualityId, owner };
|
|
}
|
|
} else {
|
|
assets[map.file] = { id: map.file, owner };
|
|
}
|
|
}
|
|
setAssetManifest({ mapId: map.id, assets }, true, true);
|
|
}
|
|
|
|
function addAssetsIfNeeded(assets: AssetManifestAsset[]) {
|
|
setAssetManifest((prevManifest) => {
|
|
if (prevManifest?.assets) {
|
|
let newAssets = { ...prevManifest.assets };
|
|
for (let asset of assets) {
|
|
const id = asset.id;
|
|
const exists = id in newAssets;
|
|
if (!exists) {
|
|
newAssets[id] = asset;
|
|
}
|
|
}
|
|
return { ...prevManifest, assets: newAssets };
|
|
}
|
|
return prevManifest;
|
|
});
|
|
}
|
|
|
|
// Keep track of assets we are already requesting to prevent from loading them multiple times
|
|
const requestingAssetsRef = useRef(new Set());
|
|
|
|
useEffect(() => {
|
|
if (!assetManifest || !userId) {
|
|
return;
|
|
}
|
|
|
|
async function requestAssetsIfNeeded() {
|
|
if (!assetManifest) {
|
|
return;
|
|
}
|
|
for (let asset of Object.values(assetManifest.assets)) {
|
|
if (
|
|
asset.owner === userId ||
|
|
requestingAssetsRef.current.has(asset.id)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const owner = Object.values(partyState).find(
|
|
(player) => player.userId === asset.owner
|
|
);
|
|
|
|
// Ensure requests are added before any async operation to prevent them from sending twice
|
|
requestingAssetsRef.current.add(asset.id);
|
|
|
|
const cachedAsset = await getAsset(asset.id);
|
|
if (!owner) {
|
|
// Add no owner toast if we don't have asset in out cache
|
|
if (!cachedAsset) {
|
|
// TODO: Stop toast from appearing multiple times
|
|
addToast("Unable to find owner for asset");
|
|
}
|
|
requestingAssetsRef.current.delete(asset.id);
|
|
continue;
|
|
}
|
|
|
|
if (cachedAsset) {
|
|
requestingAssetsRef.current.delete(asset.id);
|
|
} else if (owner.sessionId) {
|
|
assetLoadStart(asset.id);
|
|
session.sendTo(owner.sessionId, "assetRequest", asset);
|
|
}
|
|
}
|
|
}
|
|
|
|
requestAssetsIfNeeded();
|
|
}, [
|
|
assetManifest,
|
|
partyState,
|
|
session,
|
|
userId,
|
|
addToast,
|
|
getAsset,
|
|
assetLoadStart,
|
|
]);
|
|
|
|
/**
|
|
* Map state
|
|
*/
|
|
|
|
const { database } = useDatabase();
|
|
// 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
|
|
) {
|
|
updateMapState(debouncedMapState.mapId, debouncedMapState);
|
|
}
|
|
}, [currentMap, debouncedMapState, userId, database, updateMapState]);
|
|
|
|
async function handleMapChange(newMap, newMapState) {
|
|
// Clear map before sending new one
|
|
setCurrentMap(null);
|
|
session.socket?.emit("map", null);
|
|
|
|
setCurrentMapState(newMapState, true, true);
|
|
setCurrentMap(newMap);
|
|
|
|
session.socket?.emit("map", newMap);
|
|
|
|
if (!newMap || !newMapState) {
|
|
setAssetManifest(null, true, true);
|
|
return;
|
|
}
|
|
|
|
await loadAssetManifestFromMap(newMap, newMapState);
|
|
}
|
|
|
|
function handleMapReset(newMapState) {
|
|
setCurrentMapState(newMapState, true, true);
|
|
setMapActions(defaultMapActions);
|
|
}
|
|
|
|
const [mapActions, setMapActions] = useState(defaultMapActions);
|
|
|
|
function addMapActions(
|
|
actions: Action<DrawingState | FogState>[],
|
|
indexKey: MapActionsIndexKey,
|
|
actionsKey: MapActionsKey,
|
|
shapesKey: "drawShapes" | "fogShapes"
|
|
) {
|
|
setMapActions((prevMapActions) => {
|
|
const newActions = [
|
|
...prevMapActions[actionsKey].slice(0, prevMapActions[indexKey] + 1),
|
|
...actions,
|
|
];
|
|
const newIndex = newActions.length - 1;
|
|
return {
|
|
...prevMapActions,
|
|
[actionsKey]: newActions,
|
|
[indexKey]: newIndex,
|
|
};
|
|
});
|
|
// Update map state by performing the actions on it
|
|
setCurrentMapState((prevMapState) => {
|
|
if (!prevMapState) {
|
|
return prevMapState;
|
|
}
|
|
let shapes = prevMapState[shapesKey];
|
|
for (let action of actions) {
|
|
shapes = action.execute(shapes);
|
|
}
|
|
return {
|
|
...prevMapState,
|
|
[shapesKey]: shapes,
|
|
};
|
|
});
|
|
}
|
|
|
|
function updateActionIndex(
|
|
change,
|
|
indexKey: MapActionsIndexKey,
|
|
actionsKey: MapActionsKey,
|
|
shapesKey: "drawShapes" | "fogShapes"
|
|
) {
|
|
const prevIndex = mapActions[indexKey];
|
|
const newIndex = Math.min(
|
|
Math.max(mapActions[indexKey] + change, -1),
|
|
mapActions[actionsKey].length - 1
|
|
);
|
|
|
|
setMapActions((prevMapActions) => ({
|
|
...prevMapActions,
|
|
[indexKey]: newIndex,
|
|
}));
|
|
|
|
// Update map state by either performing the actions or undoing them
|
|
setCurrentMapState((prevMapState) => {
|
|
if (prevMapState) {
|
|
let shapes = prevMapState[shapesKey];
|
|
if (prevIndex < newIndex) {
|
|
// Redo
|
|
for (let i = prevIndex + 1; i < newIndex + 1; i++) {
|
|
let action = mapActions[actionsKey][i];
|
|
shapes = action.execute(shapes);
|
|
}
|
|
} else {
|
|
// Undo
|
|
for (let i = prevIndex; i > newIndex; i--) {
|
|
let action = mapActions[actionsKey][i];
|
|
shapes = action.undo(shapes);
|
|
}
|
|
}
|
|
return {
|
|
...prevMapState,
|
|
[shapesKey]: shapes,
|
|
};
|
|
}
|
|
});
|
|
|
|
return newIndex;
|
|
}
|
|
|
|
function handleMapDraw(action: Action<DrawingState>) {
|
|
addMapActions(
|
|
[action],
|
|
"mapDrawActionIndex",
|
|
"mapDrawActions",
|
|
"drawShapes"
|
|
);
|
|
}
|
|
|
|
function handleMapDrawUndo() {
|
|
updateActionIndex(-1, "mapDrawActionIndex", "mapDrawActions", "drawShapes");
|
|
}
|
|
|
|
function handleMapDrawRedo() {
|
|
updateActionIndex(1, "mapDrawActionIndex", "mapDrawActions", "drawShapes");
|
|
}
|
|
|
|
function handleFogDraw(action: Action<FogState>) {
|
|
addMapActions(
|
|
[action],
|
|
"fogDrawActionIndex",
|
|
"fogDrawActions",
|
|
"fogShapes"
|
|
);
|
|
}
|
|
|
|
function handleFogDrawUndo() {
|
|
updateActionIndex(-1, "fogDrawActionIndex", "fogDrawActions", "fogShapes");
|
|
}
|
|
|
|
function handleFogDrawRedo() {
|
|
updateActionIndex(1, "fogDrawActionIndex", "fogDrawActions", "fogShapes");
|
|
}
|
|
|
|
// If map changes clear map actions
|
|
const previousMapIdRef = useRef();
|
|
useEffect(() => {
|
|
if (currentMap && currentMap?.id !== previousMapIdRef.current) {
|
|
setMapActions(defaultMapActions);
|
|
previousMapIdRef.current = currentMap?.id;
|
|
}
|
|
}, [currentMap]);
|
|
|
|
function handleNoteChange(note) {
|
|
setCurrentMapState((prevMapState) => ({
|
|
...prevMapState,
|
|
notes: {
|
|
...prevMapState.notes,
|
|
[note.id]: note,
|
|
},
|
|
}));
|
|
}
|
|
|
|
function handleNoteRemove(noteId: string) {
|
|
setCurrentMapState((prevMapState) => ({
|
|
...prevMapState,
|
|
notes: omit(prevMapState.notes, [noteId]),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Token state
|
|
*/
|
|
|
|
async function handleMapTokensStateCreate(tokenStates: TokenState[]) {
|
|
if (!currentMap || !currentMapState) {
|
|
return;
|
|
}
|
|
|
|
let assets: AssetManifestAsset[] = [];
|
|
for (let tokenState of tokenStates) {
|
|
if (tokenState.type === "file") {
|
|
assets.push({ id: tokenState.file, owner: tokenState.owner });
|
|
}
|
|
}
|
|
if (assets.length > 0) {
|
|
addAssetsIfNeeded(assets);
|
|
}
|
|
|
|
setCurrentMapState((prevMapState) => {
|
|
let newMapTokens = { ...prevMapState.tokens };
|
|
for (let tokenState of tokenStates) {
|
|
newMapTokens[tokenState.id] = tokenState;
|
|
}
|
|
return { ...prevMapState, tokens: newMapTokens };
|
|
});
|
|
}
|
|
|
|
function handleMapTokenStateChange(change) {
|
|
if (!currentMapState) {
|
|
return;
|
|
}
|
|
setCurrentMapState((prevMapState) => {
|
|
let tokens = { ...prevMapState.tokens };
|
|
for (let id in change) {
|
|
if (id in tokens) {
|
|
tokens[id] = { ...tokens[id], ...change[id] };
|
|
}
|
|
}
|
|
|
|
return {
|
|
...prevMapState,
|
|
tokens,
|
|
};
|
|
});
|
|
}
|
|
|
|
function handleMapTokenStateRemove(tokenState) {
|
|
setCurrentMapState((prevMapState) => {
|
|
const { [tokenState.id]: old, ...rest } = prevMapState.tokens;
|
|
return { ...prevMapState, tokens: rest };
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
async function handlePeerData({
|
|
id,
|
|
data,
|
|
reply,
|
|
}: {
|
|
id: string;
|
|
data;
|
|
reply;
|
|
}) {
|
|
if (id === "assetRequest") {
|
|
const asset = await getAsset(data.id);
|
|
if (asset) {
|
|
reply("assetResponseSuccess", asset, undefined, data.id);
|
|
} else {
|
|
reply("assetResponseFail", data.id, undefined, data.id);
|
|
}
|
|
}
|
|
|
|
if (id === "assetResponseSuccess") {
|
|
const asset = data;
|
|
await putAsset(asset);
|
|
requestingAssetsRef.current.delete(asset.id);
|
|
}
|
|
|
|
if (id === "assetResponseFail") {
|
|
const assetId = data;
|
|
requestingAssetsRef.current.delete(assetId);
|
|
}
|
|
}
|
|
|
|
function handlePeerDataProgress({
|
|
id,
|
|
total,
|
|
count,
|
|
}: {
|
|
id: string;
|
|
total: number;
|
|
count: number;
|
|
}) {
|
|
assetProgressUpdate({ id, total, count });
|
|
}
|
|
|
|
async function handleSocketMap(map) {
|
|
if (map) {
|
|
setCurrentMap(map);
|
|
} else {
|
|
setCurrentMap(null);
|
|
}
|
|
}
|
|
|
|
session.on("peerData", handlePeerData);
|
|
session.on("peerDataProgress", handlePeerDataProgress);
|
|
session.socket?.on("map", handleSocketMap);
|
|
|
|
return () => {
|
|
session.off("peerData", handlePeerData);
|
|
session.off("peerDataProgress", handlePeerDataProgress);
|
|
session.socket?.off("map", handleSocketMap);
|
|
};
|
|
});
|
|
|
|
const canChangeMap = !isLoading;
|
|
|
|
const canEditMapDrawing =
|
|
currentMap &&
|
|
currentMapState &&
|
|
(currentMapState.editFlags.includes("drawing") ||
|
|
currentMap?.owner === userId);
|
|
|
|
const canEditFogDrawing =
|
|
currentMap &&
|
|
currentMapState &&
|
|
(currentMapState.editFlags.includes("fog") || currentMap?.owner === userId);
|
|
|
|
const canEditNotes =
|
|
currentMap &&
|
|
currentMapState &&
|
|
(currentMapState.editFlags.includes("notes") ||
|
|
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 &&
|
|
currentMap &&
|
|
!currentMapState.editFlags.includes("tokens") &&
|
|
currentMap?.owner !== userId
|
|
) {
|
|
for (let token of Object.values(currentMapState.tokens)) {
|
|
if (token.owner !== userId) {
|
|
disabledMapTokens[token.id] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<GlobalImageDrop
|
|
onMapChange={handleMapChange}
|
|
onMapTokensStateCreate={handleMapTokensStateCreate}
|
|
>
|
|
<Map
|
|
map={currentMap}
|
|
mapState={currentMapState}
|
|
mapActions={mapActions}
|
|
onMapTokenStateChange={handleMapTokenStateChange}
|
|
onMapTokenStateRemove={handleMapTokenStateRemove}
|
|
onMapChange={handleMapChange}
|
|
onMapReset={handleMapReset}
|
|
onMapDraw={handleMapDraw}
|
|
onMapDrawUndo={handleMapDrawUndo}
|
|
onMapDrawRedo={handleMapDrawRedo}
|
|
onFogDraw={handleFogDraw}
|
|
onFogDrawUndo={handleFogDrawUndo}
|
|
onFogDrawRedo={handleFogDrawRedo}
|
|
onMapNoteChange={handleNoteChange}
|
|
onMapNoteRemove={handleNoteRemove}
|
|
allowMapDrawing={canEditMapDrawing}
|
|
allowFogDrawing={canEditFogDrawing}
|
|
allowMapChange={canChangeMap}
|
|
allowNoteEditing={canEditNotes}
|
|
disabledTokens={disabledMapTokens}
|
|
session={session}
|
|
/>
|
|
<TokenBar onMapTokensStateCreate={handleMapTokensStateCreate} />
|
|
</GlobalImageDrop>
|
|
);
|
|
}
|
|
|
|
export default NetworkedMapAndTokens;
|