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",
"err-code": "^2.0.3",
"fake-indexeddb": "^3.1.2",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^6.4.1",
"interactjs": "^1.9.7",
"konva": "^7.1.8",

View File

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

View File

@ -14,10 +14,16 @@ import DiceTrayButton from "./DiceTrayButton";
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 }) {
const { playerState, setPlayerState, partyState } = useContext(PlayerContext);
const setPlayerState = useContext(PlayerUpdaterContext);
const playerState = useContext(PlayerStateWithoutPointerContext);
const partyState = useContext(PartyContext);
const [fullScreen] = useSetting("map.fullScreen");
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 DatabaseContext from "./DatabaseContext";
@ -6,7 +7,12 @@ import AuthContext from "./AuthContext";
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 }) {
const { userId } = useContext(AuthContext);
@ -17,14 +23,13 @@ export function PlayerProvider({ session, children }) {
nickname: "",
timer: null,
dice: { share: false, rolls: [] },
pointer: {},
pointer: { position: { x: 0, y: 0 }, visible: false },
sessionId: null,
userId,
},
session,
"player_state"
);
const [partyState, setPartyState] = useState({});
useEffect(() => {
if (!database || databaseStatus === "loading") {
@ -45,7 +50,7 @@ export function PlayerProvider({ session, children }) {
}
loadNickname();
}, [database, databaseStatus]);
}, [database, databaseStatus, setPlayerState]);
useEffect(() => {
if (
@ -64,7 +69,7 @@ export function PlayerProvider({ session, children }) {
...prevState,
userId,
}));
}, [userId]);
}, [userId, setPlayerState]);
useEffect(() => {
function handleSocketConnect() {
@ -72,21 +77,11 @@ export function PlayerProvider({ session, children }) {
setPlayerState({ ...playerState, sessionId: session.id });
}
function handleSocketPartyState(partyState) {
if (partyState) {
const { [session.id]: _, ...otherMembersState } = partyState;
setPartyState(otherMembersState);
} else {
setPartyState({});
}
}
session.on("connected", handleSocketConnect);
if (session.socket) {
session.socket.on("connect", handleSocketConnect);
session.socket.on("reconnect", handleSocketConnect);
session.socket.on("party_state", handleSocketPartyState);
}
return () => {
@ -95,19 +90,32 @@ export function PlayerProvider({ session, children }) {
if (session.socket) {
session.socket.off("connect", handleSocketConnect);
session.socket.off("reconnect", handleSocketConnect);
session.socket.off("party_state", handleSocketPartyState);
}
};
});
const value = {
playerState,
setPlayerState,
partyState,
};
const [playerStateWithoutPointer, setPlayerStateWithoutPointer] = useState(
playerState
);
useEffect(() => {
const { pointer, ...state } = playerState;
if (
!playerStateWithoutPointer ||
!compare(playerStateWithoutPointer, state)
) {
setPlayerStateWithoutPointer(state);
}
}, [playerState, playerStateWithoutPointer]);
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) {
const [state, _setState] = useState(defaultState);
@ -6,10 +6,10 @@ function useNetworkedState(defaultState, session, eventName) {
const [dirty, setDirty] = useState(false);
// Update dirty at the same time as state
function setState(update, sync = true) {
const setState = useCallback((update, sync = true) => {
_setState(update);
setDirty(sync);
}
}, []);
useEffect(() => {
if (session.socket && dirty) {
@ -31,7 +31,7 @@ function useNetworkedState(defaultState, session, eventName) {
session.socket.off(eventName, handleSocketEvent);
}
};
}, [session.socket]);
}, [session.socket, eventName]);
return [state, setState];
}

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ import AuthContext from "../contexts/AuthContext";
import { MapStageProvider } from "../contexts/MapStageContext";
import DatabaseContext from "../contexts/DatabaseContext";
import { PlayerProvider } from "../contexts/PlayerContext";
import { PartyProvider } from "../contexts/PartyContext";
import NetworkedMapAndTokens from "../network/NetworkedMapAndTokens";
import NetworkedParty from "../network/NetworkedParty";
@ -104,6 +105,7 @@ function Game() {
return (
<PlayerProvider session={session}>
<PartyProvider session={session}>
<MapStageProvider value={mapStageRef}>
<Flex sx={{ flexDirection: "column", height: "100%" }}>
<Flex
@ -117,7 +119,10 @@ function Game() {
<NetworkedMapAndTokens session={session} />
</Flex>
</Flex>
<Banner isOpen={!!peerError} onRequestClose={() => setPeerError(null)}>
<Banner
isOpen={!!peerError}
onRequestClose={() => setPeerError(null)}
>
<Box p={1}>
<Text as="p" variant="body2">
{peerError} See <Link to="/faq#connection">FAQ</Link> for more
@ -147,6 +152,7 @@ function Game() {
{authenticationStatus === "unknown" && !offline && <LoadingOverlay />}
<MapLoadingOverlay />
</MapStageProvider>
</PartyProvider>
</PlayerProvider>
);
}

View File

@ -5451,7 +5451,7 @@ fake-indexeddb@^3.1.2:
realistic-structured-clone "^2.0.1"
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"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==