Optimised pointer

This commit is contained in:
Mitchell McCaffrey 2020-12-31 17:56:51 +11:00
parent 12d9e64461
commit 501fc4377c
11 changed files with 224 additions and 152 deletions

View File

@ -18,6 +18,7 @@
"dexie": "^3.0.3", "dexie": "^3.0.3",
"err-code": "^2.0.3", "err-code": "^2.0.3",
"fake-indexeddb": "^3.1.2", "fake-indexeddb": "^3.1.2",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^6.4.1", "fuse.js": "^6.4.1",
"interactjs": "^1.9.7", "interactjs": "^1.9.7",
"konva": "^7.1.8", "konva": "^7.1.8",

View File

@ -18,6 +18,8 @@ import MapStageContext, {
import AuthContext from "../../contexts/AuthContext"; import AuthContext from "../../contexts/AuthContext";
import SettingsContext from "../../contexts/SettingsContext"; import SettingsContext from "../../contexts/SettingsContext";
import KeyboardContext from "../../contexts/KeyboardContext"; import KeyboardContext from "../../contexts/KeyboardContext";
import { PlayerUpdaterContext } from "../../contexts/PlayerContext";
import PartyContext from "../../contexts/PartyContext";
function MapInteraction({ function MapInteraction({
map, map,
@ -35,6 +37,7 @@ function MapInteraction({
useEffect(() => { useEffect(() => {
if ( if (
!map || !map ||
!mapState ||
(map.type === "file" && !map.file && !map.resolutions) || (map.type === "file" && !map.file && !map.resolutions) ||
mapState.mapId !== map.id mapState.mapId !== map.id
) { ) {
@ -177,6 +180,8 @@ function MapInteraction({
const auth = useContext(AuthContext); const auth = useContext(AuthContext);
const settings = useContext(SettingsContext); const settings = useContext(SettingsContext);
const player = useContext(PlayerUpdaterContext);
const party = useContext(PartyContext);
const mapInteraction = { const mapInteraction = {
stageScale, stageScale,
@ -218,15 +223,19 @@ function MapInteraction({
/> />
{/* Forward auth context to konva elements */} {/* Forward auth context to konva elements */}
<AuthContext.Provider value={auth}> <AuthContext.Provider value={auth}>
<SettingsContext.Provider value={settings}> <PlayerUpdaterContext.Provider value={player}>
<KeyboardContext.Provider value={keyboardValue}> <PartyContext.Provider value={party}>
<MapInteractionProvider value={mapInteraction}> <SettingsContext.Provider value={settings}>
<MapStageProvider value={mapStageRef}> <KeyboardContext.Provider value={keyboardValue}>
{mapLoaded && children} <MapInteractionProvider value={mapInteraction}>
</MapStageProvider> <MapStageProvider value={mapStageRef}>
</MapInteractionProvider> {mapLoaded && children}
</KeyboardContext.Provider> </MapStageProvider>
</SettingsContext.Provider> </MapInteractionProvider>
</KeyboardContext.Provider>
</SettingsContext.Provider>
</PartyContext.Provider>
</PlayerUpdaterContext.Provider>
</AuthContext.Provider> </AuthContext.Provider>
</Layer> </Layer>
</Stage> </Stage>

View File

@ -14,10 +14,16 @@ import DiceTrayButton from "./DiceTrayButton";
import useSetting from "../../helpers/useSetting"; import useSetting from "../../helpers/useSetting";
import PlayerContext from "../../contexts/PlayerContext"; import PartyContext from "../../contexts/PartyContext";
import {
PlayerUpdaterContext,
PlayerStateWithoutPointerContext,
} from "../../contexts/PlayerContext";
function Party({ gameId, stream, partyStreams, onStreamStart, onStreamEnd }) { function Party({ gameId, stream, partyStreams, onStreamStart, onStreamEnd }) {
const { playerState, setPlayerState, partyState } = useContext(PlayerContext); const setPlayerState = useContext(PlayerUpdaterContext);
const playerState = useContext(PlayerStateWithoutPointerContext);
const partyState = useContext(PartyContext);
const [fullScreen] = useSetting("map.fullScreen"); const [fullScreen] = useSetting("map.fullScreen");
const [shareDice, setShareDice] = useSetting("dice.shareDice"); const [shareDice, setShareDice] = useSetting("dice.shareDice");

View File

@ -0,0 +1,34 @@
import React, { useState, useEffect } from "react";
const PartyContext = React.createContext();
export function PartyProvider({ session, children }) {
const [partyState, setPartyState] = useState({});
useEffect(() => {
function handleSocketPartyState(partyState) {
if (partyState) {
const { [session.id]: _, ...otherMembersState } = partyState;
setPartyState(otherMembersState);
} else {
setPartyState({});
}
}
if (session.socket) {
session.socket.on("party_state", handleSocketPartyState);
}
return () => {
if (session.socket) {
session.socket.off("party_state", handleSocketPartyState);
}
};
});
return (
<PartyContext.Provider value={partyState}>{children}</PartyContext.Provider>
);
}
export default PartyContext;

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useContext } from "react"; import React, { useEffect, useContext, useState } from "react";
import compare from "fast-deep-equal";
import useNetworkedState from "../helpers/useNetworkedState"; import useNetworkedState from "../helpers/useNetworkedState";
import DatabaseContext from "./DatabaseContext"; import DatabaseContext from "./DatabaseContext";
@ -6,7 +7,12 @@ import AuthContext from "./AuthContext";
import { getRandomMonster } from "../helpers/monsters"; import { getRandomMonster } from "../helpers/monsters";
const PlayerContext = React.createContext(); export const PlayerStateContext = React.createContext();
export const PlayerUpdaterContext = React.createContext(() => {});
/**
* Store the player state without the pointer data to prevent unnecessary updates
*/
export const PlayerStateWithoutPointerContext = React.createContext();
export function PlayerProvider({ session, children }) { export function PlayerProvider({ session, children }) {
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
@ -17,14 +23,13 @@ export function PlayerProvider({ session, children }) {
nickname: "", nickname: "",
timer: null, timer: null,
dice: { share: false, rolls: [] }, dice: { share: false, rolls: [] },
pointer: {}, pointer: { position: { x: 0, y: 0 }, visible: false },
sessionId: null, sessionId: null,
userId, userId,
}, },
session, session,
"player_state" "player_state"
); );
const [partyState, setPartyState] = useState({});
useEffect(() => { useEffect(() => {
if (!database || databaseStatus === "loading") { if (!database || databaseStatus === "loading") {
@ -45,7 +50,7 @@ export function PlayerProvider({ session, children }) {
} }
loadNickname(); loadNickname();
}, [database, databaseStatus]); }, [database, databaseStatus, setPlayerState]);
useEffect(() => { useEffect(() => {
if ( if (
@ -64,7 +69,7 @@ export function PlayerProvider({ session, children }) {
...prevState, ...prevState,
userId, userId,
})); }));
}, [userId]); }, [userId, setPlayerState]);
useEffect(() => { useEffect(() => {
function handleSocketConnect() { function handleSocketConnect() {
@ -72,21 +77,11 @@ export function PlayerProvider({ session, children }) {
setPlayerState({ ...playerState, sessionId: session.id }); setPlayerState({ ...playerState, sessionId: session.id });
} }
function handleSocketPartyState(partyState) {
if (partyState) {
const { [session.id]: _, ...otherMembersState } = partyState;
setPartyState(otherMembersState);
} else {
setPartyState({});
}
}
session.on("connected", handleSocketConnect); session.on("connected", handleSocketConnect);
if (session.socket) { if (session.socket) {
session.socket.on("connect", handleSocketConnect); session.socket.on("connect", handleSocketConnect);
session.socket.on("reconnect", handleSocketConnect); session.socket.on("reconnect", handleSocketConnect);
session.socket.on("party_state", handleSocketPartyState);
} }
return () => { return () => {
@ -95,19 +90,32 @@ export function PlayerProvider({ session, children }) {
if (session.socket) { if (session.socket) {
session.socket.off("connect", handleSocketConnect); session.socket.off("connect", handleSocketConnect);
session.socket.off("reconnect", handleSocketConnect); session.socket.off("reconnect", handleSocketConnect);
session.socket.off("party_state", handleSocketPartyState);
} }
}; };
}); });
const value = { const [playerStateWithoutPointer, setPlayerStateWithoutPointer] = useState(
playerState, playerState
setPlayerState, );
partyState, useEffect(() => {
}; const { pointer, ...state } = playerState;
if (
!playerStateWithoutPointer ||
!compare(playerStateWithoutPointer, state)
) {
setPlayerStateWithoutPointer(state);
}
}, [playerState, playerStateWithoutPointer]);
return ( return (
<PlayerContext.Provider value={value}>{children}</PlayerContext.Provider> <PlayerStateContext.Provider value={playerState}>
<PlayerUpdaterContext.Provider value={setPlayerState}>
<PlayerStateWithoutPointerContext.Provider
value={playerStateWithoutPointer}
>
{children}
</PlayerStateWithoutPointerContext.Provider>
</PlayerUpdaterContext.Provider>
</PlayerStateContext.Provider>
); );
} }
export default PlayerContext;

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
function useNetworkedState(defaultState, session, eventName) { function useNetworkedState(defaultState, session, eventName) {
const [state, _setState] = useState(defaultState); const [state, _setState] = useState(defaultState);
@ -6,10 +6,10 @@ function useNetworkedState(defaultState, session, eventName) {
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
// Update dirty at the same time as state // Update dirty at the same time as state
function setState(update, sync = true) { const setState = useCallback((update, sync = true) => {
_setState(update); _setState(update);
setDirty(sync); setDirty(sync);
} }, []);
useEffect(() => { useEffect(() => {
if (session.socket && dirty) { if (session.socket && dirty) {
@ -31,7 +31,7 @@ function useNetworkedState(defaultState, session, eventName) {
session.socket.off(eventName, handleSocketEvent); session.socket.off(eventName, handleSocketEvent);
} }
}; };
}, [session.socket]); }, [session.socket, eventName]);
return [state, setState]; return [state, setState];
} }

View File

@ -5,7 +5,7 @@ import MapDataContext from "../contexts/MapDataContext";
import MapLoadingContext from "../contexts/MapLoadingContext"; import MapLoadingContext from "../contexts/MapLoadingContext";
import AuthContext from "../contexts/AuthContext"; import AuthContext from "../contexts/AuthContext";
import DatabaseContext from "../contexts/DatabaseContext"; import DatabaseContext from "../contexts/DatabaseContext";
import PlayerContext from "../contexts/PlayerContext"; import PartyContext from "../contexts/PartyContext";
import { omit } from "../helpers/shared"; import { omit } from "../helpers/shared";
import useDebounce from "../helpers/useDebounce"; import useDebounce from "../helpers/useDebounce";
@ -27,7 +27,7 @@ import Tokens from "../components/token/Tokens";
*/ */
function NetworkedMapAndTokens({ session }) { function NetworkedMapAndTokens({ session }) {
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const { partyState } = useContext(PlayerContext); const partyState = useContext(PartyContext);
const { const {
assetLoadStart, assetLoadStart,
assetLoadFinish, assetLoadFinish,

View File

@ -2,29 +2,31 @@ import React, { useState, useContext, useEffect, useRef } from "react";
import { Group } from "react-konva"; import { Group } from "react-konva";
import AuthContext from "../contexts/AuthContext"; import AuthContext from "../contexts/AuthContext";
import PartyContext from "../contexts/PartyContext";
import { PlayerUpdaterContext } from "../contexts/PlayerContext";
import MapPointer from "../components/map/MapPointer"; import MapPointer from "../components/map/MapPointer";
import { isEmpty } from "../helpers/shared"; import { isEmpty } from "../helpers/shared";
import { lerp } from "../helpers/vector2"; import { lerp, compare } from "../helpers/vector2";
// Send pointer updates every 33ms // Send pointer updates every 33ms
const sendTickRate = 33; const sendTickRate = 100;
function NetworkedMapPointer({ session, active, gridSize }) { let t = 0;
function NetworkedMapPointer({ active, gridSize }) {
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const [pointerState, setPointerState] = useState({}); const setPlayerState = useContext(PlayerUpdaterContext);
const partyState = useContext(PartyContext);
const [localPointerState, setLocalPointerState] = useState({});
useEffect(() => { useEffect(() => {
if (userId && !(userId in pointerState)) { if (userId && !(userId in localPointerState)) {
setPointerState({ setLocalPointerState({
[userId]: { position: { x: 0, y: 0 }, visible: false, id: userId }, [userId]: { position: { x: 0, y: 0 }, visible: false, id: userId },
}); });
} }
}, [userId, pointerState]); }, [userId, localPointerState]);
const sessionRef = useRef(session);
useEffect(() => {
sessionRef.current = session;
}, [session]);
// Send pointer updates every sendTickRate to peers to save on bandwidth // Send pointer updates every sendTickRate to peers to save on bandwidth
// We use requestAnimationFrame as setInterval was being blocked during // We use requestAnimationFrame as setInterval was being blocked during
@ -42,8 +44,14 @@ function NetworkedMapPointer({ session, active, gridSize }) {
if (counter > sendTickRate) { if (counter > sendTickRate) {
counter -= sendTickRate; counter -= sendTickRate;
if (ownPointerUpdateRef.current && sessionRef.current) { if (ownPointerUpdateRef.current) {
sessionRef.current.send("pointer", ownPointerUpdateRef.current); const { position, visible } = ownPointerUpdateRef.current;
console.log("send time", performance.now() - t);
t = performance.now();
setPlayerState((prev) => ({
...prev,
pointer: { position, visible },
}));
ownPointerUpdateRef.current = null; ownPointerUpdateRef.current = null;
} }
} }
@ -55,7 +63,7 @@ function NetworkedMapPointer({ session, active, gridSize }) {
}, []); }, []);
function updateOwnPointerState(position, visible) { function updateOwnPointerState(position, visible) {
setPointerState((prev) => ({ setLocalPointerState((prev) => ({
...prev, ...prev,
[userId]: { position, visible, id: userId }, [userId]: { position, visible, id: userId },
})); }));
@ -75,39 +83,43 @@ function NetworkedMapPointer({ session, active, gridSize }) {
} }
// Handle pointer data receive // Handle pointer data receive
const syncedPointerStateRef = useRef({}); const interpolationsRef = useRef({});
useEffect(() => { useEffect(() => {
function handlePeerData({ id, data }) { // TODO: Handle player disconnect while pointer visible
if (id === "pointer") { const interpolations = interpolationsRef.current;
// Setup an interpolation to the current pointer data when receiving a pointer event for (let player of Object.values(partyState)) {
if (syncedPointerStateRef.current[data.id]) { const id = player.userId;
const from = syncedPointerStateRef.current[data.id].to; const pointer = player.pointer;
syncedPointerStateRef.current[data.id] = { if (!id) {
id: data.id, continue;
from: { }
...from, if (!(id in interpolations)) {
time: performance.now(), interpolations[id] = {
}, id,
to: { from: null,
...data, to: { ...pointer, time: performance.now() + sendTickRate },
time: performance.now() + sendTickRate, };
}, } else if (
}; !compare(interpolations[id].to.position, pointer.position, 0.0001) ||
} else { interpolations[id].to.visible !== pointer.visible
syncedPointerStateRef.current[data.id] = { ) {
from: null, console.log("receive time", performance.now() - t, pointer.position);
to: { ...data, time: performance.now() + sendTickRate }, t = performance.now();
}; const from = interpolations[id].to;
} interpolations[id] = {
id,
from: {
...from,
time: performance.now(),
},
to: {
...pointer,
time: performance.now() + sendTickRate,
},
};
} }
} }
}, [partyState]);
session.on("peerData", handlePeerData);
return () => {
session.off("peerData", handlePeerData);
};
});
// Animate to the peer pointer positions // Animate to the peer pointer positions
useEffect(() => { useEffect(() => {
@ -116,36 +128,32 @@ function NetworkedMapPointer({ session, active, gridSize }) {
function animate(time) { function animate(time) {
request = requestAnimationFrame(animate); request = requestAnimationFrame(animate);
let interpolatedPointerState = {}; let interpolatedPointerState = {};
for (let syncState of Object.values(syncedPointerStateRef.current)) { for (let interp of Object.values(interpolationsRef.current)) {
if (!syncState.from || !syncState.to) { if (!interp.from || !interp.to) {
continue; continue;
} }
const totalInterpTime = syncState.to.time - syncState.from.time; const totalInterpTime = interp.to.time - interp.from.time;
const currentInterpTime = time - syncState.from.time; const currentInterpTime = time - interp.from.time;
const alpha = currentInterpTime / totalInterpTime; const alpha = currentInterpTime / totalInterpTime;
if (alpha >= 0 && alpha <= 1) { if (alpha >= 0 && alpha <= 1) {
interpolatedPointerState[syncState.id] = { interpolatedPointerState[interp.id] = {
id: syncState.to.id, id: interp.id,
visible: syncState.from.visible, visible: interp.from.visible,
position: lerp( position: lerp(interp.from.position, interp.to.position, alpha),
syncState.from.position,
syncState.to.position,
alpha
),
}; };
} }
if (alpha > 1 && !syncState.to.visible) { if (alpha > 1 && !interp.to.visible) {
interpolatedPointerState[syncState.id] = { interpolatedPointerState[interp.id] = {
id: syncState.id, id: interp.id,
visible: syncState.to.visible, visible: interp.to.visible,
position: syncState.to.position, position: interp.to.position,
}; };
delete syncedPointerStateRef.current[syncState.to.id]; delete interpolationsRef.current[interp.id];
} }
} }
if (!isEmpty(interpolatedPointerState)) { if (!isEmpty(interpolatedPointerState)) {
setPointerState((prev) => ({ setLocalPointerState((prev) => ({
...prev, ...prev,
...interpolatedPointerState, ...interpolatedPointerState,
})); }));
@ -159,7 +167,7 @@ function NetworkedMapPointer({ session, active, gridSize }) {
return ( return (
<Group> <Group>
{Object.values(pointerState).map((pointer) => ( {Object.values(localPointerState).map((pointer) => (
<MapPointer <MapPointer
key={pointer.id} key={pointer.id}
gridSize={gridSize} gridSize={gridSize}

View File

@ -5,7 +5,7 @@ import React, { useContext, useState, useEffect, useCallback } from "react";
import Session from "./Session"; import Session from "./Session";
import { isStreamStopped, omit } from "../helpers/shared"; import { isStreamStopped, omit } from "../helpers/shared";
import PlayerContext from "../contexts/PlayerContext"; import PartyContext from "../contexts/PartyContext";
import Party from "../components/party/Party"; import Party from "../components/party/Party";
@ -19,7 +19,7 @@ import Party from "../components/party/Party";
* @param {NetworkedPartyProps} props * @param {NetworkedPartyProps} props
*/ */
function NetworkedParty({ gameId, session }) { function NetworkedParty({ gameId, session }) {
const { partyState } = useContext(PlayerContext); const partyState = useContext(PartyContext);
const [stream, setStream] = useState(null); const [stream, setStream] = useState(null);
const [partyStreams, setPartyStreams] = useState({}); const [partyStreams, setPartyStreams] = useState({});

View File

@ -13,6 +13,7 @@ import AuthContext from "../contexts/AuthContext";
import { MapStageProvider } from "../contexts/MapStageContext"; import { MapStageProvider } from "../contexts/MapStageContext";
import DatabaseContext from "../contexts/DatabaseContext"; import DatabaseContext from "../contexts/DatabaseContext";
import { PlayerProvider } from "../contexts/PlayerContext"; import { PlayerProvider } from "../contexts/PlayerContext";
import { PartyProvider } from "../contexts/PartyContext";
import NetworkedMapAndTokens from "../network/NetworkedMapAndTokens"; import NetworkedMapAndTokens from "../network/NetworkedMapAndTokens";
import NetworkedParty from "../network/NetworkedParty"; import NetworkedParty from "../network/NetworkedParty";
@ -104,49 +105,54 @@ function Game() {
return ( return (
<PlayerProvider session={session}> <PlayerProvider session={session}>
<MapStageProvider value={mapStageRef}> <PartyProvider session={session}>
<Flex sx={{ flexDirection: "column", height: "100%" }}> <MapStageProvider value={mapStageRef}>
<Flex <Flex sx={{ flexDirection: "column", height: "100%" }}>
sx={{ <Flex
justifyContent: "space-between", sx={{
flexGrow: 1, justifyContent: "space-between",
height: "100%", flexGrow: 1,
}} height: "100%",
> }}
<NetworkedParty session={session} gameId={gameId} /> >
<NetworkedMapAndTokens session={session} /> <NetworkedParty session={session} gameId={gameId} />
<NetworkedMapAndTokens session={session} />
</Flex>
</Flex> </Flex>
</Flex> <Banner
<Banner isOpen={!!peerError} onRequestClose={() => setPeerError(null)}> isOpen={!!peerError}
<Box p={1}> onRequestClose={() => setPeerError(null)}
<Text as="p" variant="body2"> >
{peerError} See <Link to="/faq#connection">FAQ</Link> for more <Box p={1}>
information. <Text as="p" variant="body2">
</Text> {peerError} See <Link to="/faq#connection">FAQ</Link> for more
</Box> information.
</Banner> </Text>
<Banner isOpen={offline} onRequestClose={() => {}} allowClose={false}> </Box>
<Box p={1}> </Banner>
<Text as="p" variant="body2"> <Banner isOpen={offline} onRequestClose={() => {}} allowClose={false}>
Unable to connect to game, refresh to reconnect. <Box p={1}>
</Text> <Text as="p" variant="body2">
</Box> Unable to connect to game, refresh to reconnect.
</Banner> </Text>
<Banner </Box>
isOpen={!connected && authenticationStatus === "authenticated"} </Banner>
onRequestClose={() => {}} <Banner
allowClose={false} isOpen={!connected && authenticationStatus === "authenticated"}
> onRequestClose={() => {}}
<Box p={1}> allowClose={false}
<Text as="p" variant="body2"> >
Disconnected. Attempting to reconnect... <Box p={1}>
</Text> <Text as="p" variant="body2">
</Box> Disconnected. Attempting to reconnect...
</Banner> </Text>
<AuthModal isOpen={authenticationStatus === "unauthenticated"} /> </Box>
{authenticationStatus === "unknown" && !offline && <LoadingOverlay />} </Banner>
<MapLoadingOverlay /> <AuthModal isOpen={authenticationStatus === "unauthenticated"} />
</MapStageProvider> {authenticationStatus === "unknown" && !offline && <LoadingOverlay />}
<MapLoadingOverlay />
</MapStageProvider>
</PartyProvider>
</PlayerProvider> </PlayerProvider>
); );
} }

View File

@ -5451,7 +5451,7 @@ fake-indexeddb@^3.1.2:
realistic-structured-clone "^2.0.1" realistic-structured-clone "^2.0.1"
setimmediate "^1.0.5" setimmediate "^1.0.5"
fast-deep-equal@^3.1.1: fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3" version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==