Merge pull request #15 from mitchemmc/feature/map-thumbnail

v1.4.2
This commit is contained in:
Mitchell McCaffrey 2020-07-20 21:39:15 +10:00 committed by GitHub
commit 42f4d0fdfa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1606 additions and 1125 deletions

View File

@ -1,6 +1,6 @@
{
"name": "owlbear-rodeo",
"version": "1.4.1",
"version": "1.4.2",
"private": true,
"dependencies": {
"@msgpack/msgpack": "^1.12.1",
@ -31,7 +31,7 @@
"react-spring": "^8.0.27",
"react-use-gesture": "^7.0.15",
"shortid": "^2.2.15",
"simple-peer": "^9.6.2",
"simple-peer": "feross/simple-peer#694/head",
"simplebar-react": "^2.1.0",
"simplify-js": "^1.2.4",
"socket.io-client": "^2.3.0",

View File

@ -3,7 +3,7 @@ import { Box } from "theme-ui";
import Spinner from "./Spinner";
function LoadingOverlay() {
function LoadingOverlay(bg) {
return (
<Box
sx={{
@ -17,11 +17,15 @@ function LoadingOverlay() {
left: 0,
flexDirection: "column",
}}
bg="muted"
bg={bg}
>
<Spinner />
</Box>
);
}
LoadingOverlay.defaultProps = {
bg: "muted",
};
export default LoadingOverlay;

View File

@ -30,20 +30,14 @@ function StyledModal({
}}
{...props}
>
{/* Stop keyboard events when modal is open to prevent shortcuts from triggering */}
<div
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
>
{children}
{allowClose && (
<Close
m={0}
sx={{ position: "absolute", top: 0, right: 0 }}
onClick={onRequestClose}
/>
)}
</div>
{children}
{allowClose && (
<Close
m={0}
sx={{ position: "absolute", top: 0, right: 0 }}
onClick={onRequestClose}
/>
)}
</Modal>
);
}

View File

@ -1,4 +1,5 @@
import React, { useState, useContext, useEffect } from "react";
import { Group } from "react-konva";
import MapControls from "./MapControls";
import MapInteraction from "./MapInteraction";
@ -32,6 +33,7 @@ function Map({
onFogDrawRedo,
allowMapDrawing,
allowFogDrawing,
allowMapChange,
disabledTokens,
}) {
const { tokensById } = useContext(TokenDataContext);
@ -141,6 +143,9 @@ function Map({
if (!allowFogDrawing) {
disabledControls.push("fog");
}
if (!allowMapChange) {
disabledControls.push("map");
}
const disabledSettings = { fog: [], drawing: [] };
if (mapShapes.length === 0) {
@ -204,29 +209,39 @@ function Map({
}
}
const mapTokens =
mapState &&
Object.values(mapState.tokens)
.sort(sortMapTokenStates)
.map((tokenState) => (
<MapToken
key={tokenState.id}
token={tokensById[tokenState.tokenId]}
tokenState={tokenState}
tokenSizePercent={tokenSizePercent}
onTokenStateChange={onMapTokenStateChange}
onTokenMenuOpen={handleTokenMenuOpen}
onTokenDragStart={(e) =>
setDraggingTokenOptions({ tokenState, tokenImage: e.target })
}
onTokenDragEnd={() => setDraggingTokenOptions(null)}
draggable={
(selectedToolId === "pan" || selectedToolId === "erase") &&
!(tokenState.id in disabledTokens)
}
mapState={mapState}
/>
));
const mapTokens = mapState && (
<Group>
{Object.values(mapState.tokens)
.sort(sortMapTokenStates)
.map((tokenState) => (
<MapToken
key={tokenState.id}
token={tokensById[tokenState.tokenId]}
tokenState={tokenState}
tokenSizePercent={tokenSizePercent}
onTokenStateChange={onMapTokenStateChange}
onTokenMenuOpen={handleTokenMenuOpen}
onTokenDragStart={(e) =>
setDraggingTokenOptions({
dragging: true,
tokenState,
tokenGroup: e.target,
})
}
onTokenDragEnd={() =>
setDraggingTokenOptions({
...draggingTokenOptions,
dragging: false,
})
}
draggable={
selectedToolId === "pan" && !(tokenState.id in disabledTokens)
}
mapState={mapState}
/>
))}
</Group>
);
const tokenMenu = (
<TokenMenu
@ -246,7 +261,8 @@ function Map({
}}
onTokenStateChange={onMapTokenStateChange}
tokenState={draggingTokenOptions && draggingTokenOptions.tokenState}
tokenImage={draggingTokenOptions && draggingTokenOptions.tokenImage}
tokenGroup={draggingTokenOptions && draggingTokenOptions.tokenGroup}
dragging={draggingTokenOptions && draggingTokenOptions.dragging}
token={tokensById[draggingTokenOptions.tokenState.tokenId]}
mapState={mapState}
/>

View File

@ -67,6 +67,7 @@ function MapContols({
onMapStateChange={onMapStateChange}
currentMap={currentMap}
currentMapState={currentMapState}
disabled={disabledControls.includes("map")}
/>
),
},

View File

@ -8,8 +8,7 @@ import useDataSource from "../../helpers/useDataSource";
import { mapSources as defaultMapSources } from "../../maps";
import { getStrokeWidth } from "../../helpers/drawing";
const lightnessDetectionOffset = 0.1;
import { getImageLightness } from "../../helpers/image";
function MapGrid({ map, gridSize }) {
const mapSource = useDataSource(map, defaultMapSources);
@ -28,38 +27,7 @@ function MapGrid({ map, gridSize }) {
// When the map changes find the average lightness of its pixels
useEffect(() => {
if (mapLoadingStatus === "loaded") {
const imageWidth = mapImage.width;
const imageHeight = mapImage.height;
let canvas = document.createElement("canvas");
canvas.width = imageWidth;
canvas.height = imageHeight;
let context = canvas.getContext("2d");
context.drawImage(mapImage, 0, 0);
const imageData = context.getImageData(0, 0, imageWidth, imageHeight);
const data = imageData.data;
let lightPixels = 0;
let darkPixels = 0;
// Loop over every pixels rgba values
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const max = Math.max(Math.max(r, g), b);
if (max < 128) {
darkPixels++;
} else {
lightPixels++;
}
}
const norm = (lightPixels - darkPixels) / (imageWidth * imageHeight);
if (norm + lightnessDetectionOffset < 0) {
setIsImageLight(false);
} else {
setIsImageLight(true);
}
setIsImageLight(getImageLightness(mapImage));
}
}, [mapImage, mapLoadingStatus]);

View File

@ -30,8 +30,38 @@ function MapInteraction({
onSelectedToolChange,
disabledControls,
}) {
const mapSource = useDataSource(map, defaultMapSources);
const [mapSourceImage] = useImage(mapSource);
let mapSourceMap = map;
if (map && map.type === "file") {
if (
map.resolutions &&
map.quality !== "original" &&
map.resolutions[map.quality]
) {
mapSourceMap = map.resolutions[map.quality];
}
}
const mapSource = useDataSource(mapSourceMap, defaultMapSources);
const [mapSourceImage, mapSourceImageStatus] = useImage(mapSource);
// Create a map source that only updates when the image is fully loaded
const [loadedMapSourceImage, setLoadedMapSourceImage] = useState();
useEffect(() => {
if (mapSourceImageStatus === "loaded") {
setLoadedMapSourceImage(mapSourceImage);
}
}, [mapSourceImage, mapSourceImageStatus]);
// Map loaded taking in to account different resolutions
const [mapLoaded, setMapLoaded] = useState(false);
useEffect(() => {
if (map === null) {
setMapLoaded(false);
}
if (mapSourceImageStatus === "loaded") {
setMapLoaded(true);
}
}, [mapSourceImageStatus, map]);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
@ -46,7 +76,7 @@ function MapInteraction({
// Reset transform when map changes
useEffect(() => {
const layer = mapLayerRef.current;
if (map && layer) {
if (map && layer && !mapLoaded) {
const mapHeight = stageWidthRef.current * (map.height / map.width);
const newTranslate = {
x: 0,
@ -59,7 +89,7 @@ function MapInteraction({
setStageScale(1);
}
}, [map]);
}, [map, mapLoaded]);
const pinchPreviousDistanceRef = useRef();
const pinchPreviousOriginRef = useRef();
@ -167,45 +197,85 @@ function MapInteraction({
stageHeightRef.current = height;
}
function handleKeyDown(event) {
// Change to pan tool when pressing space
if (event.key === " " && selectedToolId === "pan") {
// Stop active state on pan icon from being selected
event.preventDefault();
}
if (
event.key === " " &&
selectedToolId !== "pan" &&
!disabledControls.includes("pan")
) {
event.preventDefault();
previousSelectedToolRef.current = selectedToolId;
onSelectedToolChange("pan");
// Added key events to interaction emitter
useEffect(() => {
function handleKeyDown(event) {
// Ignore text input
if (event.target instanceof HTMLInputElement) {
return;
}
interactionEmitter.emit("keyDown", event);
}
// Basic keyboard shortcuts
if (event.key === "w" && !disabledControls.includes("pan")) {
onSelectedToolChange("pan");
}
if (event.key === "d" && !disabledControls.includes("drawing")) {
onSelectedToolChange("drawing");
}
if (event.key === "f" && !disabledControls.includes("fog")) {
onSelectedToolChange("fog");
}
if (event.key === "m" && !disabledControls.includes("measure")) {
onSelectedToolChange("measure");
function handleKeyUp(event) {
// Ignore text input
if (event.target instanceof HTMLInputElement) {
return;
}
interactionEmitter.emit("keyUp", event);
}
interactionEmitter.emit("keyDown", event);
}
document.body.addEventListener("keydown", handleKeyDown);
document.body.addEventListener("keyup", handleKeyUp);
document.body.tabIndex = 1;
return () => {
document.body.removeEventListener("keydown", handleKeyDown);
document.body.removeEventListener("keyup", handleKeyUp);
document.body.tabIndex = 0;
};
}, [interactionEmitter]);
function handleKeyUp(event) {
if (event.key === " " && selectedToolId === "pan") {
onSelectedToolChange(previousSelectedToolRef.current);
// Create default keyboard shortcuts
useEffect(() => {
function handleKeyDown(event) {
// Change to pan tool when pressing space
if (event.key === " " && selectedToolId === "pan") {
// Stop active state on pan icon from being selected
event.preventDefault();
}
if (
event.key === " " &&
selectedToolId !== "pan" &&
!disabledControls.includes("pan")
) {
event.preventDefault();
previousSelectedToolRef.current = selectedToolId;
onSelectedToolChange("pan");
}
// Basic keyboard shortcuts
if (event.key === "w" && !disabledControls.includes("pan")) {
onSelectedToolChange("pan");
}
if (event.key === "d" && !disabledControls.includes("drawing")) {
onSelectedToolChange("drawing");
}
if (event.key === "f" && !disabledControls.includes("fog")) {
onSelectedToolChange("fog");
}
if (event.key === "m" && !disabledControls.includes("measure")) {
onSelectedToolChange("measure");
}
}
interactionEmitter.emit("keyUp", event);
}
function handleKeyUp(event) {
if (event.key === " " && selectedToolId === "pan") {
onSelectedToolChange(previousSelectedToolRef.current);
}
}
interactionEmitter.on("keyDown", handleKeyDown);
interactionEmitter.on("keyUp", handleKeyUp);
return () => {
interactionEmitter.off("keyDown", handleKeyDown);
interactionEmitter.off("keyUp", handleKeyUp);
};
}, [
interactionEmitter,
onSelectedToolChange,
disabledControls,
selectedToolId,
]);
function getCursorForTool(tool) {
switch (tool) {
@ -254,9 +324,6 @@ function MapInteraction({
ref={containerRef}
{...bind()}
className="map"
tabIndex={1}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
>
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
<Stage
@ -270,7 +337,7 @@ function MapInteraction({
>
<Layer ref={mapLayerRef}>
<Image
image={mapSourceImage}
image={mapLoaded && loadedMapSourceImage}
width={mapWidth}
height={mapHeight}
id="mapImage"
@ -280,7 +347,7 @@ function MapInteraction({
<AuthContext.Provider value={auth}>
<MapInteractionProvider value={mapInteraction}>
<MapStageProvider value={mapStageRef}>
{children}
{mapLoaded && children}
</MapStageProvider>
</MapInteractionProvider>
</AuthContext.Provider>

View File

@ -1,7 +1,6 @@
import React, { useContext, useEffect, useRef } from "react";
import { Box, Progress } from "theme-ui";
import Spinner from "../Spinner";
import MapLoadingContext from "../../contexts/MapLoadingContext";
function MapLoadingOverlay() {
@ -36,18 +35,16 @@ function MapLoadingOverlay() {
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
top: 0,
left: 0,
left: "8px",
bottom: "8px",
flexDirection: "column",
borderRadius: "28px",
}}
bg="muted"
bg="overlay"
>
<Spinner />
<Progress
ref={progressBarRef}
max={1}

View File

@ -79,13 +79,7 @@ function MapMenu({
}}
contentRef={handleModalContent}
>
{/* Stop keyboard events when modal is open to prevent shortcuts from triggering */}
<div
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
>
{children}
</div>
{children}
</Modal>
);
}

View File

@ -15,6 +15,14 @@ import { isEmpty } from "../../helpers/shared";
import Divider from "../Divider";
const qualitySettings = [
{ id: "low", name: "Low" },
{ id: "medium", name: "Medium" },
{ id: "high", name: "High" },
{ id: "ultra", name: "Ultra High" },
{ id: "original", name: "Original" },
];
function MapSettings({
map,
mapState,
@ -34,6 +42,17 @@ function MapSettings({
}
}
function getMapSize() {
let size = 0;
if (map.quality === "original") {
size = map.file.length;
} else {
size = map.resolutions[map.quality].file.length;
}
size /= 1000000; // Bytes to Megabytes
return `${size.toFixed(2)}MB`;
}
const mapEmpty = !map || isEmpty(map);
const mapStateEmpty = !mapState || isEmpty(mapState);
@ -81,7 +100,11 @@ function MapSettings({
my={1}
/>
</Box>
<Flex my={2} sx={{ alignItems: "center" }}>
<Flex
mt={2}
mb={map.type === "default" ? 2 : 0}
sx={{ alignItems: "center" }}
>
<Box sx={{ width: "50%" }}>
<Label>Grid Type</Label>
<Select
@ -102,6 +125,35 @@ function MapSettings({
Show Grid
</Label>
</Flex>
{!mapEmpty && map.type !== "default" && (
<Flex my={2} sx={{ alignItems: "center" }}>
<Box sx={{ width: "50%" }}>
<Label>Quality</Label>
<Select
my={1}
value={!mapEmpty && map.quality}
disabled={mapEmpty}
onChange={(e) => onSettingsChange("quality", e.target.value)}
>
{qualitySettings.map((quality) => (
<option
key={quality.id}
value={quality.id}
disabled={
quality.id !== "original" &&
!map.resolutions[quality.id]
}
>
{quality.name}
</option>
))}
</Select>
</Box>
<Label sx={{ width: "50%" }} ml={2}>
Size: {getMapSize()}
</Label>
</Flex>
)}
<Divider fill />
<Box my={2} sx={{ flexGrow: 1 }}>
<Label>Allow Others to Edit</Label>

View File

@ -17,9 +17,18 @@ function MapTile({
onMapReset,
onDone,
}) {
const mapSource = useDataSource(map, defaultMapSources, unknownSource);
const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false);
const isDefault = map.type === "default";
const mapSource = useDataSource(
isDefault
? map
: map.resolutions && map.resolutions.low
? map.resolutions.low
: map,
defaultMapSources,
unknownSource
);
const hasMapState =
mapState &&
(Object.values(mapState.tokens).length > 0 ||

View File

@ -45,15 +45,15 @@ function MapToken({
}, [tokenSourceImage]);
function handleDragStart(event) {
const tokenImage = event.target;
const tokenImageRect = tokenImage.getClientRect();
const tokenGroup = event.target;
const tokenImage = imageRef.current;
if (token && token.isVehicle) {
// Find all other tokens on the map
const layer = tokenImage.getLayer();
const layer = tokenGroup.getLayer();
const tokens = layer.find(".token");
for (let other of tokens) {
if (other === tokenImage) {
if (other === tokenGroup) {
continue;
}
const otherRect = other.getClientRect();
@ -61,16 +61,10 @@ function MapToken({
x: otherRect.x + otherRect.width / 2,
y: otherRect.y + otherRect.height / 2,
};
// Check the other tokens center overlaps this tokens bounding box
if (
otherCenter.x > tokenImageRect.x &&
otherCenter.x < tokenImageRect.x + tokenImageRect.width &&
otherCenter.y > tokenImageRect.y &&
otherCenter.y < tokenImageRect.y + tokenImageRect.height
) {
if (tokenImage.intersects(otherCenter)) {
// Save and restore token position after moving layer
const position = other.absolutePosition();
other.moveTo(tokenImage);
other.moveTo(tokenGroup);
other.absolutePosition(position);
}
}
@ -80,16 +74,16 @@ function MapToken({
}
function handleDragEnd(event) {
const tokenImage = event.target;
const tokenGroup = event.target;
const mountChanges = {};
if (token && token.isVehicle) {
const layer = tokenImage.getLayer();
const mountedTokens = tokenImage.find(".token");
const parent = tokenGroup.getParent();
const mountedTokens = tokenGroup.find(".token");
for (let mountedToken of mountedTokens) {
// Save and restore token position after moving layer
const position = mountedToken.absolutePosition();
mountedToken.moveTo(layer);
mountedToken.moveTo(parent);
mountedToken.absolutePosition(position);
mountChanges[mountedToken.id()] = {
...mapState.tokens[mountedToken.id()],
@ -105,8 +99,8 @@ function MapToken({
...mountChanges,
[tokenState.id]: {
...tokenState,
x: tokenImage.x() / mapWidth,
y: tokenImage.y() / mapHeight,
x: tokenGroup.x() / mapWidth,
y: tokenGroup.y() / mapHeight,
lastEditedBy: userId,
},
});

View File

@ -11,6 +11,7 @@ function SelectMapButton({
onMapStateChange,
currentMap,
currentMapState,
disabled,
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
@ -30,6 +31,7 @@ function SelectMapButton({
aria-label="Select Map"
title="Select Map"
onClick={openModal}
disabled={disabled}
>
<SelectMapIcon />
</IconButton>

View File

@ -13,8 +13,9 @@ function ToolSection({ collapse, tools, onToolClick }) {
if (selectedTool) {
setCollapsedTool(selectedTool);
} else {
setCollapsedTool(
(prevTool) => prevTool && { ...prevTool, isSelected: false }
// No selected tool, deselect if we have a tool or get the first tool if not
setCollapsedTool((prevTool) =>
prevTool ? { ...prevTool, isSelected: false } : tools[0]
);
}
}, [tools]);

View File

@ -1,21 +1,23 @@
import React from "react";
import { Text } from "theme-ui";
import { Text, Flex } from "theme-ui";
import Stream from "./Stream";
function Nickname({ nickname, stream }) {
return (
<Text
as="p"
my={1}
variant="body2"
sx={{
position: "relative",
}}
>
{nickname}
<Flex sx={{ flexDirection: "column" }}>
<Text
as="p"
my={1}
variant="body2"
sx={{
position: "relative",
}}
>
{nickname}
</Text>
{stream && <Stream stream={stream} nickname={nickname} />}
</Text>
</Flex>
);
}

View File

@ -1,14 +1,17 @@
import React, { useState, useRef, useEffect } from "react";
import { Text, IconButton, Box } from "theme-ui";
import { Text, IconButton, Box, Slider, Flex } from "theme-ui";
import StreamMuteIcon from "../../icons/StreamMuteIcon";
import Banner from "../Banner";
function Stream({ stream, nickname }) {
const [streamMuted, setStreamMuted] = useState(false);
const [streamVolume, setStreamVolume] = useState(1);
const [showStreamInteractBanner, setShowStreamInteractBanner] = useState(
false
);
const audioRef = useRef();
const streamMuted = streamVolume === 0;
useEffect(() => {
if (audioRef.current) {
@ -21,7 +24,7 @@ function Stream({ stream, nickname }) {
})
.catch(() => {
// Unable to autoplay
setStreamMuted(true);
setStreamVolume(0);
setShowStreamInteractBanner(true);
});
}
@ -31,49 +34,85 @@ function Stream({ stream, nickname }) {
if (audioRef.current) {
if (streamMuted) {
audioRef.current.play().then(() => {
setStreamMuted(false);
setStreamVolume(1);
setShowStreamInteractBanner(false);
});
} else {
setStreamMuted(true);
setStreamVolume(0);
}
}
}
function handleVolumeChange(event) {
const volume = parseFloat(event.target.value);
setStreamVolume(volume);
}
// Use an audio context gain node to control volume to go past 100%
const audioGainRef = useRef();
useEffect(() => {
if (stream) {
let audioContext = new AudioContext();
let source = audioContext.createMediaStreamSource(stream);
let gainNode = audioContext.createGain();
gainNode.gain.value = 0;
source.connect(gainNode);
gainNode.connect(audioContext.destination);
audioGainRef.current = gainNode;
}
}, [stream]);
// Platforms like iOS don't allow you to control audio volume
// Detect this by trying to change the audio volume
const [isVolumeControlAvailable, setIsVolumeControlAvailable] = useState(
true
);
useEffect(() => {
if (audioRef.current) {
const prevVolume = audioRef.current.volume;
audioRef.current.volume = 0.5;
setIsVolumeControlAvailable(audioRef.current.volume !== 0.5);
audioRef.current.volume = prevVolume;
}
}, [stream]);
useEffect(() => {
if (audioGainRef.current && audioRef.current) {
if (streamVolume <= 1) {
audioGainRef.current.gain.value = 0;
audioRef.current.volume = streamVolume;
} else {
audioRef.current.volume = 1;
audioGainRef.current.gain.value = (streamVolume - 1) * 2;
}
}
}, [streamVolume]);
return (
<>
<IconButton
sx={{
width: "14px",
height: "14px",
padding: 0,
marginLeft: "2px",
position: "absolute",
bottom: "-2px",
}}
aria-label={streamMuted ? "Unmute Player" : "Mute Player"}
title={streamMuted ? "Unmute Player" : "Mute Player"}
onClick={() => {
if (stream) {
toggleMute();
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 0 24 24"
width="14"
fill="currentcolor"
<Flex sx={{ alignItems: "center", height: "24px" }}>
<IconButton
aria-label={streamMuted ? "Unmute Player" : "Mute Player"}
title={streamMuted ? "Unmute Player" : "Mute Player"}
onClick={() => {
if (stream) {
toggleMute();
}
}}
>
{streamMuted ? (
<path d="M3.63 3.63c-.39.39-.39 1.02 0 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91-.36.15-.58.53-.58.92 0 .72.73 1.18 1.39.91.8-.33 1.55-.77 2.22-1.31l1.34 1.34c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0zM19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87 0-3.83-2.4-7.11-5.78-8.4-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12zm-8.71-6.29l-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7zM16.5 12c0-1.77-1.02-3.29-2.5-4.03v1.79l2.48 2.48c.01-.08.02-.16.02-.24z" />
) : (
<path d="M3 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V6.41c0-.89-1.08-1.34-1.71-.71L7 9H4c-.55 0-1 .45-1 1zm13.5 2c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 4.45v.2c0 .38.25.71.6.85C17.18 6.53 19 9.06 19 12s-1.82 5.47-4.4 6.5c-.36.14-.6.47-.6.85v.2c0 .63.63 1.07 1.21.85C18.6 19.11 21 15.84 21 12s-2.4-7.11-5.79-8.4c-.58-.23-1.21.22-1.21.85z" />
)}
</svg>
</IconButton>
{stream && <audio ref={audioRef} playsInline muted={streamMuted} />}
<StreamMuteIcon muted={streamMuted} />
</IconButton>
{isVolumeControlAvailable && (
<Slider
value={streamVolume}
min={0}
max={2}
step={0.1}
onChange={handleVolumeChange}
/>
)}
{stream && <audio ref={audioRef} playsInline muted={streamMuted} />}
</Flex>
<Banner
isOpen={showStreamInteractBanner}
onRequestClose={() => setShowStreamInteractBanner(false)}

View File

@ -11,7 +11,8 @@ function TokenDragOverlay({
onTokenStateChange,
token,
tokenState,
tokenImage,
tokenGroup,
dragging,
mapState,
}) {
const { userId } = useContext(AuthContext);
@ -28,7 +29,7 @@ function TokenDragOverlay({
const mapRect = map.getBoundingClientRect();
function detectRemoveHover() {
const pointerPosition = tokenImage.getStage().getPointerPosition();
const pointerPosition = tokenGroup.getStage().getPointerPosition();
const screenSpacePointerPosition = {
x: pointerPosition.x + mapRect.left,
y: pointerPosition.y + mapRect.top,
@ -50,7 +51,7 @@ function TokenDragOverlay({
}
let handler;
if (tokenState && tokenImage) {
if (tokenState && tokenGroup && dragging) {
handler = setInterval(detectRemoveHover, 100);
}
@ -59,74 +60,62 @@ function TokenDragOverlay({
clearInterval(handler);
}
};
}, [tokenState, tokenImage, isRemoveHovered]);
}, [tokenState, tokenGroup, isRemoveHovered, dragging]);
// Detect drag end of token image and remove it if it is over the remove icon
useEffect(() => {
function handleTokenDragEnd() {
if (isRemoveHovered) {
// Handle other tokens when a vehicle gets deleted
if (token && token.isVehicle) {
const layer = tokenImage.getLayer();
const mountedTokens = tokenImage.find(".token");
for (let mountedToken of mountedTokens) {
// Save and restore token position after moving layer
const position = mountedToken.absolutePosition();
mountedToken.moveTo(layer);
mountedToken.absolutePosition(position);
onTokenStateChange({
[mountedToken.id()]: {
...mapState.tokens[mountedToken.id()],
x: mountedToken.x() / mapWidth,
y: mountedToken.y() / mapHeight,
lastEditedBy: userId,
},
});
}
// Handle other tokens when a vehicle gets deleted
if (token && token.isVehicle) {
const layer = tokenGroup.getLayer();
const mountedTokens = tokenGroup.find(".token");
for (let mountedToken of mountedTokens) {
// Save and restore token position after moving layer
const position = mountedToken.absolutePosition();
mountedToken.moveTo(layer);
mountedToken.absolutePosition(position);
onTokenStateChange({
[mountedToken.id()]: {
...mapState.tokens[mountedToken.id()],
x: mountedToken.x() / mapWidth,
y: mountedToken.y() / mapHeight,
lastEditedBy: userId,
},
});
}
onTokenStateRemove(tokenState);
setPreventMapInteraction(false);
}
onTokenStateRemove(tokenState);
setPreventMapInteraction(false);
}
tokenImage.on("dragend", handleTokenDragEnd);
return () => {
tokenImage.off("dragend", handleTokenDragEnd);
};
}, [
tokenImage,
token,
tokenState,
isRemoveHovered,
mapWidth,
mapHeight,
userId,
onTokenStateChange,
onTokenStateRemove,
setPreventMapInteraction,
mapState.tokens,
]);
if (!dragging && tokenState && isRemoveHovered) {
handleTokenDragEnd();
}
});
return (
<Box
sx={{
position: "absolute",
bottom: "32px",
left: "50%",
borderRadius: "50%",
transform: isRemoveHovered
? "translateX(-50%) scale(2.0)"
: "translateX(-50%) scale(1.5)",
transition: "transform 250ms ease",
color: isRemoveHovered ? "primary" : "text",
pointerEvents: "none",
}}
bg="overlay"
ref={removeTokenRef}
>
<IconButton>
<RemoveTokenIcon />
</IconButton>
</Box>
dragging && (
<Box
sx={{
position: "absolute",
bottom: "32px",
left: "50%",
borderRadius: "50%",
transform: isRemoveHovered
? "translateX(-50%) scale(2.0)"
: "translateX(-50%) scale(1.5)",
transition: "transform 250ms ease",
color: isRemoveHovered ? "primary" : "text",
pointerEvents: "none",
}}
bg="overlay"
ref={removeTokenRef}
>
<IconButton>
<RemoveTokenIcon />
</IconButton>
</Box>
)
);
}

View File

@ -1,10 +1,15 @@
import React, { useRef, useEffect, useState } from "react";
import { Rect, Text, Group } from "react-konva";
const maxTokenSize = 3;
function TokenLabel({ tokenState, width, height }) {
const fontSize = height / 6 / tokenState.size;
const paddingY = height / 16 / tokenState.size;
const paddingX = height / 8 / tokenState.size;
const fontSize =
(height / 6 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
const paddingY =
(height / 16 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
const paddingX =
(height / 8 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
const [rectWidth, setRectWidth] = useState(0);
useEffect(() => {

View File

@ -105,13 +105,12 @@ export function MapDataProvider({ children }) {
}
async function updateMap(id, update) {
const change = { ...update, lastModified: Date.now() };
await database.table("maps").update(id, change);
await database.table("maps").update(id, update);
setMaps((prevMaps) => {
const newMaps = [...prevMaps];
const i = newMaps.findIndex((map) => map.id === id);
if (i > -1) {
newMaps[i] = { ...newMaps[i], ...change };
newMaps[i] = { ...newMaps[i], ...update };
}
return newMaps;
});

View File

@ -117,6 +117,18 @@ function loadVersions(db) {
}
});
});
// v1.4.2 - Added map resolutions
db.version(6)
.stores({})
.upgrade((tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.resolutions = {};
map.quality = "original";
});
});
}
// Get the dexie database used in DatabaseContext

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View File

@ -24,7 +24,7 @@ To do this there is the Column and Row properties. Columns represents how many g
`Tip: Owlbear Rodeo can automatically fill the Column and Row properties for you if you include them in the file name of the uploaded map. E.g. River [10x15] will create a map named River with 10 columns and 15 rows`
`Note: When uploading a custom map keep the file size in mind. Maps are shared between users in Owlbear Rodeo so if a map is taking too long to load for other party members consider lowering the file size.`
`Note: When uploading a custom map keep the file size in mind. Maps are shared between users in Owlbear Rodeo so if a map is taking too long to load for other party members consider changing the Quality option in the advanced map settings.`
## Custom Maps (Advanced)
@ -38,6 +38,7 @@ A brief summary of these settings is listed below.
- Name: The name of the map shown in the Map Select Screen.
- Grid Type: Change the type of grid to use for the map. Currently only the Square type is supported however Hex will be added in a future release.
- Show Grid: When enabled Owlbear Rodeo will draw a grid on top of your map, this is useful if a custom map you have uploaded does't include a grid.
- Quality: When uploading a map Owlbear Rodeo will automatically generate various quality options, selecting a lower quality may help speed up map sending in resource constrained environments.
- Allow others to edit: These properties control what other party members can edit when viewing your map.
- Fog: Controls whether others can edit the maps fog (default disabled).
- Drawings: Controls whether others can add drawings to the map (default enabled).

View File

@ -0,0 +1,25 @@
## Minor Changes
This release we're hoping to help with map load times to do this we added 2 new features:
1. Map and token sending now supports multiplexing which means you can now use the app while these elements load. This means that the old map and token loading screen that took up the whole map is now pushed to the bottom left of the map and doesn't prevent interaction.
2. For any new map uploaded multiple resolutions are automatically generated. This helps in two ways. First, before sending the full quality map to each player a preview of the map is sent. This should hopefully lower the time in which players are waiting to see any part of the map. Second, in the advanced map settings you can now select from one of these generated resolutions with the Quality option. This should allow you to easily change the quality of the map if sending is taking too long.
A lot of the underlying network code was changed to support this so there may still be a few bugs that we haven't come across yet, so if there are any issues let us know.
Along with this there are a few bug fixes and small enhancements as well:
- Fixed a bug that caused the drawing tools to dissapear on smaller screens when changing tools.
- Fixed keyboard shortcuts not working when interacting with elements other than the map.
- Added a volume slider for audio sharing on platforms that support controlling audio.
- Added a better function for determining which tokens are sitting on a token with the Vehicle / Mount option set.
- Fixed a bug when double clicking a map to select it while also pressing the remove map button.
- Fixed a bug where tokens couldn't get deleted when the cursor was on top of a fog shape.
- Changed token labels to scale with the token. This is limited so that the label doesn't get too large on huge creatures.
[Reddit]()
[Twitter]()
---
July 20 2020

132
src/helpers/Connection.js Normal file
View File

@ -0,0 +1,132 @@
import SimplePeer from "simple-peer";
import { encode, decode } from "@msgpack/msgpack";
import shortid from "shortid";
import blobToBuffer from "./blobToBuffer";
// Limit buffer size to 16kb to avoid issues with chrome packet size
// http://viblast.com/blog/2015/2/5/webrtc-data-channel-message-size/
const MAX_BUFFER_SIZE = 16000;
class Connection extends SimplePeer {
constructor(props) {
super(props);
this.currentChunks = {};
this.dataChannels = {};
this.on("data", this.handleData);
this.on("datachannel", this.handleDataChannel);
}
// Intercept the data event with decoding and chunking support
handleData(packed) {
const unpacked = decode(packed);
// If the special property __chunked is set and true
// The data is a partial chunk of the a larger file
// So wait until all chunks are collected and assembled
// before emitting the dataComplete event
if (unpacked.__chunked) {
let chunk = this.currentChunks[unpacked.id] || {
data: [],
count: 0,
total: unpacked.total,
};
chunk.data[unpacked.index] = unpacked.data;
chunk.count++;
this.currentChunks[unpacked.id] = chunk;
this.emit("dataProgress", {
id: unpacked.id,
count: chunk.count,
total: chunk.total,
});
// All chunks have been loaded
if (chunk.count === chunk.total) {
// Merge chunks with a blob
// TODO: Look at a more efficient way to recombine buffer data
const merged = new Blob(chunk.data);
blobToBuffer(merged).then((buffer) => {
this.emit("dataComplete", decode(buffer));
delete this.currentChunks[unpacked.id];
});
}
} else {
this.emit("dataComplete", unpacked);
}
}
// Override the send function with encoding, chunking and data channel support
send(data, channel) {
try {
const packedData = encode(data);
if (packedData.byteLength > MAX_BUFFER_SIZE) {
const chunks = this.chunk(packedData);
for (let chunk of chunks) {
if (this.dataChannels[channel]) {
this.dataChannels[channel].send(encode(chunk));
} else {
super.send(encode(chunk));
}
}
return;
} else {
if (this.dataChannels[channel]) {
this.dataChannels[channel].send(packedData);
} else {
super.send(packedData);
}
}
} catch (error) {
console.error(error);
}
}
// Override the create data channel function to store our own named reference to it
// and to use our custom data handler
createDataChannel(channelName, channelConfig, opts) {
const channel = super.createDataChannel(channelName, channelConfig, opts);
this.handleDataChannel(channel);
return channel;
}
handleDataChannel(channel) {
const channelName = channel.channelName;
this.dataChannels[channelName] = channel;
channel.on("data", this.handleData.bind(this));
channel.on("error", (error) => {
this.emit("error", error);
});
}
// Converted from https://github.com/peers/peerjs/
chunk(data) {
const chunks = [];
const size = data.byteLength;
const total = Math.ceil(size / MAX_BUFFER_SIZE);
const id = shortid.generate();
let index = 0;
let start = 0;
while (start < size) {
const end = Math.min(size, start + MAX_BUFFER_SIZE);
const slice = data.slice(start, end);
const chunk = {
__chunked: true,
data: slice,
id,
index,
total,
};
chunks.push(chunk);
start = end;
index++;
}
return chunks;
}
}
export default Connection;

View File

@ -1,102 +0,0 @@
import SimplePeer from "simple-peer";
import { encode, decode } from "@msgpack/msgpack";
import shortid from "shortid";
import blobToBuffer from "./blobToBuffer";
// Limit buffer size to 16kb to avoid issues with chrome packet size
// http://viblast.com/blog/2015/2/5/webrtc-data-channel-message-size/
const MAX_BUFFER_SIZE = 16000;
class Peer extends SimplePeer {
constructor(props) {
super(props);
this.currentChunks = {};
this.on("data", (packed) => {
const unpacked = decode(packed);
// If the special property __chunked is set and true
// The data is a partial chunk of the a larger file
// So wait until all chunks are collected and assembled
// before emitting the dataComplete event
if (unpacked.__chunked) {
let chunk = this.currentChunks[unpacked.id] || {
data: [],
count: 0,
total: unpacked.total,
};
chunk.data[unpacked.index] = unpacked.data;
chunk.count++;
this.currentChunks[unpacked.id] = chunk;
this.emit("dataProgress", {
id: unpacked.id,
count: chunk.count,
total: chunk.total,
});
// All chunks have been loaded
if (chunk.count === chunk.total) {
// Merge chunks with a blob
// TODO: Look at a more efficient way to recombine buffer data
const merged = new Blob(chunk.data);
blobToBuffer(merged).then((buffer) => {
this.emit("dataComplete", decode(buffer));
delete this.currentChunks[unpacked.id];
});
}
} else {
this.emit("dataComplete", unpacked);
}
});
}
send(data) {
try {
const packedData = encode(data);
if (packedData.byteLength > MAX_BUFFER_SIZE) {
const chunks = this.chunk(packedData);
for (let chunk of chunks) {
super.send(encode(chunk));
}
return;
} else {
super.send(packedData);
}
} catch (error) {
console.error(error);
}
}
// Converted from https://github.com/peers/peerjs/
chunk(data) {
const chunks = [];
const size = data.byteLength;
const total = Math.ceil(size / MAX_BUFFER_SIZE);
const id = shortid.generate();
let index = 0;
let start = 0;
while (start < size) {
const end = Math.min(size, start + MAX_BUFFER_SIZE);
const slice = data.slice(start, end);
const chunk = {
__chunked: true,
data: slice,
id,
index,
total,
};
chunks.push(chunk);
start = end;
index++;
}
return chunks;
}
}
export default Peer;

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

@ -0,0 +1,245 @@
import io from "socket.io-client";
import { EventEmitter } from "events";
import Connection from "./Connection";
import { omit } from "./shared";
/**
* @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) {
if (typeof partyId !== "string" || typeof password !== "string") {
console.error(
"Unable to join party: invalid party ID or password",
partyId,
password
);
this.emit("disconnected");
return;
}
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, channel) {
peer.connection.send({ id, data }, channel);
}
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() {
this.emit("disconnect", { peer });
peer.connection.destroy();
this.peers = omit(this.peers, [peer.id]);
}
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;

68
src/helpers/image.js Normal file
View File

@ -0,0 +1,68 @@
const lightnessDetectionOffset = 0.1;
/**
* @param {HTMLImageElement} image
* @returns {boolean} True is the image is light
*/
export function getImageLightness(image) {
const width = image.width;
const height = image.height;
let canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
let context = canvas.getContext("2d");
context.drawImage(image, 0, 0);
const imageData = context.getImageData(0, 0, width, height);
const data = imageData.data;
let lightPixels = 0;
let darkPixels = 0;
// Loop over every pixels rgba values
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const max = Math.max(Math.max(r, g), b);
if (max < 128) {
darkPixels++;
} else {
lightPixels++;
}
}
const norm = (lightPixels - darkPixels) / (width * height);
return norm + lightnessDetectionOffset >= 0;
}
/**
* @param {HTMLImageElement} image the image to resize
* @param {number} size the size of the longest edge of the new image
* @param {string} type the mime type of the image
* @param {number} quality if image is a jpeg or webp this is the quality setting
*/
export async function resizeImage(image, size, type, quality) {
const width = image.width;
const height = image.height;
const ratio = width / height;
let canvas = document.createElement("canvas");
if (ratio > 1) {
canvas.width = size;
canvas.height = Math.round(size / ratio);
} else {
canvas.width = Math.round(size * ratio);
canvas.height = size;
}
let context = canvas.getContext("2d");
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return new Promise((resolve) => {
canvas.toBlob(
(blob) => {
resolve({ blob, width: canvas.width, height: canvas.height });
},
type,
quality
);
});
}

View File

@ -1,235 +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 },
});
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,21 @@
import React from "react";
function StreamMuteIcon({ muted }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
{muted ? (
<path d="M3.63 3.63c-.39.39-.39 1.02 0 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91-.36.15-.58.53-.58.92 0 .72.73 1.18 1.39.91.8-.33 1.55-.77 2.22-1.31l1.34 1.34c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0zM19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87 0-3.83-2.4-7.11-5.78-8.4-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12zm-8.71-6.29l-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7zM16.5 12c0-1.77-1.02-3.29-2.5-4.03v1.79l2.48 2.48c.01-.08.02-.16.02-.24z" />
) : (
<path d="M3 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V6.41c0-.89-1.08-1.34-1.71-.71L7 9H4c-.55 0-1 .45-1 1zm13.5 2c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 4.45v.2c0 .38.25.71.6.85C17.18 6.53 19 9.06 19 12s-1.82 5.47-4.4 6.5c-.36.14-.6.47-.6.85v.2c0 .63.63 1.07 1.21.85C18.6 19.11 21 15.84 21 12s-2.4-7.11-5.79-8.4c-.58-.23-1.21.22-1.21.85z" />
)}
</svg>
);
}
export default StreamMuteIcon;

View File

@ -6,6 +6,7 @@ import Modal from "../components/Modal";
import MapTiles from "../components/map/MapTiles";
import MapSettings from "../components/map/MapSettings";
import ImageDrop from "../components/ImageDrop";
import LoadingOverlay from "../components/LoadingOverlay";
import blobToBuffer from "../helpers/blobToBuffer";
@ -13,6 +14,7 @@ import MapDataContext from "../contexts/MapDataContext";
import AuthContext from "../contexts/AuthContext";
import { isEmpty } from "../helpers/shared";
import { resizeImage } from "../helpers/image";
const defaultMapSize = 22;
const defaultMapProps = {
@ -20,8 +22,16 @@ const defaultMapProps = {
// TODO: add support for hex horizontal and hex vertical
gridType: "grid",
showGrid: false,
quality: "original",
};
const mapResolutions = [
{ size: 512, quality: 0.5, id: "low" },
{ size: 1024, quality: 0.6, id: "medium" },
{ size: 2048, quality: 0.7, id: "high" },
{ size: 4096, quality: 0.8, id: "ultra" },
];
function SelectMapModal({
isOpen,
onDone,
@ -103,10 +113,32 @@ function SelectMapModal({
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
image.onload = function () {
image.onload = async function () {
// Create resolutions
const resolutions = {};
for (let resolution of mapResolutions) {
if (Math.max(image.width, image.height) > resolution.size) {
const resized = await resizeImage(
image,
resolution.size,
file.type,
resolution.quality
);
const resizedBuffer = await blobToBuffer(resized.blob);
resolutions[resolution.id] = {
file: resizedBuffer,
width: resized.width,
height: resized.height,
type: "file",
id: resolution.id,
};
}
}
handleMapAdd({
// Save as a buffer to send with msgpack
file: buffer,
resolutions,
name,
type: "file",
gridX: fileGridX,
@ -164,6 +196,9 @@ function SelectMapModal({
}
async function handleDone() {
if (imageLoading) {
return;
}
if (selectedMapId) {
await applyMapChanges();
onMapChange(selectedMapWithChanges, selectedMapStateWithChanges);
@ -181,7 +216,11 @@ function SelectMapModal({
const [mapStateSettingChanges, setMapStateSettingChanges] = useState({});
function handleMapSettingsChange(key, value) {
setMapSettingChanges((prevChanges) => ({ ...prevChanges, [key]: value }));
setMapSettingChanges((prevChanges) => ({
...prevChanges,
[key]: value,
lastModified: Date.now(),
}));
}
function handleMapStateSettingsChange(key, value) {
@ -256,6 +295,7 @@ function SelectMapModal({
</Button>
</Flex>
</ImageDrop>
{imageLoading && <LoadingOverlay bg="overlay" />}
</Modal>
);
}

View File

@ -0,0 +1,436 @@
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,
isLoading,
} = useContext(MapLoadingContext);
const { putToken, getToken } = useContext(TokenDataContext);
const { putMap, getMap } = 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");
if (!newMap || !newMapState) {
return;
}
session.send("mapState", newMapState);
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;
}
}
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.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);
}
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, "token");
}
}
if (currentMap) {
reply("map", getMapDataToSend(currentMap), "map");
}
}
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, preview) {
reply(
"mapResponse",
{
...map,
file,
resolutions: {},
// If preview don't send the last modified so that it will not be cached
lastModified: preview ? 0 : map.lastModified,
},
"map"
);
}
switch (map.quality) {
case "low":
replyWithFile(map.resolutions.low.file, false);
break;
case "medium":
replyWithFile(map.resolutions.low.file, true);
replyWithFile(map.resolutions.medium.file, false);
break;
case "high":
replyWithFile(map.resolutions.medium.file, true);
replyWithFile(map.resolutions.high.file, false);
break;
case "ultra":
replyWithFile(map.resolutions.medium.file, true);
replyWithFile(map.resolutions.ultra.file, false);
break;
case "original":
if (map.resolutions.medium) {
replyWithFile(map.resolutions.medium.file, true);
} else if (map.resolutions.low) {
replyWithFile(map.resolutions.low.file, true);
}
replyWithFile(map.file, false);
break;
default:
replyWithFile(map.file, false);
}
}
if (id === "mapResponse") {
await putMap(data);
setCurrentMap(data);
}
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, "token");
}
}
}
if (id === "tokenRequest") {
const token = getToken(data);
reply("tokenResponse", token, "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 canChangeMap = !isLoading;
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}
allowMapChange={canChangeMap}
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,488 +9,81 @@ 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";
const session = new 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 { peers, socket, connected } = useSession(
gameId,
handlePeerConnected,
handlePeerDisconnected,
handlePeerData,
handlePeerDataProgress,
handlePeerTrackAdded,
handlePeerTrackRemoved,
handlePeerError
);
const { putToken, getToken } = useContext(TokenDataContext);
const { putMap, getMap } = 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 });
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, ...rest } = mapData;
peer.connection.send({ id: "map", data: rest });
} else {
peer.connection.send({ id: "map", data: mapData });
}
}
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 {
peer.connection.send({ id: "mapRequest", data: newMap.id });
}
} else {
setCurrentMap(newMap);
}
}
// Send full map data including file
if (data.id === "mapRequest") {
const map = getMap(data.data);
peer.connection.send({ id: "mapResponse", data: map });
}
// A new map response with a file attached
if (data.id === "mapResponse") {
if (data.data && data.data.type === "file") {
const newMap = { ...data.data, file: data.data.file };
putMap(newMap).then(() => {
setCurrentMap(newMap);
});
} else {
setCurrentMap(data.data);
}
}
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]);
// 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);
};
}, []);
// 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);
};
}, []);
// Join game
useEffect(() => {
session.joinParty(gameId, password);
}, [gameId, password]);
// A ref to the Konva stage
// the ref will be assigned in the MapInteraction component
@ -519,34 +99,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)}>

View File

@ -55,7 +55,7 @@ function Home() {
Join Game
</Button>
<Text variant="caption" as="p" sx={{ textAlign: "center" }}>
Beta v1.4.1
Beta v1.4.2
</Text>
<Button
m={2}

View File

@ -15,6 +15,7 @@ const v132 = raw("../docs/releaseNotes/v1.3.2.md");
const v133 = raw("../docs/releaseNotes/v1.3.3.md");
const v140 = raw("../docs/releaseNotes/v1.4.0.md");
const v141 = raw("../docs/releaseNotes/v1.4.1.md");
const v142 = raw("../docs/releaseNotes/v1.4.2.md");
function ReleaseNotes() {
return (
@ -38,6 +39,11 @@ function ReleaseNotes() {
<Text mb={2} variant="heading" as="h1" sx={{ fontSize: 5 }}>
Release Notes
</Text>
<div id="v141">
<Accordion heading="v1.4.2" defaultOpen>
<Markdown source={v142} />
</Accordion>
</div>
<div id="v141">
<Accordion heading="v1.4.1" defaultOpen>
<Markdown source={v141} />

View File

@ -10213,10 +10213,9 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
simple-peer@^9.6.2:
version "9.6.2"
resolved "https://registry.yarnpkg.com/simple-peer/-/simple-peer-9.6.2.tgz#42418e77cf8f9184e4fa22ef1017b195c2bf84d7"
integrity sha512-EOKoImCaqtNvXIntxT1CBBK/3pVi7tMAoJ3shdyd9qk3zLm3QPiRLb/sPC1G2xvKJkJc5fkQjCXqRZ0AknwTig==
simple-peer@feross/simple-peer#694/head:
version "9.7.2"
resolved "https://codeload.github.com/feross/simple-peer/tar.gz/b8a4ec0210547414c52857343f10089e80710f03"
dependencies:
debug "^4.0.1"
get-browser-rtc "^1.0.0"