Merge pull request #17 from mitchemmc/release/v1.5.0

Release/v1.5.0 2
This commit is contained in:
Mitchell McCaffrey 2020-08-12 12:51:49 +10:00 committed by GitHub
commit 78b8748386
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 4020 additions and 2198 deletions

3
.env
View File

@ -1,2 +1,3 @@
REACT_APP_BROKER_URL=http://localhost:9000
REACT_APP_ICE_SERVERS_URL=http://localhost:9000/iceservers
REACT_APP_ICE_SERVERS_URL=http://localhost:9000/iceservers
REACT_APP_VERSION=$npm_package_version

View File

@ -1,2 +1,3 @@
REACT_APP_BROKER_URL=https://broker.owlbear.app
REACT_APP_ICE_SERVERS_URL=https://broker.owlbear.app/iceservers
REACT_APP_ICE_SERVERS_URL=https://broker.owlbear.app/iceservers
REACT_APP_VERSION=$npm_package_version

View File

@ -1,21 +1,23 @@
{
"name": "owlbear-rodeo",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"dependencies": {
"@babylonjs/core": "^4.1.0",
"@babylonjs/loaders": "^4.1.0",
"@msgpack/msgpack": "^1.12.1",
"@stripe/stripe-js": "^1.3.2",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"ammo.js": "kripken/ammo.js#aab297a4164779c3a9d8dc8d9da26958de3cb778",
"babylonjs": "^4.1.0",
"babylonjs-loaders": "^4.1.0",
"case": "^1.6.3",
"dexie": "^2.0.4",
"fake-indexeddb": "^3.0.0",
"interactjs": "^1.9.7",
"konva": "^6.0.0",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"polygon-clipping": "^0.14.3",
"raw.macro": "^0.3.0",
"react": "^16.13.0",
@ -35,11 +37,13 @@
"simplebar-react": "^2.1.0",
"simplify-js": "^1.2.4",
"socket.io-client": "^2.3.0",
"source-map-explorer": "^2.4.2",
"theme-ui": "^0.3.1",
"use-image": "^1.0.5",
"webrtc-adapter": "^7.5.1"
},
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",

View File

@ -15,41 +15,44 @@ import { DatabaseProvider } from "./contexts/DatabaseContext";
import { MapDataProvider } from "./contexts/MapDataContext";
import { TokenDataProvider } from "./contexts/TokenDataContext";
import { MapLoadingProvider } from "./contexts/MapLoadingContext";
import { SettingsProvider } from "./contexts/SettingsContext.js";
function App() {
return (
<ThemeProvider theme={theme}>
<DatabaseProvider>
<AuthProvider>
<Router>
<Switch>
<Route path="/howTo">
<HowTo />
</Route>
<Route path="/releaseNotes">
<ReleaseNotes />
</Route>
<Route path="/about">
<About />
</Route>
<Route path="/faq">
<FAQ />
</Route>
<Route path="/game/:id">
<MapLoadingProvider>
<MapDataProvider>
<TokenDataProvider>
<Game />
</TokenDataProvider>
</MapDataProvider>
</MapLoadingProvider>
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</Router>
</AuthProvider>
<SettingsProvider>
<AuthProvider>
<Router>
<Switch>
<Route path="/howTo">
<HowTo />
</Route>
<Route path="/releaseNotes">
<ReleaseNotes />
</Route>
<Route path="/about">
<About />
</Route>
<Route path="/faq">
<FAQ />
</Route>
<Route path="/game/:id">
<MapLoadingProvider>
<MapDataProvider>
<TokenDataProvider>
<Game />
</TokenDataProvider>
</MapDataProvider>
</MapLoadingProvider>
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</Router>
</AuthProvider>
</SettingsProvider>
</DatabaseProvider>
</ThemeProvider>
);

View File

@ -16,7 +16,7 @@ function StyledModal({
isOpen={isOpen}
onRequestClose={onRequestClose}
style={{
overlay: { backgroundColor: "rgba(0, 0, 0, 0.73)" },
overlay: { backgroundColor: "rgba(0, 0, 0, 0.73)", zIndex: 100 },
content: {
backgroundColor: theme.colors.background,
top: "50%",

View File

@ -3,14 +3,14 @@ import { IconButton } from "theme-ui";
import Count from "./DiceButtonCount";
function DiceButton({ title, children, count, onClick }) {
function DiceButton({ title, children, count, onClick, disabled }) {
return (
<IconButton
title={title}
aria-label={title}
onClick={onClick}
color="hsl(210, 50%, 96%)"
sx={{ position: "relative" }}
disabled={disabled}
>
{children}
{count && <Count>{count}</Count>}

View File

@ -6,9 +6,9 @@ function DiceButtonCount({ children }) {
<Box
sx={{
position: "absolute",
left: "50%",
bottom: "100%",
transform: "translateX(-50%)",
top: "50%",
right: "90%",
transform: "translateY(-50%)",
height: "14px",
display: "flex",
alignItems: "center",
@ -20,7 +20,7 @@ function DiceButtonCount({ children }) {
color="text"
sx={{ fontSize: "10px", fontWeight: "bold" }}
>
{children}×
{children}
</Text>
</Box>
);

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
import { Flex, IconButton } from "theme-ui";
import { Flex, IconButton, Box } from "theme-ui";
import SimpleBar from "simplebar-react";
import D20Icon from "../../icons/D20Icon";
import D12Icon from "../../icons/D12Icon";
@ -9,6 +10,8 @@ import D6Icon from "../../icons/D6Icon";
import D4Icon from "../../icons/D4Icon";
import D100Icon from "../../icons/D100Icon";
import ExpandMoreDiceTrayIcon from "../../icons/ExpandMoreDiceTrayIcon";
import ShareDiceOnIcon from "../../icons/ShareDiceOnIcon";
import ShareDiceOffIcon from "../../icons/ShareDiceOffIcon";
import DiceButton from "./DiceButton";
import SelectDiceButton from "./SelectDiceButton";
@ -16,6 +19,7 @@ import SelectDiceButton from "./SelectDiceButton";
import Divider from "../Divider";
import { dice } from "../../dice";
import useSetting from "../../helpers/useSetting";
function DiceButtons({
diceRolls,
@ -23,11 +27,17 @@ function DiceButtons({
onDiceLoad,
diceTraySize,
onDiceTraySizeChange,
shareDice,
onShareDiceChange,
loading,
}) {
const [currentDice, setCurrentDice] = useState(dice[0]);
const [currentDiceStyle, setCurrentDiceStyle] = useSetting("dice.style");
const [currentDice, setCurrentDice] = useState(
dice.find((d) => d.key === currentDiceStyle)
);
useEffect(() => {
const initialDice = dice[0];
const initialDice = dice.find((d) => d.key === currentDiceStyle);
onDiceLoad(initialDice);
setCurrentDice(initialDice);
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -45,89 +55,129 @@ function DiceButtons({
async function handleDiceChange(dice) {
await onDiceLoad(dice);
setCurrentDice(dice);
setCurrentDiceStyle(dice.key);
}
let buttons = [
{
key: "d20",
title: "Add D20",
count: diceCounts.d20,
onClick: () => onDiceAdd(currentDice.class, "d20"),
children: <D20Icon />,
},
{
key: "d12",
title: "Add D12",
count: diceCounts.d12,
onClick: () => onDiceAdd(currentDice.class, "d12"),
children: <D12Icon />,
},
{
key: "d10",
title: "Add D10",
count: diceCounts.d10,
onClick: () => onDiceAdd(currentDice.class, "d10"),
children: <D10Icon />,
},
{
key: "d8",
title: "Add D8",
count: diceCounts.d8,
onClick: () => onDiceAdd(currentDice.class, "d8"),
children: <D8Icon />,
},
{
key: "d6",
title: "Add D6",
count: diceCounts.d6,
onClick: () => onDiceAdd(currentDice.class, "d6"),
children: <D6Icon />,
},
{
key: "d4",
title: "Add D4",
count: diceCounts.d4,
onClick: () => onDiceAdd(currentDice.class, "d4"),
children: <D4Icon />,
},
{
key: "d100",
title: "Add D100",
count: diceCounts.d100,
onClick: () => onDiceAdd(currentDice.class, "d100"),
children: <D100Icon />,
},
];
return (
<Flex
<Box
sx={{
justifyContent: "center",
width: "100%",
alignItems: "center",
padding: "0 15px",
borderRadius: "4px",
position: "absolute",
top: "0",
left: "0",
height: "100%",
maxHeight: "390px",
pointerEvents: "all",
}}
bg="overlay"
>
<SelectDiceButton
onDiceChange={handleDiceChange}
currentDice={currentDice}
/>
<Divider vertical color="hsl(210, 50%, 96%)" />
<DiceButton
title="Add D20"
count={diceCounts.d20}
onClick={() => onDiceAdd(currentDice.class, "d20")}
>
<D20Icon />
</DiceButton>
<DiceButton
title="Add D12"
count={diceCounts.d12}
onClick={() => onDiceAdd(currentDice.class, "d12")}
>
<D12Icon />
</DiceButton>
<DiceButton
title="Add D10"
count={diceCounts.d10}
onClick={() => onDiceAdd(currentDice.class, "d10")}
>
<D10Icon />
</DiceButton>
<DiceButton
title="Add D8"
count={diceCounts.d8}
onClick={() => onDiceAdd(currentDice.class, "d8")}
>
<D8Icon />
</DiceButton>
<DiceButton
title="Add D6"
count={diceCounts.d6}
onClick={() => onDiceAdd(currentDice.class, "d6")}
>
<D6Icon />
</DiceButton>
<DiceButton
title="Add D4"
count={diceCounts.d4}
onClick={() => onDiceAdd(currentDice.class, "d4")}
>
<D4Icon />
</DiceButton>
<DiceButton
title="Add D100"
count={diceCounts.d100}
onClick={() => onDiceAdd(currentDice.class, "d100")}
>
<D100Icon />
</DiceButton>
<Divider vertical color="hsl(210, 50%, 96%)" />
<IconButton
aria-label={
diceTraySize === "single" ? "Expand Dice Tray" : "Shrink Dice Tray"
}
title={
diceTraySize === "single" ? "Expand Dice Tray" : "Shrink Dice Tray"
}
sx={{
transform: diceTraySize === "single" ? "rotate(0)" : "rotate(180deg)",
}}
onClick={() =>
onDiceTraySizeChange(diceTraySize === "single" ? "double" : "single")
}
>
<ExpandMoreDiceTrayIcon />
</IconButton>
</Flex>
<SimpleBar style={{ width: "48px", height: "100%" }}>
<Flex
sx={{
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
}}
p={2}
>
<SelectDiceButton
onDiceChange={handleDiceChange}
currentDice={currentDice}
disabled={loading}
/>
<Divider />
{buttons.map((button) => (
<DiceButton disabled={loading} {...button} />
))}
<Divider />
<IconButton
aria-label={
diceTraySize === "single"
? "Expand Dice Tray"
: "Shrink Dice Tray"
}
title={
diceTraySize === "single"
? "Expand Dice Tray"
: "Shrink Dice Tray"
}
sx={{
transform:
diceTraySize === "single" ? "rotate(0)" : "rotate(180deg)",
}}
onClick={() =>
onDiceTraySizeChange(
diceTraySize === "single" ? "double" : "single"
)
}
disabled={loading}
>
<ExpandMoreDiceTrayIcon />
</IconButton>
<Divider />
<IconButton
aria-label={shareDice ? "Hide Dice Rolls" : "Share Dice Rolls"}
title={shareDice ? "Hide Dice Rolls" : "Share Dice Rolls"}
onClick={() => onShareDiceChange(!shareDice)}
disabled={loading}
>
{shareDice ? <ShareDiceOnIcon /> : <ShareDiceOffIcon />}
</IconButton>
</Flex>
</SimpleBar>
</Box>
);
}

View File

@ -1,130 +0,0 @@
import React, { useEffect, useState } from "react";
import * as BABYLON from "babylonjs";
import DiceButtons from "./DiceButtons";
import DiceResults from "./DiceResults";
function DiceControls({
diceRefs,
sceneVisibleRef,
onDiceAdd,
onDiceClear,
onDiceReroll,
onDiceLoad,
diceTraySize,
onDiceTraySizeChange,
}) {
const [diceRolls, setDiceRolls] = useState([]);
// Update dice rolls
useEffect(() => {
// Find the number facing up on a dice object
function getDiceRoll(dice) {
let number = getDiceInstanceRoll(dice.instance);
// If the dice is a d100 add the d10
if (dice.type === "d100") {
const d10Number = getDiceInstanceRoll(dice.d10Instance);
// Both zero set to 100
if (d10Number === 0 && number === 0) {
number = 100;
} else {
number += d10Number;
}
} else if (dice.type === "d10" && number === 0) {
number = 10;
}
return { type: dice.type, roll: number };
}
// Find the number facing up on a mesh instance of a dice
function getDiceInstanceRoll(instance) {
let highestDot = -1;
let highestLocator;
for (let locator of instance.getChildTransformNodes()) {
let dif = locator
.getAbsolutePosition()
.subtract(instance.getAbsolutePosition());
let direction = dif.normalize();
const dot = BABYLON.Vector3.Dot(direction, BABYLON.Vector3.Up());
if (dot > highestDot) {
highestDot = dot;
highestLocator = locator;
}
}
return parseInt(highestLocator.name.slice(12));
}
function updateDiceRolls() {
const die = diceRefs.current;
const sceneVisible = sceneVisibleRef.current;
if (!sceneVisible) {
return;
}
const diceAwake = die.map((dice) => dice.asleep).includes(false);
if (!diceAwake) {
return;
}
let newRolls = [];
for (let i = 0; i < die.length; i++) {
const dice = die[i];
let roll = getDiceRoll(dice);
newRolls[i] = roll;
}
setDiceRolls(newRolls);
}
const updateInterval = setInterval(updateDiceRolls, 100);
return () => {
clearInterval(updateInterval);
};
}, [diceRefs, sceneVisibleRef]);
return (
<>
<div
style={{
position: "absolute",
bottom: "16px",
left: 0,
right: 0,
display: "flex",
color: "white",
pointerEvents: "none",
}}
>
<DiceResults
diceRolls={diceRolls}
onDiceClear={() => {
onDiceClear();
setDiceRolls([]);
}}
onDiceReroll={onDiceReroll}
/>
</div>
<div
style={{
position: "absolute",
top: "24px",
width: "100%",
}}
>
<DiceButtons
diceRolls={diceRolls}
onDiceAdd={(style, type) => {
onDiceAdd(style, type);
setDiceRolls((prevRolls) => [
...prevRolls,
{ type, roll: "unknown" },
]);
}}
onDiceLoad={onDiceLoad}
onDiceTraySizeChange={onDiceTraySizeChange}
diceTraySize={diceTraySize}
/>
</div>
</>
);
}
export default DiceControls;

View File

@ -1,7 +1,19 @@
import React, { useRef, useEffect } from "react";
import * as BABYLON from "babylonjs";
import { Engine } from "@babylonjs/core/Engines/engine";
import { Scene } from "@babylonjs/core/scene";
import { Vector3, Color4, Matrix } from "@babylonjs/core/Maths/math";
import { AmmoJSPlugin } from "@babylonjs/core/Physics/Plugins/ammoJSPlugin";
import { TargetCamera } from "@babylonjs/core/Cameras/targetCamera";
import * as AMMO from "ammo.js";
import "babylonjs-loaders";
import "@babylonjs/core/Physics/physicsEngineComponent";
import "@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent";
import "@babylonjs/core/Materials/Textures/Loaders/ddsTextureLoader";
import "@babylonjs/core/Meshes/Builders/boxBuilder";
import "@babylonjs/core/Actions/actionManager";
import "@babylonjs/core/Culling/ray";
import "@babylonjs/loaders/glTF";
import ReactResizeDetector from "react-resize-detector";
import usePreventTouch from "../../helpers/usePreventTouch";
@ -16,25 +28,18 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
useEffect(() => {
const canvas = canvasRef.current;
const engine = new BABYLON.Engine(canvas, true, {
const engine = new Engine(canvas, true, {
preserveDrawingBuffer: true,
stencil: true,
});
const scene = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color4(0, 0, 0, 0);
const scene = new Scene(engine);
scene.clearColor = new Color4(0, 0, 0, 0);
// Enable physics
scene.enablePhysics(
new BABYLON.Vector3(0, -98, 0),
new BABYLON.AmmoJSPlugin(true, AMMO)
);
scene.enablePhysics(new Vector3(0, -98, 0), new AmmoJSPlugin(true, AMMO));
let camera = new BABYLON.TargetCamera(
"camera",
new BABYLON.Vector3(0, 33.5, 0),
scene
);
let camera = new TargetCamera("camera", new Vector3(0, 33.5, 0), scene);
camera.fov = 0.65;
camera.setTarget(BABYLON.Vector3.Zero());
camera.setTarget(Vector3.Zero());
onSceneMount && onSceneMount({ scene, engine, canvas });
@ -48,7 +53,7 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
const ray = scene.createPickingRay(
scene.pointerX,
scene.pointerY,
BABYLON.Matrix.Identity(),
Matrix.Identity(),
camera
);
const currentPosition = selectedMesh.getAbsolutePosition();
@ -72,17 +77,19 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
const selectedMeshRef = useRef();
const selectedMeshVelocityWindowRef = useRef([]);
const selectedMeshVelocityWindowSize = 4;
const selectedMeshMassRef = useRef();
function handlePointerDown() {
const scene = sceneRef.current;
if (scene) {
const pickInfo = scene.pick(scene.pointerX, scene.pointerY);
if (pickInfo.hit && pickInfo.pickedMesh.name !== "dice_tray") {
pickInfo.pickedMesh.physicsImpostor.setLinearVelocity(
BABYLON.Vector3.Zero()
);
pickInfo.pickedMesh.physicsImpostor.setAngularVelocity(
BABYLON.Vector3.Zero()
);
pickInfo.pickedMesh.physicsImpostor.setLinearVelocity(Vector3.Zero());
pickInfo.pickedMesh.physicsImpostor.setAngularVelocity(Vector3.Zero());
// Save the meshes mass and set it to 0 so we can pick it up
selectedMeshMassRef.current = pickInfo.pickedMesh.physicsImpostor.mass;
pickInfo.pickedMesh.physicsImpostor.setMass(0);
selectedMeshRef.current = pickInfo.pickedMesh;
}
}
@ -95,7 +102,7 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
const scene = sceneRef.current;
if (selectedMesh && scene) {
// Average velocity window
let velocity = BABYLON.Vector3.Zero();
let velocity = Vector3.Zero();
for (let v of velocityWindow) {
velocity.addInPlace(v);
}
@ -103,6 +110,10 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
velocity.scaleInPlace(1 / velocityWindow.length);
}
// Re-apply the meshes mass
selectedMesh.physicsImpostor.setMass(selectedMeshMassRef.current);
selectedMesh.physicsImpostor.forceUpdate();
selectedMesh.physicsImpostor.applyImpulse(
velocity.scale(diceThrowSpeed * selectedMesh.physicsImpostor.mass),
selectedMesh.physicsImpostor.getObjectCenter()
@ -110,6 +121,7 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
}
selectedMeshRef.current = null;
selectedMeshVelocityWindowRef.current = [];
selectedMeshMassRef.current = null;
onPointerUp();
}

View File

@ -4,42 +4,49 @@ import { Flex, Text, Button, IconButton } from "theme-ui";
import ClearDiceIcon from "../../icons/ClearDiceIcon";
import RerollDiceIcon from "../../icons/RerollDiceIcon";
import { getDiceRollTotal } from "../../helpers/dice";
const maxDiceRollsShown = 6;
function DiceResults({ diceRolls, onDiceClear, onDiceReroll }) {
const [isExpanded, setIsExpanded] = useState(false);
if (
diceRolls.map((dice) => dice.roll).includes("unknown") ||
diceRolls.length === 0
) {
if (diceRolls.length === 0) {
return null;
}
let rolls = [];
if (diceRolls.length > 1) {
rolls = diceRolls.map((dice, index) => (
<React.Fragment key={index}>
<Text variant="body2" as="p" color="hsl(210, 50%, 96%)">
{dice.roll}
</Text>
<Text variant="body2" as="p" color="grey">
{index === diceRolls.length - 1 ? "" : "+"}
</Text>
</React.Fragment>
));
rolls = diceRolls
.filter((dice) => dice.roll !== "unknown")
.map((dice, index) => (
<React.Fragment key={index}>
<Text variant="body2" as="p" color="hsl(210, 50%, 96%)">
{dice.roll}
</Text>
<Text variant="body2" as="p" color="grey">
{index === diceRolls.length - 1 ? "" : "+"}
</Text>
</React.Fragment>
));
}
return (
<Flex
sx={{
width: "100%",
justifyContent: "space-between",
alignItems: "center",
position: "absolute",
bottom: "5%",
left: 0,
right: 0,
display: "flex",
color: "white",
pointerEvents: "none",
}}
>
<IconButton
ml="24px"
ml="7%"
title="Clear Dice"
aria-label="Clear Dice"
onClick={onDiceClear}
@ -58,9 +65,8 @@ function DiceResults({ diceRolls, onDiceClear, onDiceReroll }) {
variant="heading"
as="h1"
sx={{ fontSize: 5, userSelect: "none" }}
mb={diceRolls.length === 1 ? "24px" : 0}
>
{diceRolls.reduce((accumulator, dice) => accumulator + dice.roll, 0)}
{getDiceRollTotal(diceRolls)}
</Text>
{rolls.length > maxDiceRollsShown ? (
<Button
@ -78,11 +84,11 @@ function DiceResults({ diceRolls, onDiceClear, onDiceReroll }) {
)}
</Button>
) : (
<Flex>{rolls}</Flex>
<Flex sx={{ height: "15px" }}>{rolls}</Flex>
)}
</Flex>
<IconButton
mr="24px"
mr="7%"
title="Reroll Dice"
aria-label="Reroll Dice"
onClick={onDiceReroll}

View File

@ -5,21 +5,33 @@ import React, {
useContext,
useState,
} from "react";
import * as BABYLON from "babylonjs";
import { Vector3 } from "@babylonjs/core/Maths/math";
import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight";
import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator";
import { CubeTexture } from "@babylonjs/core/Materials/Textures/cubeTexture";
import { Box } from "theme-ui";
import environment from "../../dice/environment.dds";
import DiceInteraction from "./DiceInteraction";
import DiceControls from "./DiceControls";
import Dice from "../../dice/Dice";
import LoadingOverlay from "../LoadingOverlay";
import DiceButtons from "./DiceButtons";
import DiceResults from "./DiceResults";
import DiceTray from "../../dice/diceTray/DiceTray";
import DiceLoadingContext from "../../contexts/DiceLoadingContext";
function DiceTrayOverlay({ isOpen }) {
import { getDiceRoll } from "../../helpers/dice";
function DiceTrayOverlay({
isOpen,
shareDice,
onShareDiceChage,
diceRolls,
onDiceRollsChange,
}) {
const sceneRef = useRef();
const shadowGeneratorRef = useRef();
const diceRefs = useRef([]);
@ -93,20 +105,20 @@ function DiceTrayOverlay({ isOpen }) {
async function initializeScene(scene) {
handleAssetLoadStart();
let light = new BABYLON.DirectionalLight(
let light = new DirectionalLight(
"DirectionalLight",
new BABYLON.Vector3(-0.5, -1, -0.5),
new Vector3(-0.5, -1, -0.5),
scene
);
light.position = new BABYLON.Vector3(5, 10, 5);
light.position = new Vector3(5, 10, 5);
light.shadowMinZ = 1;
light.shadowMaxZ = 50;
let shadowGenerator = new BABYLON.ShadowGenerator(1024, light);
let shadowGenerator = new ShadowGenerator(1024, light);
shadowGenerator.useCloseExponentialShadowMap = true;
shadowGenerator.darkness = 0.7;
shadowGeneratorRef.current = shadowGenerator;
scene.environmentTexture = BABYLON.CubeTexture.CreateFromPrefilteredData(
scene.environmentTexture = CubeTexture.CreateFromPrefilteredData(
environment,
scene
);
@ -218,43 +230,130 @@ function DiceTrayOverlay({ isOpen }) {
handleAssetLoadFinish();
}
const [traySize, setTraySize] = useState({
width: 0,
height: 0,
});
useEffect(() => {
function handleResize() {
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
const availableWidth = mapRect.width - 108; // Subtract padding
const availableHeight = mapRect.height - 80; // Subtract paddding and open icon
let height = Math.min(availableHeight, 1000);
let width = diceTraySize === "single" ? height / 2 : height;
if (width > availableWidth) {
width = availableWidth;
height = diceTraySize === "single" ? width * 2 : width;
}
setTraySize({ width, height });
}
window.addEventListener("resize", handleResize);
handleResize();
return () => {
window.removeEventListener("resize", handleResize);
};
}, [diceTraySize]);
// Update dice rolls
useEffect(() => {
function updateDiceRolls() {
const die = diceRefs.current;
const sceneVisible = sceneVisibleRef.current;
if (!sceneVisible) {
return;
}
const diceAwake = die.map((dice) => dice.asleep).includes(false);
if (!diceAwake) {
return;
}
let newRolls = [];
for (let i = 0; i < die.length; i++) {
const dice = die[i];
let roll = getDiceRoll(dice);
newRolls[i] = roll;
}
onDiceRollsChange(newRolls);
}
const updateInterval = setInterval(updateDiceRolls, 100);
return () => {
clearInterval(updateInterval);
};
}, [diceRefs, sceneVisibleRef, onDiceRollsChange]);
return (
<Box
sx={{
width: diceTraySize === "single" ? "500px" : "1000px",
maxWidth:
diceTraySize === "single"
? "calc(50vh - 48px)"
: "calc(100vh - 64px)",
paddingBottom: diceTraySize === "single" ? "200%" : "100%",
width: `${traySize.width}px`,
height: `${traySize.height}px`,
borderRadius: "4px",
display: isOpen ? "block" : "none",
position: "relative",
overflow: "hidden",
pointerEvents: "all",
overflow: "visible",
}}
bg="background"
>
<DiceInteraction
onSceneMount={handleSceneMount}
onPointerDown={() => {
sceneInteractionRef.current = true;
<Box
sx={{
transform: "translateX(50px)",
width: "100%",
height: "100%",
pointerEvents: "all",
}}
onPointerUp={() => {
sceneInteractionRef.current = false;
>
<DiceInteraction
onSceneMount={handleSceneMount}
onPointerDown={() => {
sceneInteractionRef.current = true;
}}
onPointerUp={() => {
sceneInteractionRef.current = false;
}}
/>
<DiceResults
diceRolls={diceRolls}
onDiceClear={() => {
handleDiceClear();
onDiceRollsChange([]);
}}
onDiceReroll={handleDiceReroll}
/>
</Box>
<DiceButtons
diceRolls={diceRolls}
onDiceAdd={(style, type) => {
handleDiceAdd(style, type);
onDiceRollsChange([...diceRolls, { type, roll: "unknown" }]);
}}
/>
<DiceControls
diceRefs={diceRefs}
sceneVisibleRef={sceneVisibleRef}
onDiceAdd={handleDiceAdd}
onDiceClear={handleDiceClear}
onDiceReroll={handleDiceReroll}
onDiceLoad={handleDiceLoad}
diceTraySize={diceTraySize}
onDiceTraySizeChange={setDiceTraySize}
diceTraySize={diceTraySize}
shareDice={shareDice}
onShareDiceChange={onShareDiceChage}
loading={isLoading}
/>
{isLoading && <LoadingOverlay />}
{isLoading && (
<Box
sx={{
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: "50px",
}}
>
<LoadingOverlay />
</Box>
)}
</Box>
);
}

View File

@ -4,7 +4,7 @@ import { IconButton } from "theme-ui";
import SelectDiceIcon from "../../icons/SelectDiceIcon";
import SelectDiceModal from "../../modals/SelectDiceModal";
function SelectDiceButton({ onDiceChange, currentDice }) {
function SelectDiceButton({ onDiceChange, currentDice, disabled }) {
const [isModalOpen, setIsModalOpen] = useState(false);
function openModal() {
@ -24,8 +24,8 @@ function SelectDiceButton({ onDiceChange, currentDice }) {
<IconButton
aria-label="Select Dice Style"
title="Select Dice Style"
color="hsl(210, 50%, 96%)"
onClick={openModal}
disabled={disabled}
>
<SelectDiceIcon />
</IconButton>

View File

@ -6,12 +6,13 @@ import MapInteraction from "./MapInteraction";
import MapToken from "./MapToken";
import MapDrawing from "./MapDrawing";
import MapFog from "./MapFog";
import MapDice from "./MapDice";
import MapGrid from "./MapGrid";
import MapMeasure from "./MapMeasure";
import MapLoadingOverlay from "./MapLoadingOverlay";
import NetworkedMapPointer from "../../network/NetworkedMapPointer";
import TokenDataContext from "../../contexts/TokenDataContext";
import SettingsContext from "../../contexts/SettingsContext";
import TokenMenu from "../token/TokenMenu";
import TokenDragOverlay from "../token/TokenDragOverlay";
@ -35,6 +36,7 @@ function Map({
allowFogDrawing,
allowMapChange,
disabledTokens,
session,
}) {
const { tokensById } = useContext(TokenDataContext);
@ -47,20 +49,10 @@ function Map({
const tokenSizePercent = gridSizeNormalized.x;
const [selectedToolId, setSelectedToolId] = useState("pan");
const [toolSettings, setToolSettings] = useState({
fog: { type: "polygon", useEdgeSnapping: false, useFogCut: false },
drawing: {
color: "red",
type: "brush",
useBlending: true,
},
measure: {
type: "chebyshev",
},
});
const { settings, setSettings } = useContext(SettingsContext);
function handleToolSettingChange(tool, change) {
setToolSettings((prevSettings) => ({
setSettings((prevSettings) => ({
...prevSettings,
[tool]: {
...prevSettings[tool],
@ -139,6 +131,7 @@ function Map({
if (!map) {
disabledControls.push("pan");
disabledControls.push("measure");
disabledControls.push("pointer");
}
if (!allowFogDrawing) {
disabledControls.push("fog");
@ -178,7 +171,7 @@ function Map({
currentMapState={mapState}
onSelectedToolChange={setSelectedToolId}
selectedToolId={selectedToolId}
toolSettings={toolSettings}
toolSettings={settings}
onToolSettingChange={handleToolSettingChange}
onToolAction={handleToolAction}
disabledControls={disabledControls}
@ -238,6 +231,8 @@ function Map({
selectedToolId === "pan" && !(tokenState.id in disabledTokens)
}
mapState={mapState}
fadeOnHover={selectedToolId === "drawing"}
map={map}
/>
))}
</Group>
@ -270,25 +265,30 @@ function Map({
const mapDrawing = (
<MapDrawing
map={map}
shapes={mapShapes}
onShapeAdd={handleMapShapeAdd}
onShapesRemove={handleMapShapesRemove}
selectedToolId={selectedToolId}
selectedToolSettings={toolSettings[selectedToolId]}
active={selectedToolId === "drawing"}
toolId="drawing"
toolSettings={settings.drawing}
gridSize={gridSizeNormalized}
/>
);
const mapFog = (
<MapFog
map={map}
shapes={fogShapes}
onShapeAdd={handleFogShapeAdd}
onShapeSubtract={handleFogShapeSubtract}
onShapesRemove={handleFogShapesRemove}
onShapesEdit={handleFogShapesEdit}
selectedToolId={selectedToolId}
selectedToolSettings={toolSettings[selectedToolId]}
active={selectedToolId === "fog"}
toolId="fog"
toolSettings={settings.fog}
gridSize={gridSizeNormalized}
transparent={allowFogDrawing && !settings.fog.preview}
/>
);
@ -298,9 +298,18 @@ function Map({
const mapMeasure = (
<MapMeasure
map={map}
active={selectedToolId === "measure"}
gridSize={gridSizeNormalized}
selectedToolSettings={toolSettings[selectedToolId]}
selectedToolSettings={settings[selectedToolId]}
/>
);
const mapPointer = (
<NetworkedMapPointer
active={selectedToolId === "pointer"}
gridSize={gridSizeNormalized}
session={session}
/>
);
@ -312,7 +321,6 @@ function Map({
{mapControls}
{tokenMenu}
{tokenDragOverlay}
<MapDice />
<MapLoadingOverlay />
</>
}
@ -324,6 +332,7 @@ function Map({
{mapDrawing}
{mapTokens}
{mapFog}
{mapPointer}
{mapMeasure}
</MapInteraction>
);

View File

@ -15,6 +15,7 @@ import FogToolIcon from "../../icons/FogToolIcon";
import BrushToolIcon from "../../icons/BrushToolIcon";
import MeasureToolIcon from "../../icons/MeasureToolIcon";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
import PointerToolIcon from "../../icons/PointerToolIcon";
function MapContols({
onMapChange,
@ -55,8 +56,13 @@ function MapContols({
title: "Measure Tool",
SettingsComponent: MeasureToolSettings,
},
pointer: {
id: "pointer",
icon: <PointerToolIcon />,
title: "Pointer Tool",
},
};
const tools = ["pan", "fog", "drawing", "measure"];
const tools = ["pan", "fog", "drawing", "measure", "pointer"];
const sections = [
{

View File

@ -18,11 +18,13 @@ import { getRelativePointerPositionNormalized } from "../../helpers/konva";
import colors from "../../helpers/colors";
function MapDrawing({
map,
shapes,
onShapeAdd,
onShapesRemove,
selectedToolId,
selectedToolSettings,
active,
toolId,
toolSettings,
gridSize,
}) {
const { stageScale, mapWidth, mapHeight, interactionEmitter } = useContext(
@ -33,22 +35,17 @@ function MapDrawing({
const [isBrushDown, setIsBrushDown] = useState(false);
const [erasingShapes, setErasingShapes] = useState([]);
const shouldHover =
selectedToolSettings && selectedToolSettings.type === "erase";
const isEditing = selectedToolId === "drawing";
const shouldHover = toolSettings.type === "erase";
const isBrush =
selectedToolSettings &&
(selectedToolSettings.type === "brush" ||
selectedToolSettings.type === "paint");
toolSettings.type === "brush" || toolSettings.type === "paint";
const isShape =
selectedToolSettings &&
(selectedToolSettings.type === "line" ||
selectedToolSettings.type === "rectangle" ||
selectedToolSettings.type === "circle" ||
selectedToolSettings.type === "triangle");
toolSettings.type === "line" ||
toolSettings.type === "rectangle" ||
toolSettings.type === "circle" ||
toolSettings.type === "triangle";
useEffect(() => {
if (!isEditing) {
if (!active) {
return;
}
const mapStage = mapStageRef.current;
@ -56,9 +53,10 @@ function MapDrawing({
function getBrushPosition() {
const mapImage = mapStage.findOne("#mapImage");
return getBrushPositionForTool(
map,
getRelativePointerPositionNormalized(mapImage),
selectedToolId,
selectedToolSettings,
toolId,
toolSettings,
gridSize,
shapes
);
@ -67,24 +65,24 @@ function MapDrawing({
function handleBrushDown() {
const brushPosition = getBrushPosition();
const commonShapeData = {
color: selectedToolSettings && selectedToolSettings.color,
blend: selectedToolSettings && selectedToolSettings.useBlending,
color: toolSettings.color,
blend: toolSettings.useBlending,
id: shortid.generate(),
};
if (isBrush) {
setDrawingShape({
type: "path",
pathType: selectedToolSettings.type === "brush" ? "stroke" : "fill",
pathType: toolSettings.type === "brush" ? "stroke" : "fill",
data: { points: [brushPosition] },
strokeWidth: selectedToolSettings.type === "brush" ? 1 : 0,
strokeWidth: toolSettings.type === "brush" ? 1 : 0,
...commonShapeData,
});
} else if (isShape) {
setDrawingShape({
type: "shape",
shapeType: selectedToolSettings.type,
data: getDefaultShapeData(selectedToolSettings.type, brushPosition),
strokeWidth: selectedToolSettings.type === "line" ? 1 : 0,
shapeType: toolSettings.type,
data: getDefaultShapeData(toolSettings.type, brushPosition),
strokeWidth: toolSettings.type === "line" ? 1 : 0,
...commonShapeData,
});
}
@ -157,23 +155,7 @@ function MapDrawing({
interactionEmitter.off("drag", handleBrushMove);
interactionEmitter.off("dragEnd", handleBrushUp);
};
}, [
drawingShape,
erasingShapes,
gridSize,
isBrush,
isBrushDown,
isEditing,
isShape,
mapStageRef,
onShapeAdd,
onShapesRemove,
selectedToolId,
selectedToolSettings,
shapes,
stageScale,
interactionEmitter,
]);
});
function handleShapeOver(shape, isDown) {
if (shouldHover && isDown) {
@ -206,6 +188,7 @@ function MapDrawing({
closed={shape.pathType === "fill"}
fillEnabled={shape.pathType === "fill"}
lineCap="round"
lineJoin="round"
strokeWidth={getStrokeWidth(
shape.strokeWidth,
gridSize,

View File

@ -22,14 +22,17 @@ import {
} from "../../helpers/konva";
function MapFog({
map,
shapes,
onShapeAdd,
onShapeSubtract,
onShapesRemove,
onShapesEdit,
selectedToolId,
selectedToolSettings,
active,
toolId,
toolSettings,
gridSize,
transparent,
}) {
const { stageScale, mapWidth, mapHeight, interactionEmitter } = useContext(
MapInteractionContext
@ -39,16 +42,14 @@ function MapFog({
const [isBrushDown, setIsBrushDown] = useState(false);
const [editingShapes, setEditingShapes] = useState([]);
const isEditing = selectedToolId === "fog";
const shouldHover =
isEditing &&
(selectedToolSettings.type === "toggle" ||
selectedToolSettings.type === "remove");
active &&
(toolSettings.type === "toggle" || toolSettings.type === "remove");
const [patternImage] = useImage(diagonalPattern);
useEffect(() => {
if (!isEditing) {
if (!active) {
return;
}
@ -57,9 +58,10 @@ function MapFog({
function getBrushPosition() {
const mapImage = mapStage.findOne("#mapImage");
return getBrushPositionForTool(
map,
getRelativePointerPositionNormalized(mapImage),
selectedToolId,
selectedToolSettings,
toolId,
toolSettings,
gridSize,
shapes
);
@ -67,7 +69,7 @@ function MapFog({
function handleBrushDown() {
const brushPosition = getBrushPosition();
if (selectedToolSettings.type === "brush") {
if (toolSettings.type === "brush") {
setDrawingShape({
type: "fog",
data: {
@ -75,7 +77,7 @@ function MapFog({
holes: [],
},
strokeWidth: 0.5,
color: selectedToolSettings.useFogSubtract ? "red" : "black",
color: toolSettings.useFogSubtract ? "red" : "black",
blend: false,
id: shortid.generate(),
visible: true,
@ -85,11 +87,7 @@ function MapFog({
}
function handleBrushMove() {
if (
selectedToolSettings.type === "brush" &&
isBrushDown &&
drawingShape
) {
if (toolSettings.type === "brush" && isBrushDown && drawingShape) {
const brushPosition = getBrushPosition();
setDrawingShape((prevShape) => {
const prevPoints = prevShape.data.points;
@ -114,8 +112,8 @@ function MapFog({
}
function handleBrushUp() {
if (selectedToolSettings.type === "brush" && drawingShape) {
const subtract = selectedToolSettings.useFogSubtract;
if (toolSettings.type === "brush" && drawingShape) {
const subtract = toolSettings.useFogSubtract;
if (drawingShape.data.points.length > 1) {
let shapeData = {};
@ -147,9 +145,9 @@ function MapFog({
// Erase
if (editingShapes.length > 0) {
if (selectedToolSettings.type === "remove") {
if (toolSettings.type === "remove") {
onShapesRemove(editingShapes.map((shape) => shape.id));
} else if (selectedToolSettings.type === "toggle") {
} else if (toolSettings.type === "toggle") {
onShapesEdit(
editingShapes.map((shape) => ({
...shape,
@ -164,7 +162,7 @@ function MapFog({
}
function handlePolygonClick() {
if (selectedToolSettings.type === "polygon") {
if (toolSettings.type === "polygon") {
const brushPosition = getBrushPosition();
setDrawingShape((prevDrawingShape) => {
if (prevDrawingShape) {
@ -183,7 +181,7 @@ function MapFog({
holes: [],
},
strokeWidth: 0.5,
color: selectedToolSettings.useFogSubtract ? "red" : "black",
color: toolSettings.useFogSubtract ? "red" : "black",
blend: false,
id: shortid.generate(),
visible: true,
@ -194,7 +192,7 @@ function MapFog({
}
function handlePolygonMove() {
if (selectedToolSettings.type === "polygon" && drawingShape) {
if (toolSettings.type === "polygon" && drawingShape) {
const brushPosition = getBrushPosition();
setDrawingShape((prevShape) => {
if (!prevShape) {
@ -227,26 +225,10 @@ function MapFog({
mapStage.off("mousemove touchmove", handlePolygonMove);
mapStage.off("click tap", handlePolygonClick);
};
}, [
mapStageRef,
isEditing,
drawingShape,
editingShapes,
gridSize,
isBrushDown,
onShapeAdd,
onShapeSubtract,
onShapesEdit,
onShapesRemove,
selectedToolId,
selectedToolSettings,
shapes,
stageScale,
interactionEmitter,
]);
});
const finishDrawingPolygon = useCallback(() => {
const subtract = selectedToolSettings.useFogSubtract;
const subtract = toolSettings.useFogSubtract;
const data = {
...drawingShape.data,
// Remove the last point as it hasn't been placed yet
@ -263,16 +245,12 @@ function MapFog({
}
setDrawingShape(null);
}, [selectedToolSettings, drawingShape, onShapeSubtract, onShapeAdd]);
}, [toolSettings, drawingShape, onShapeSubtract, onShapeAdd]);
// Add keyboard shortcuts
useEffect(() => {
function handleKeyDown({ key }) {
if (
key === "Enter" &&
selectedToolSettings.type === "polygon" &&
drawingShape
) {
if (key === "Enter" && toolSettings.type === "polygon" && drawingShape) {
finishDrawingPolygon();
}
if (key === "Escape" && drawingShape) {
@ -296,7 +274,7 @@ function MapFog({
}
return {
...prevShape,
color: selectedToolSettings.useFogSubtract ? "black" : "red",
color: toolSettings.useFogSubtract ? "black" : "red",
};
});
}
@ -307,12 +285,7 @@ function MapFog({
interactionEmitter.off("keyDown", handleKeyDown);
interactionEmitter.off("keyUp", handleKeyUp);
};
}, [
finishDrawingPolygon,
interactionEmitter,
drawingShape,
selectedToolSettings,
]);
}, [finishDrawingPolygon, interactionEmitter, drawingShape, toolSettings]);
function handleShapeOver(shape, isDown) {
if (shouldHover && isDown) {
@ -343,17 +316,21 @@ function MapFog({
fill={colors[shape.color] || shape.color}
closed
lineCap="round"
lineJoin="round"
strokeWidth={getStrokeWidth(
shape.strokeWidth,
gridSize,
mapWidth,
mapHeight
)}
visible={isEditing || shape.visible}
opacity={isEditing ? 0.5 : 1}
visible={(active && !toolSettings.preview) || shape.visible}
opacity={transparent ? 0.5 : 1}
fillPatternImage={patternImage}
fillPriority={isEditing && !shape.visible ? "pattern" : "color"}
fillPriority={active && !shape.visible ? "pattern" : "color"}
holes={holes}
// Disable collision if the fog is transparent and we're not editing it
// This allows tokens to be moved under the fog
hitFunc={transparent && !active ? () => {} : undefined}
/>
);
}
@ -394,8 +371,8 @@ function MapFog({
{shapes.map(renderShape)}
{drawingShape && renderShape(drawingShape)}
{drawingShape &&
selectedToolSettings &&
selectedToolSettings.type === "polygon" &&
toolSettings &&
toolSettings.type === "polygon" &&
renderPolygonAcceptTick(drawingShape)}
{editingShapes.length > 0 && editingShapes.map(renderEditingShape)}
</Group>

View File

@ -74,9 +74,11 @@ function MapInteraction({
const stageTranslateRef = useRef({ x: 0, y: 0 });
// Reset transform when map changes
const previousMapIdRef = useRef();
useEffect(() => {
const layer = mapLayerRef.current;
if (map && layer && !mapLoaded) {
const previousMapId = previousMapIdRef.current;
if (map && layer && previousMapId !== map.id) {
const mapHeight = stageWidthRef.current * (map.height / map.width);
const newTranslate = {
x: 0,
@ -89,7 +91,8 @@ function MapInteraction({
setStageScale(1);
}
}, [map, mapLoaded]);
previousMapIdRef.current = map && map.id;
}, [map]);
const pinchPreviousDistanceRef = useRef();
const pinchPreviousOriginRef = useRef();
@ -256,6 +259,9 @@ function MapInteraction({
if (event.key === "m" && !disabledControls.includes("measure")) {
onSelectedToolChange("measure");
}
if (event.key === "q" && !disabledControls.includes("pointer")) {
onSelectedToolChange("pointer");
}
}
function handleKeyUp(event) {
@ -284,6 +290,7 @@ function MapInteraction({
case "fog":
case "drawing":
case "measure":
case "pointer":
return "crosshair";
default:
return "default";

View File

@ -13,7 +13,7 @@ import {
import { getRelativePointerPositionNormalized } from "../../helpers/konva";
import * as Vector2 from "../../helpers/vector2";
function MapMeasure({ selectedToolSettings, active, gridSize }) {
function MapMeasure({ map, selectedToolSettings, active, gridSize }) {
const { stageScale, mapWidth, mapHeight, interactionEmitter } = useContext(
MapInteractionContext
);
@ -21,6 +21,12 @@ function MapMeasure({ selectedToolSettings, active, gridSize }) {
const [drawingShapeData, setDrawingShapeData] = useState(null);
const [isBrushDown, setIsBrushDown] = useState(false);
const toolScale =
active && selectedToolSettings.scale.match(/(\d*)([a-zA-Z]*)/);
const toolMultiplier =
active && !isNaN(parseInt(toolScale[1])) ? parseInt(toolScale[1]) : 1;
const toolUnit = active && toolScale[2];
useEffect(() => {
if (!active) {
return;
@ -30,6 +36,7 @@ function MapMeasure({ selectedToolSettings, active, gridSize }) {
function getBrushPosition() {
const mapImage = mapStage.findOne("#mapImage");
return getBrushPositionForTool(
map,
getRelativePointerPositionNormalized(mapImage),
"drawing",
{ type: "line" },
@ -81,15 +88,7 @@ function MapMeasure({ selectedToolSettings, active, gridSize }) {
interactionEmitter.off("drag", handleBrushMove);
interactionEmitter.off("dragEnd", handleBrushUp);
};
}, [
drawingShapeData,
gridSize,
isBrushDown,
mapStageRef,
interactionEmitter,
active,
selectedToolSettings,
]);
});
function renderShape(shapeData) {
const linePoints = shapeData.points.reduce(
@ -126,7 +125,9 @@ function MapMeasure({ selectedToolSettings, active, gridSize }) {
>
<Tag fill="hsla(230, 25%, 18%, 0.8)" cornerRadius={4} />
<Text
text={shapeData.length.toFixed(2)}
text={`${(shapeData.length * toolMultiplier).toFixed(
2
)}${toolUnit}`}
fill="white"
fontSize={24}
padding={4}

View File

@ -0,0 +1,81 @@
import React, { useContext, useEffect } from "react";
import { Group } from "react-konva";
import MapInteractionContext from "../../contexts/MapInteractionContext";
import MapStageContext from "../../contexts/MapStageContext";
import { getStrokeWidth } from "../../helpers/drawing";
import {
getRelativePointerPositionNormalized,
Trail,
} from "../../helpers/konva";
import { multiply } from "../../helpers/vector2";
import colors from "../../helpers/colors";
function MapPointer({
gridSize,
active,
position,
onPointerDown,
onPointerMove,
onPointerUp,
visible,
}) {
const { mapWidth, mapHeight, interactionEmitter } = useContext(
MapInteractionContext
);
const mapStageRef = useContext(MapStageContext);
useEffect(() => {
if (!active) {
return;
}
const mapStage = mapStageRef.current;
function getBrushPosition() {
const mapImage = mapStage.findOne("#mapImage");
return getRelativePointerPositionNormalized(mapImage);
}
function handleBrushDown() {
onPointerDown && onPointerDown(getBrushPosition());
}
function handleBrushMove() {
onPointerMove && onPointerMove(getBrushPosition());
}
function handleBrushUp() {
onPointerMove && onPointerUp(getBrushPosition());
}
interactionEmitter.on("dragStart", handleBrushDown);
interactionEmitter.on("drag", handleBrushMove);
interactionEmitter.on("dragEnd", handleBrushUp);
return () => {
interactionEmitter.off("dragStart", handleBrushDown);
interactionEmitter.off("drag", handleBrushMove);
interactionEmitter.off("dragEnd", handleBrushUp);
};
});
const size = getStrokeWidth(2, gridSize, mapWidth, mapHeight);
return (
<Group>
{visible && (
<Trail
position={multiply(position, { x: mapWidth, y: mapHeight })}
color={colors.red}
size={size}
duration={200}
/>
)}
</Group>
);
}
export default MapPointer;

View File

@ -64,7 +64,7 @@ function MapSettings({
<Input
type="number"
name="gridX"
value={(map && map.gridX) || 0}
value={`${(map && map.gridX) || 0}`}
onChange={(e) =>
onSettingsChange("gridX", parseInt(e.target.value))
}
@ -78,7 +78,7 @@ function MapSettings({
<Input
type="number"
name="gridY"
value={(map && map.gridY) || 0}
value={`${(map && map.gridY) || 0}`}
onChange={(e) =>
onSettingsChange("gridY", parseInt(e.target.value))
}
@ -103,7 +103,7 @@ function MapSettings({
<Flex
mt={2}
mb={map.type === "default" ? 2 : 0}
sx={{ alignItems: "center" }}
sx={{ alignItems: "flex-end" }}
>
<Box sx={{ width: "50%" }}>
<Label>Grid Type</Label>
@ -116,14 +116,28 @@ function MapSettings({
<option disabled>Hex (Coming Soon)</option>
</Select>
</Box>
<Label sx={{ width: "50%" }} ml={2}>
<Checkbox
checked={!mapEmpty && map.showGrid}
disabled={mapEmpty || map.type === "default"}
onChange={(e) => onSettingsChange("showGrid", e.target.checked)}
/>
Show Grid
</Label>
<Flex sx={{ width: "50%", flexDirection: "column" }} ml={2}>
<Label>
<Checkbox
checked={!mapEmpty && map.showGrid}
disabled={mapEmpty || map.type === "default"}
onChange={(e) =>
onSettingsChange("showGrid", e.target.checked)
}
/>
Show Grid
</Label>
<Label>
<Checkbox
checked={!mapEmpty && map.snapToGrid}
disabled={mapEmpty || map.type === "default"}
onChange={(e) =>
onSettingsChange("snapToGrid", e.target.checked)
}
/>
Snap to Grid
</Label>
</Flex>
</Flex>
{!mapEmpty && map.type !== "default" && (
<Flex my={2} sx={{ alignItems: "center" }}>

View File

@ -109,7 +109,8 @@ function MapTile({
}}
m={2}
bg="muted"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
setIsTileMenuOpen(false);
if (!isSelected) {
onMapSelect(map);

View File

@ -31,6 +31,7 @@ function MapTiles({
width: "500px",
borderRadius: "4px",
}}
onClick={() => onMapSelect(null)}
>
<Flex
onClick={onMapAdd}

View File

@ -6,6 +6,7 @@ import useImage from "use-image";
import useDataSource from "../../helpers/useDataSource";
import useDebounce from "../../helpers/useDebounce";
import usePrevious from "../../helpers/usePrevious";
import * as Vector2 from "../../helpers/vector2";
import AuthContext from "../../contexts/AuthContext";
import MapInteractionContext from "../../contexts/MapInteractionContext";
@ -15,6 +16,8 @@ import TokenLabel from "../token/TokenLabel";
import { tokenSources, unknownSource } from "../../tokens";
const snappingThreshold = 1 / 7;
function MapToken({
token,
tokenState,
@ -25,6 +28,8 @@ function MapToken({
onTokenDragEnd,
draggable,
mapState,
fadeOnHover,
map,
}) {
const { userId } = useContext(AuthContext);
const {
@ -73,6 +78,25 @@ function MapToken({
onTokenDragStart(event);
}
function handleDragMove(event) {
const tokenGroup = event.target;
// Snap to corners of grid
if (map.snapToGrid) {
const position = {
x: tokenGroup.x() + tokenGroup.width() / 2,
y: tokenGroup.y() + tokenGroup.height() / 2,
};
const gridSize = { x: mapWidth / map.gridX, y: mapHeight / map.gridY };
const gridSnap = Vector2.roundTo(position, gridSize);
const gridDistance = Vector2.length(Vector2.subtract(gridSnap, position));
const minGrid = Vector2.min(gridSize);
if (gridDistance < minGrid * snappingThreshold) {
tokenGroup.x(gridSnap.x - tokenGroup.width() / 2);
tokenGroup.y(gridSnap.y - tokenGroup.height() / 2);
}
}
}
function handleDragEnd(event) {
const tokenGroup = event.target;
@ -127,13 +151,13 @@ function MapToken({
}
}
function handlePointerOver() {
if (!draggable) {
function handlePointerEnter() {
if (fadeOnHover) {
setTokenOpacity(0.5);
}
}
function handlePointerOut() {
function handlePointerLeave() {
if (tokenOpacity !== 1.0) {
setTokenOpacity(1.0);
}
@ -183,14 +207,15 @@ function MapToken({
draggable={draggable}
onMouseDown={handlePointerDown}
onMouseUp={handlePointerUp}
onMouseOver={handlePointerOver}
onMouseOut={handlePointerOut}
onMouseEnter={handlePointerEnter}
onMouseLeave={handlePointerLeave}
onTouchStart={handlePointerDown}
onTouchEnd={handlePointerUp}
onClick={handleClick}
onTap={handleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
opacity={tokenOpacity}
name={token && token.isVehicle ? "vehicle" : "token"}
id={tokenState.id}

View File

@ -5,6 +5,7 @@ import SelectMapModal from "../../modals/SelectMapModal";
import SelectMapIcon from "../../icons/SelectMapIcon";
import MapDataContext from "../../contexts/MapDataContext";
import AuthContext from "../../contexts/AuthContext";
function SelectMapButton({
onMapChange,
@ -16,8 +17,11 @@ function SelectMapButton({
const [isModalOpen, setIsModalOpen] = useState(false);
const { updateMapState } = useContext(MapDataContext);
const { userId } = useContext(AuthContext);
function openModal() {
currentMapState && updateMapState(currentMapState.mapId, currentMapState);
if (currentMapState && currentMap && currentMap.owner === userId) {
updateMapState(currentMapState.mapId, currentMapState);
}
setIsModalOpen(true);
}

View File

@ -0,0 +1,19 @@
import React from "react";
import { IconButton } from "theme-ui";
import PreviewOnIcon from "../../../icons/FogPreviewOnIcon";
import PreviewOffIcon from "../../../icons/FogPreviewOffIcon";
function FogPreviewToggle({ useFogPreview, onFogPreviewChange }) {
return (
<IconButton
aria-label={useFogPreview ? "Disable Fog Preview" : "Enable Fog Preview"}
title={useFogPreview ? "Disable Fog Preview" : "Enable Fog Preview"}
onClick={() => onFogPreviewChange(!useFogPreview)}
>
{useFogPreview ? <PreviewOnIcon /> : <PreviewOffIcon />}
</IconButton>
);
}
export default FogPreviewToggle;

View File

@ -4,6 +4,7 @@ import { useMedia } from "react-media";
import EdgeSnappingToggle from "./EdgeSnappingToggle";
import RadioIconButton from "./RadioIconButton";
import FogPreviewToggle from "./FogPreviewToggle";
import FogBrushIcon from "../../../icons/FogBrushIcon";
import FogPolygonIcon from "../../../icons/FogPolygonIcon";
@ -43,6 +44,8 @@ function BrushToolSettings({
onSettingChange({ type: "remove" });
} else if (key === "s") {
onSettingChange({ useEdgeSnapping: !settings.useEdgeSnapping });
} else if (key === "f") {
onSettingChange({ preview: !settings.preview });
} else if (
(key === "z" || key === "Z") &&
(ctrlKey || metaKey) &&
@ -142,6 +145,10 @@ function BrushToolSettings({
onSettingChange({ useEdgeSnapping })
}
/>
<FogPreviewToggle
useFogPreview={settings.preview}
onFogPreviewChange={(preview) => onSettingChange({ preview })}
/>
<Divider vertical />
<UndoButton
onClick={() => onToolAction("fogUndo")}

View File

@ -1,11 +1,13 @@
import React, { useEffect, useContext } from "react";
import { Flex } from "theme-ui";
import { Flex, Input, Text } from "theme-ui";
import ToolSection from "./ToolSection";
import MeasureChebyshevIcon from "../../../icons/MeasureChebyshevIcon";
import MeasureEuclideanIcon from "../../../icons/MeasureEuclideanIcon";
import MeasureManhattanIcon from "../../../icons/MeasureManhattanIcon";
import Divider from "../../Divider";
import MapInteractionContext from "../../../contexts/MapInteractionContext";
function MeasureToolSettings({ settings, onSettingChange }) {
@ -50,14 +52,31 @@ function MeasureToolSettings({ settings, onSettingChange }) {
},
];
// TODO Add keyboard shortcuts
return (
<Flex sx={{ alignItems: "center" }}>
<ToolSection
tools={tools}
onToolClick={(tool) => onSettingChange({ type: tool.id })}
/>
<Divider vertical />
<Text as="label" variant="body2" sx={{ fontSize: "16px" }} p={1}>
Scale:
</Text>
<Input
p={1}
pl={0}
sx={{
width: "40px",
border: "none",
":focus": {
outline: "none",
},
lineHeight: 1.2,
}}
value={settings.scale}
onChange={(e) => onSettingChange({ scale: e.target.value })}
autoComplete="off"
/>
</Flex>
);
}

View File

@ -0,0 +1,19 @@
import React from "react";
import { Flex, Box, Text } from "theme-ui";
function DiceRoll({ rolls, type, children }) {
return (
<Flex sx={{ flexWrap: "wrap" }}>
<Box sx={{ transform: "scale(0.8)" }}>{children}</Box>
{rolls
.filter((d) => d.type === type && d.roll !== "unknown")
.map((dice, index) => (
<Text as="p" my={1} variant="caption" mx={1} key={index}>
{dice.roll}
</Text>
))}
</Flex>
);
}
export default DiceRoll;

View File

@ -0,0 +1,72 @@
import React, { useState } from "react";
import { Flex, Text, IconButton } from "theme-ui";
import DiceRollsIcon from "../../icons/DiceRollsIcon";
import D20Icon from "../../icons/D20Icon";
import D12Icon from "../../icons/D12Icon";
import D10Icon from "../../icons/D10Icon";
import D8Icon from "../../icons/D8Icon";
import D6Icon from "../../icons/D6Icon";
import D4Icon from "../../icons/D4Icon";
import D100Icon from "../../icons/D100Icon";
import DiceRoll from "./DiceRoll";
import { getDiceRollTotal } from "../../helpers/dice";
function DiceRolls({ rolls }) {
const total = getDiceRollTotal(rolls);
const [expanded, setExpanded] = useState(false);
return (
total > 0 && (
<Flex sx={{ flexDirection: "column" }}>
<Flex sx={{ alignItems: "center" }}>
<IconButton
title={expanded ? "Hide Rolls" : "Show Rolls"}
aria-label={expanded ? "Hide Rolls" : "Show Rolls"}
onClick={() => setExpanded(!expanded)}
>
<DiceRollsIcon />
</IconButton>
<Text px={1} as="p" my={1} variant="body2" sx={{ width: "100%" }}>
{total}
</Text>
</Flex>
{expanded && (
<Flex
bg="overlay"
sx={{
flexDirection: "column",
}}
>
<DiceRoll rolls={rolls} type="d20">
<D20Icon />
</DiceRoll>
<DiceRoll rolls={rolls} type="d12">
<D12Icon />
</DiceRoll>
<DiceRoll rolls={rolls} type="d10">
<D10Icon />
</DiceRoll>
<DiceRoll rolls={rolls} type="d8">
<D8Icon />
</DiceRoll>
<DiceRoll rolls={rolls} type="d6">
<D6Icon />
</DiceRoll>
<DiceRoll rolls={rolls} type="d4">
<D4Icon />
</DiceRoll>
<DiceRoll rolls={rolls} type="d100">
<D100Icon />
</DiceRoll>
</Flex>
)}
</Flex>
)
);
}
export default DiceRolls;

View File

@ -6,7 +6,12 @@ import DiceTrayOverlay from "../dice/DiceTrayOverlay";
import { DiceLoadingProvider } from "../../contexts/DiceLoadingContext";
function MapDice() {
function DiceTrayButton({
shareDice,
onShareDiceChage,
diceRolls,
onDiceRollsChange,
}) {
const [isExpanded, setIsExpanded] = useState(false);
return (
@ -14,11 +19,12 @@ function MapDice() {
sx={{
position: "absolute",
top: 0,
left: 0,
left: "100%",
bottom: 0,
flexDirection: "column",
alignItems: "flex-start",
pointerEvents: "none",
zIndex: 1,
}}
ml={1}
>
@ -37,10 +43,16 @@ function MapDice() {
<ExpandMoreDiceIcon isExpanded={isExpanded} />
</IconButton>
<DiceLoadingProvider>
<DiceTrayOverlay isOpen={isExpanded} />
<DiceTrayOverlay
isOpen={isExpanded}
shareDice={shareDice}
onShareDiceChage={onShareDiceChage}
diceRolls={diceRolls}
onDiceRollsChange={onDiceRollsChange}
/>
</DiceLoadingProvider>
</Flex>
);
}
export default MapDice;
export default DiceTrayButton;

View File

@ -2,8 +2,9 @@ import React from "react";
import { Text, Flex } from "theme-ui";
import Stream from "./Stream";
import DiceRolls from "./DiceRolls";
function Nickname({ nickname, stream }) {
function Nickname({ nickname, stream, diceRolls }) {
return (
<Flex sx={{ flexDirection: "column" }}>
<Text
@ -17,6 +18,7 @@ function Nickname({ nickname, stream }) {
{nickname}
</Text>
{stream && <Stream stream={stream} nickname={nickname} />}
{diceRolls && <DiceRolls rolls={diceRolls} />}
</Flex>
);
}

View File

@ -1,11 +1,15 @@
import React from "react";
import { Flex, Box, Text } from "theme-ui";
import SimpleBar from "simplebar-react";
import AddPartyMemberButton from "./AddPartyMemberButton";
import Nickname from "./Nickname";
import ChangeNicknameButton from "./ChangeNicknameButton";
import StartStreamButton from "./StartStreamButton";
import SettingsButton from "../SettingsButton";
import StartTimerButton from "./StartTimerButton";
import Timer from "./Timer";
import DiceTrayButton from "./DiceTrayButton";
function Party({
nickname,
@ -16,6 +20,15 @@ function Party({
partyStreams,
onStreamStart,
onStreamEnd,
timer,
partyTimers,
onTimerStart,
onTimerStop,
shareDice,
onShareDiceChage,
diceRolls,
onDiceRollsChange,
partyDiceRolls,
}) {
return (
<Flex
@ -23,10 +36,11 @@ function Party({
bg="background"
sx={{
flexDirection: "column",
overflow: "visible",
alignItems: "center",
position: "relative",
width: "112px",
minWidth: "112px",
overflowY: "auto",
alignItems: "center",
}}
>
<Box
@ -38,21 +52,37 @@ function Party({
Party
</Text>
</Box>
<Box
sx={{
<SimpleBar
style={{
flexGrow: 1,
width: "100%",
minWidth: "112px",
padding: "0 16px",
height: "calc(100% - 232px)",
}}
>
<Nickname nickname={`${nickname} (you)` || ""} />
<Nickname
nickname={`${nickname} (you)`}
diceRolls={shareDice && diceRolls}
/>
{Object.entries(partyNicknames).map(([id, partyNickname]) => (
<Nickname
nickname={partyNickname}
key={id}
stream={partyStreams[id]}
diceRolls={partyDiceRolls[id]}
/>
))}
</Box>
{timer && <Timer timer={timer} index={0} />}
{Object.entries(partyTimers).map(([id, partyTimer], index) => (
<Timer
timer={partyTimer}
key={id}
// Put party timers above your timer if there is one
index={timer ? index + 1 : index}
/>
))}
</SimpleBar>
<Flex sx={{ flexDirection: "column" }}>
<ChangeNicknameButton nickname={nickname} onChange={onNicknameChange} />
<AddPartyMemberButton gameId={gameId} />
@ -61,8 +91,19 @@ function Party({
onStreamEnd={onStreamEnd}
stream={stream}
/>
<StartTimerButton
onTimerStart={onTimerStart}
onTimerStop={onTimerStop}
timer={timer}
/>
<SettingsButton />
</Flex>
<DiceTrayButton
shareDice={shareDice}
onShareDiceChage={onShareDiceChage}
diceRolls={diceRolls}
onDiceRollsChange={onDiceRollsChange}
/>
</Flex>
);
}

View File

@ -0,0 +1,38 @@
import React, { useState } from "react";
import { IconButton } from "theme-ui";
import StartTimerModal from "../../modals/StartTimerModal";
import StartTimerIcon from "../../icons/StartTimerIcon";
function StartTimerButton({ onTimerStart, onTimerStop, timer }) {
const [isTimerModalOpen, setIsTimerModalOpen] = useState(false);
function openModal() {
setIsTimerModalOpen(true);
}
function closeModal() {
setIsTimerModalOpen(false);
}
return (
<>
<IconButton
m={1}
aria-label="Start Timer"
title="Start Timer"
onClick={openModal}
>
<StartTimerIcon />
</IconButton>
<StartTimerModal
isOpen={isTimerModalOpen}
onRequestClose={closeModal}
onTimerStart={onTimerStart}
onTimerStop={onTimerStop}
timer={timer}
/>
</>
);
}
export default StartTimerButton;

View File

@ -102,15 +102,14 @@ function Stream({ stream, nickname }) {
>
<StreamMuteIcon muted={streamMuted} />
</IconButton>
{isVolumeControlAvailable && (
<Slider
value={streamVolume}
min={0}
max={2}
step={0.1}
onChange={handleVolumeChange}
/>
)}
<Slider
value={streamVolume}
min={0}
max={2}
step={0.1}
onChange={handleVolumeChange}
disabled={!isVolumeControlAvailable}
/>
{stream && <audio ref={audioRef} playsInline muted={streamMuted} />}
</Flex>
<Banner

View File

@ -0,0 +1,67 @@
import React, { useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { Box, Progress } from "theme-ui";
import usePortal from "../../helpers/usePortal";
function Timer({ timer, index }) {
const progressBarRef = useRef();
useEffect(() => {
if (progressBarRef.current && timer) {
progressBarRef.current.value = timer.current;
}
}, [timer]);
useEffect(() => {
let request = requestAnimationFrame(animate);
let previousTime = performance.now();
function animate(time) {
request = requestAnimationFrame(animate);
const deltaTime = time - previousTime;
previousTime = time;
if (progressBarRef.current && progressBarRef.current.value > 0) {
progressBarRef.current.value -= deltaTime;
}
}
return () => {
cancelAnimationFrame(request);
};
}, []);
const timerContainer = usePortal("root");
return ReactDOM.createPortal(
<Box
sx={{
position: "absolute",
display: "flex",
justifyContent: "center",
alignItems: "center",
// Offset for iOS safe zone
bottom: "env(safe-area-inset-bottom)",
flexDirection: "column",
borderRadius: "28px",
left: "50%",
maxWidth: "500px",
width: "40%",
transform: `translate(-50%, -${index * 36}px)`,
padding: "0 8px",
margin: "8px",
}}
bg="overlay"
>
<Progress
max={timer && timer.max}
m={2}
sx={{ width: "100%" }}
ref={progressBarRef}
/>
</Box>,
timerContainer
);
}
export default Timer;

View File

@ -19,7 +19,7 @@ function TokenSettings({
<Input
type="number"
name="tokenSize"
value={(token && token.defaultSize) || 1}
value={`${(token && token.defaultSize) || 0}`}
onChange={(e) =>
onSettingsChange("defaultSize", parseInt(e.target.value))
}

View File

@ -44,6 +44,7 @@ export function MapDataProvider({ children }) {
lastModified: Date.now() + i,
gridType: "grid",
showGrid: false,
snapToGrid: true,
});
// Add a state for the map if there isn't one already
const state = await database.table("states").get(id);

View File

@ -0,0 +1,31 @@
import React, { useState, useEffect } from "react";
import { getSettings } from "../settings";
const SettingsContext = React.createContext({
settings: {},
setSettings: () => {},
});
const settingsProvider = getSettings();
export function SettingsProvider({ children }) {
const [settings, setSettings] = useState(settingsProvider.getAll());
useEffect(() => {
settingsProvider.setAll(settings);
}, [settings]);
const value = {
settings,
setSettings,
};
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
}
export default SettingsContext;

View File

@ -129,6 +129,32 @@ function loadVersions(db) {
map.quality = "original";
});
});
// v1.5.0 - Fixed default token rogue spelling
db.version(7)
.stores({})
.upgrade((tx) => {
return tx
.table("states")
.toCollection()
.modify((state) => {
for (let id in state.tokens) {
if (state.tokens[id].tokenId === "__default-Rouge") {
state.tokens[id].tokenId = "__default-Rogue";
}
}
});
});
// v1.5.0 - Added map snap to grid option
db.version(8)
.stores({})
.upgrade((tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.snapToGrid = true;
});
});
}
// Get the dexie database used in DatabaseContext

View File

@ -1,4 +1,7 @@
import * as BABYLON from "babylonjs";
import { Vector3 } from "@babylonjs/core/Maths/math";
import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader";
import { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial";
import { PhysicsImpostor } from "@babylonjs/core/Physics/physicsImpostor";
import d4Source from "./shared/d4.glb";
import d6Source from "./shared/d6.glb";
@ -35,9 +38,8 @@ class Dice {
}
static async loadMesh(source, material, scene) {
let mesh = (
await BABYLON.SceneLoader.ImportMeshAsync("", source, "", scene)
).meshes[1];
let mesh = (await SceneLoader.ImportMeshAsync("", source, "", scene))
.meshes[1];
mesh.setParent(null);
mesh.material = material;
@ -48,7 +50,7 @@ class Dice {
}
static async loadMaterial(materialName, textures, scene) {
let pbr = new BABYLON.PBRMaterial(materialName, scene);
let pbr = new PBRMaterial(materialName, scene);
pbr.albedoTexture = await importTextureAsync(textures.albedo);
pbr.normalTexture = await importTextureAsync(textures.normal);
pbr.metallicTexture = await importTextureAsync(textures.metalRoughness);
@ -68,9 +70,9 @@ class Dice {
instance.addChild(locator);
}
instance.physicsImpostor = new BABYLON.PhysicsImpostor(
instance.physicsImpostor = new PhysicsImpostor(
instance,
BABYLON.PhysicsImpostor.ConvexHullImpostor,
PhysicsImpostor.ConvexHullImpostor,
physicalProperties,
scene
);
@ -99,8 +101,8 @@ class Dice {
}
static roll(instance) {
instance.physicsImpostor.setLinearVelocity(BABYLON.Vector3.Zero());
instance.physicsImpostor.setAngularVelocity(BABYLON.Vector3.Zero());
instance.physicsImpostor.setLinearVelocity(Vector3.Zero());
instance.physicsImpostor.setAngularVelocity(Vector3.Zero());
const scene = instance.getScene();
const diceTraySingle = scene.getNodeByID("dice_tray_single");
@ -110,7 +112,7 @@ class Dice {
: diceTrayDouble;
const trayBounds = visibleDiceTray.getBoundingInfo().boundingBox;
const position = new BABYLON.Vector3(
const position = new Vector3(
trayBounds.center.x + (Math.random() * 2 - 1),
8,
trayBounds.center.z + (Math.random() * 2 - 1)
@ -122,13 +124,13 @@ class Dice {
Math.random() * Math.PI * 2
);
const throwTarget = new BABYLON.Vector3(
const throwTarget = new Vector3(
lerp(trayBounds.minimumWorld.x, trayBounds.maximumWorld.x, Math.random()),
5,
lerp(trayBounds.minimumWorld.z, trayBounds.maximumWorld.z, Math.random())
);
const impulse = new BABYLON.Vector3(0, 0, 0)
const impulse = new Vector3(0, 0, 0)
.subtract(throwTarget)
.normalizeToNew()
.scale(lerp(minDiceRollSpeed, maxDiceRollSpeed, Math.random()));

View File

@ -1,4 +1,7 @@
import * as BABYLON from "babylonjs";
import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader";
import { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial";
import { PhysicsImpostor } from "@babylonjs/core/Physics/physicsImpostor";
import { Mesh } from "@babylonjs/core/Meshes/mesh";
import singleMeshSource from "./single.glb";
import doubleMeshSource from "./double.glb";
@ -55,19 +58,19 @@ class DiceTray {
}
createCollision(name, x, y, z, friction) {
let collision = BABYLON.Mesh.CreateBox(
let collision = Mesh.CreateBox(
name,
this.collisionSize,
this.scene,
true,
BABYLON.Mesh.DOUBLESIDE
Mesh.DOUBLESIDE
);
collision.position.x = x;
collision.position.y = y;
collision.position.z = z;
collision.physicsImpostor = new BABYLON.PhysicsImpostor(
collision.physicsImpostor = new PhysicsImpostor(
collision,
BABYLON.PhysicsImpostor.BoxImpostor,
PhysicsImpostor.BoxImpostor,
{ mass: 0, friction: friction },
this.scene
);
@ -115,19 +118,11 @@ class DiceTray {
async loadMeshes() {
this.singleMesh = (
await BABYLON.SceneLoader.ImportMeshAsync(
"",
singleMeshSource,
"",
this.scene
)
await SceneLoader.ImportMeshAsync("", singleMeshSource, "", this.scene)
).meshes[1];
this.singleMesh.id = "dice_tray_single";
this.singleMesh.name = "dice_tray";
let singleMaterial = new BABYLON.PBRMaterial(
"dice_tray_mat_single",
this.scene
);
let singleMaterial = new PBRMaterial("dice_tray_mat_single", this.scene);
singleMaterial.albedoTexture = await importTextureAsync(singleAlbedo);
singleMaterial.normalTexture = await importTextureAsync(singleNormal);
singleMaterial.metallicTexture = await importTextureAsync(
@ -143,19 +138,11 @@ class DiceTray {
this.singleMesh.isVisible = this.size === "single";
this.doubleMesh = (
await BABYLON.SceneLoader.ImportMeshAsync(
"",
doubleMeshSource,
"",
this.scene
)
await SceneLoader.ImportMeshAsync("", doubleMeshSource, "", this.scene)
).meshes[1];
this.doubleMesh.id = "dice_tray_double";
this.doubleMesh.name = "dice_tray";
let doubleMaterial = new BABYLON.PBRMaterial(
"dice_tray_mat_double",
this.scene
);
let doubleMaterial = new PBRMaterial("dice_tray_mat_double", this.scene);
doubleMaterial.albedoTexture = await importTextureAsync(doubleAlbedo);
doubleMaterial.normalTexture = await importTextureAsync(doubleNormal);
doubleMaterial.metallicTexture = await importTextureAsync(

View File

@ -1,4 +1,5 @@
import * as BABYLON from "babylonjs";
import { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial";
import { Color3 } from "@babylonjs/core/Maths/math";
import Dice from "../Dice";
@ -18,7 +19,7 @@ class GemstoneDice extends Dice {
}
static async loadMaterial(materialName, textures, scene) {
let pbr = new BABYLON.PBRMaterial(materialName, scene);
let pbr = new PBRMaterial(materialName, scene);
pbr.albedoTexture = await importTextureAsync(textures.albedo);
pbr.normalTexture = await importTextureAsync(textures.normal);
pbr.metallicTexture = await importTextureAsync(textures.metalRoughness);
@ -30,7 +31,7 @@ class GemstoneDice extends Dice {
pbr.subSurface.translucencyIntensity = 1.0;
pbr.subSurface.minimumThickness = 5;
pbr.subSurface.maximumThickness = 10;
pbr.subSurface.tintColor = new BABYLON.Color3(190 / 255, 0, 220 / 255);
pbr.subSurface.tintColor = new Color3(190 / 255, 0, 220 / 255);
return pbr;
}

View File

@ -1,4 +1,5 @@
import * as BABYLON from "babylonjs";
import { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial";
import { Color3 } from "@babylonjs/core/Maths/math";
import Dice from "../Dice";
@ -18,7 +19,7 @@ class GlassDice extends Dice {
}
static async loadMaterial(materialName, textures, scene) {
let pbr = new BABYLON.PBRMaterial(materialName, scene);
let pbr = new PBRMaterial(materialName, scene);
pbr.albedoTexture = await importTextureAsync(textures.albedo);
pbr.normalTexture = await importTextureAsync(textures.normal);
pbr.roughness = 0.25;
@ -30,7 +31,7 @@ class GlassDice extends Dice {
pbr.subSurface.translucencyIntensity = 2.5;
pbr.subSurface.minimumThickness = 10;
pbr.subSurface.maximumThickness = 10;
pbr.subSurface.tintColor = new BABYLON.Color3(43 / 255, 1, 115 / 255);
pbr.subSurface.tintColor = new Color3(43 / 255, 1, 115 / 255);
pbr.subSurface.thicknessTexture = await importTextureAsync(textures.mask);
pbr.subSurface.useMaskFromThicknessTexture = true;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -18,6 +18,9 @@ import addPartyMember from "./AddPartyMember.mp4";
import changeNickname from "./ChangeNickname.mp4";
import sharingAudio from "./SharingAudio.mp4";
import startGame from "./StartGame.mp4";
import diceSharing from "./DiceSharing.mp4";
import usingTimer from "./UsingTimer.mp4";
import usingPointer from "./UsingPointer.mp4";
export default {
defaultMaps,
@ -40,4 +43,7 @@ export default {
changeNickname,
sharingAudio,
startGame,
diceSharing,
usingTimer,
usingPointer,
};

View File

@ -2,4 +2,4 @@
### Database is disabled.
Owlbear Rodeo uses a local database to store saved data. If you are seeing a database is disabled message this usually means you have data storage disabled. The most common occurances of this is if you are using Private Browsing modes or in Firefox have the Never Remember History option enabled. The site will still function in these cases however all data will be lost when the page closes or reloads.
Owlbear Rodeo uses a local database to store saved data. If you are seeing a database is disabled message this usually means you have data storage disabled. The most common occurrences of this is if you are using Private Browsing modes or in Firefox have the Never Remember History option enabled. The site will still function in these cases however all data will be lost when the page closes or reloads.

View File

@ -1,10 +1,10 @@
While using Owlbear Rodeo you can share your computers audio to other party memebers.
While using Owlbear Rodeo you can share your computer's audio to other party members.
To accomplish this Owlbear Rodeo uses the audio portion of a browsers screen share support. This means that sharing audio relies on a browser that supports this functionality (currently this is Google Chrome and the new Microsoft Edge). What kind of audio you can share depends on the operating system you are using. Currently Google Chrome on Windows allows you to share the audio of any tab or an entire screen while on MacOS you can only share the audio of a tab. The limited support is why this feature is marked as experimental.
`Note: Even though sharing audio requires a supported browser, receiving audio works on all browsers`
To use audio sharing click the Start Radio Stream button in the bottom left to open the Radio Screen then click Start Radio. The browser will then ask to share your screen, click on the Chrome Tab option and select your tab. Insure to select the Share Audio Checkbox and finally click Share.
To use audio sharing click the Start Radio Stream button in the bottom left to open the Radio Screen then click Start Radio. The browser will then ask to share your screen, click on the Chrome Tab option and select your tab. Ensure to select the Share Audio Checkbox and finally click Share.
![Sharing Audio](sharingAudio)

View File

@ -20,7 +20,7 @@ To do this open the Map Select Screen and then either click the Add Map button i
Once a custom map has been added you must configure the size of the map.
To do this there is the Column and Row properties. Columns represents how many grid cells your map has in the horizontal direction while Rows represents the amount of cells in the vertical direction.
To do this there are the Column and Row properties. Columns represent how many grid cells your map has in the horizontal direction while Rows represents the amount of cells in the vertical direction.
`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`
@ -37,7 +37,8 @@ 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.
- 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 doesn't include a grid.
- Snap to Grid: When enabled tokens, drawing, fog and measurements will attempt to snap to the 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).

View File

@ -20,15 +20,21 @@ To reroll all your dice you can click the Reroll Dice icon in the bottom right o
To clear the dice in your dice tray you can click the Clear Dice button in the bottom left of the dice tray.
## Sharing Dice Rolls
To share dice rolls with other party members you can click the Share Dice Rolls button when the dice tray is open. Others can then see your dice roll total as well as a breakdown of each dice rolled updated in real time.
![Dice Sharing](diceSharing)
## Styling Your Dice
Owlbear Rodeo has a bunch of varying dice styles to choose from.
To change your dice style click Select Dice Style button in the top left of the dice tray.
To change your dice style click the Select Dice Style button in the top left of the dice tray.
![Dice Styles](diceStyles)
## Expanding Your Dice Tray
The dice tray comes in two different sizes to change the size click the Expand Dice Tray button in the top right of the dice tray.
The dice tray comes in two different sizes. To change the size click the Expand Dice Tray button in the top right of the dice tray.
![Dice Tray Size](diceTraySize)

View File

@ -4,7 +4,7 @@ The Fog Tool allows you to add hidden areas to control what the other party memb
![Using Fog](usingFog)
`Note: When using the Fog Tool the fog will be transparent, this is to make it easier to align your fog with the map below. When the Fog Tool is no longer in use the fog will be opaque. This extends to other party members meaning if others are denied fog editing permissions they can not see under the fog until you reveal what's underneath.`
`Note: If it is your map the fog will be transparent for you. This is to make it easier to edit the fog and also allows you to move any tokens that are underneath the fog. For other members (who don't have edit permissions for fog) the fog will be opaque. If you want to preview what other people will see you can enable the Fog Preview option in the fog tool settings.`
A summary of the Fog Tool options are listed below.
@ -17,5 +17,6 @@ A summary of the Fog Tool options are listed below.
| Add Fog | When selected drawing a fog shape will add it to the scene | Alt (Toggle) |
| Subtract Fog | When selected drawing a fog shape will subtract it from other shapes | Alt (Toggle) |
| Edge Snapping | Enables/Disables edge snapping | S |
| Fog Preview | Enables/Disables a preview of the final fog shapes | F |
| Undo | Undo a fog action | Ctrl + Z |
| Redo | Redo a fog action | Ctrl + Shift + Z |

View File

@ -6,6 +6,7 @@ A summary of the Measure Tool options are listed below.
| Option | Description | Shortcut |
| ------------------- | ---------------------------------------------------------------------------------- | -------- |
| Grid Distance | This is the distance on a grid and is the metric use in D&D | G |
| Grid Distance | This is the distance on a grid and is the metric used in D&D | G |
| Line Distance | This is the actual distance between the two points of the measure tool | L |
| City Block Distance | This is the distance when only travelling in the horizontal or vertical directions | C |
| Scale | This allows you to enter a custom scale and unit value to apply | |

View File

@ -0,0 +1,3 @@
The pointer tool allows you to temporarily highlight parts of the map for other members to see.
![Using Pointer](usingPointer)

View File

@ -0,0 +1,3 @@
The countdown timer allows you to run timed encounters. When clicking the Start Timer button you can set the duration of the timer and simply click the Start button to begin a timer that is shared between all party members.
![Using Timer](usingTimer)

View File

@ -41,13 +41,13 @@ Once a token has been uploaded you can adjust the default size that is used when
`Note: The size input for a non-square image represents the number of grid cells a token takes up on the horizontal axis. The number of cells in the vertical axis is determined by the aspect ratio of the uploaded image.`
`Tip: Owlbear Rodeo has full transparancy support for tokens. This means that players can only interact with visible parts of a token so feel free to upload creatures that might have large extended areas like wings.`
`Tip: Owlbear Rodeo has full transparency support for tokens. This means that players can only interact with visible parts of a token so feel free to upload creatures that might have large extended areas like wings.`
## Custom Tokens (Advanced)
When uploading a custom token there are a couple of more advanced options that may come in handy.
To get access to these settings select the desired token in the Edit Token Screen and click the Show More Button under the Deafult Size Input.
To get access to these settings select the desired token in the Edit Token Screen and click the Show More Button under the Default Size Input.
![Custom Tokens Advanced](customTokensAdvanced)

View File

@ -5,7 +5,7 @@
### Saved Maps
Added the ability to load and save multiple maps also added a selection of default maps.
The map upload modal now shows a grid of maps at first this will be the new set of default maps (Blank, Grass, Sand, Stone, Water and Wood) but when you add your own they will show here as well.
The map upload modal now shows a grid of maps. At first this will be the new set of default maps (Blank, Grass, Sand, Stone, Water and Wood) but when you add your own they will show here as well.
### Edit Permissions
@ -26,7 +26,7 @@ First fog and drawing have been separated into three tools. The first is the fog
The second tool is a new brush tool. The main update to this tool is a new line only drawing option which is good for making plans or annotations. The auto-shape detection has been removed in favour of the third tool.
The third tool is a new shape tool, hopefully this should be more precise then the auto-shape option from v1.1. Rectangles, circles and triangles can be created by selecting a point and dragging outwards to create the required shape. While drawing shapes they will snap to the grid to make it easier draw area of effect spells.
The third tool is a new shape tool, hopefully this should be more precise then the auto-shape option from v1.1. Rectangles, circles and triangles can be created by selecting a point and dragging outwards to create the required shape. While drawing shapes they will snap to the grid to make it easier to draw area of effect spells.
## Minor Changes

View File

@ -11,7 +11,7 @@ Added a physically simulated dice tray and dice with a bunch of features.
- Automatic dice total and roll breakdown.
- Physically based rendering for beautiful metal, plastic, glass, wood and stone dice.
- Two dice tray sizes, a small one for rolling while still seeing the map or a large one for when you have a lot of dice to roll.
- Intelligent renderering to allow the dice to only be drawn when there is movement, this allows there to be almost zero cost on battery life while the dice are inactive.
- Intelligent rendering to allow the dice to only be drawn when there is movement, this allows there to be almost zero cost on battery life while the dice are inactive.
### Custom and New Default Tokens

View File

@ -1,7 +1,7 @@
## Minor Changes
- Fixed a bug where tokens that were placed on the map then removed from the token select screen could no longer be deleted from the map.
- Fixed a bug where fog drawing couldn't be undone if there the last fog shape was deleted.
- Fixed a bug where fog drawing couldn't be undone if the last fog shape was deleted.
- Added the ability to add multiple new maps or tokens at the same time.
- Added a Show Grid option for maps that will overlay a grid on the map. This can be useful for when you have a map with no grid or you want to verify your current grid settings.
- Added the ability to erase multiple shapes at a time by dragging over a shape with the eraser tool. This works for fog erase and toggle as well.

View File

@ -1,7 +1,7 @@
## Minor Changes
- Fixed a bug that would cause the game to crash when a player would lose internet connection.
- Added an automatic reconnection feature for when internet connection is lost. This should also help when players put the site into the background and try and return to the game later.
- Added an automatic reconnection feature for when internet connection is lost. This should also help when players put the site into the background and try to return to the game later.
[Reddit](https://www.reddit.com/r/OwlbearRodeo/comments/ha301n/beta_v133_release_bug_fix_and_auto_reconnect/)
[Twitter](https://twitter.com/OwlbearRodeo/status/1272868031014727680?s=20)

View File

@ -13,7 +13,7 @@ The fog tool now has more options for supporting different interaction paradigms
A new measure tool has been added to allow you to easily find out how far areas of the map are from one another. This tool has three options for calculating distance.
- Grid Distance (default) or Chebyshev distance, this is the distance on a grid and is the metric use in D&D.
- Grid Distance (default) or Chebyshev distance, this is the distance on a grid and is the metric used in D&D.
- Line Distance or Euclidean distance is the actual distance between the two points of the measure tool.
- City Block Distance or Manhattan distance is the distance when only travelling in the horizontal or vertical directions.
@ -60,12 +60,12 @@ When interacting with the map we now support keyboard shortcuts for quickly swit
## Minor Changes
- The brush tool, shape tool and erase tool have been combined in to one drawing tool. Having these tools combined should hopefully make the drawing experience a little simpler.
- Added a new line tool that will allow you to draw staight lines.
- The brush tool, shape tool and erase tool have been combined into one drawing tool. Having these tools combined should hopefully make the drawing experience a little simpler.
- Added a new line tool that will allow you to draw straight lines.
- Fixed performance regression for drawing tools that was introduced in v1.3.0.
- Fixed performance issues when editing map and token settings.
- Added a notification for when a user can connect to the server but not to other party members.
- Fixed a bug that lead to a token getting stuck to the cursor when moving.
- Fixed a bug that led to a token getting stuck to the cursor when moving.
- Added a new loading indicator showing the progress of map and tokens when downloading them from other party members.
- Fixed a bug that stopped the undo and redo buttons for fog editing being synced to other party members.

View File

@ -9,7 +9,7 @@ A lot of the underlying network code was changed to support this so there may st
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 a bug that caused the drawing tools to disappear 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.

View File

@ -0,0 +1,43 @@
[embed:](https://www.youtube.com/embed/i4JvZboAPhQ)
## Major Changes
### Pointer Tool
Use a virtual laser pointer with the new pointer tool. With the tool selected simply click and drag and all party members will be able to see your pointer. This should be helpful for when you want to quickly draw the attention of the other players to a specific part of the map or token.
### Countdown Timer
A new countdown timer tool allows you to create timed encounters. Whether it's for a room filling with water or a crumbling tower about to collapse, the timer tool allows you to start a countdown that all players can see.
### Dice Sharing
A lot of changes have gone into the virtual dice this release.
The major two are:
1. There is a new share dice option that will show both your dice total but also a breakdown of all your rolled dice to each party member.
2. The UI to add dice is now to the side of the dice tray and displayed vertically which allows the dice UI to now scale across all screen sizes.
## Minor Changes
- The measure tool now has a scale option that allows you to add a scale and unit to the measurement values. The default is 5ft which will set each grid cell size to 5ft. This is then taken into account when taking measurements.
- For party members that are allowed to edit the fog it is now transparent while using other tools. This means GMs can always see what's under the fog now and can even move tokens that are hidden by fog. If you wish to get a preview of what other players will see there is a new option in the fog tool called Show Preview that can be enabled or disabled at any time.
- Fixed the spelling for the default rogue token.
- Fixed a bug that caused the dice throwing to not work if the dice was stationary for too long on Windows.
- Fixed a bug that caused the close button of the map select screen to select a map anyway.
- Added the ability to deselect maps in the map select screen by clicking outside of a map tile.
- Added a snap to grid option in the advanced map settings. Disabling this will disable all grid snapping features for a map.
- Added grid snapping for tokens. I was planning to add this when hex grid support was added but I liked it enough to add it now. For those who use hex grids you can disable the new snap to grid option to get the old functionality.
- Number inputs now behave better when typing by removing the prepended 0.
- Fog and drawing shapes now use different line endings which fixes issues with overhangs on three sided shapes.
- Tool settings are now saved across page refreshes this includes things like the shape tool colour, measurement units and also selected dice style.
- Fixed a bug that would stop map transforms from resetting when changing to a new map.
- Fixed a bug that caused the state of default maps to be overridden when joining another person's game.
[Reddit]()
[Twitter]()
---
Aug 11 2020

43
src/helpers/Settings.js Normal file
View File

@ -0,0 +1,43 @@
/**
* An interface to a local storage back settings store with a versioning mechanism
*/
class Settings {
name;
currentVersion;
constructor(name) {
this.name = name;
this.currentVersion = this.get("__version");
}
version(versionNumber, upgradeFunction) {
if (versionNumber > this.currentVersion) {
this.currentVersion = versionNumber;
this.setAll(upgradeFunction(this.getAll()));
}
}
getAll() {
return JSON.parse(localStorage.getItem(this.name));
}
get(key) {
const settings = this.getAll();
return settings && settings[key];
}
setAll(newSettings) {
localStorage.setItem(
this.name,
JSON.stringify({ ...newSettings, __version: this.currentVersion })
);
}
set(key, value) {
let settings = this.getAll();
settings[key] = value;
this.setAll(settings);
}
}
export default Settings;

View File

@ -1,9 +1,9 @@
import * as BABYLON from "babylonjs";
import { Texture } from "@babylonjs/core/Materials/Textures/texture";
// Turn texture load into an async function so it can be awaited
export async function importTextureAsync(url) {
return new Promise((resolve, reject) => {
let texture = new BABYLON.Texture(
let texture = new Texture(
url,
null,
undefined,

53
src/helpers/dice.js Normal file
View File

@ -0,0 +1,53 @@
import { Vector3 } from "@babylonjs/core/Maths/math";
/**
* Find the number facing up on a mesh instance of a dice
* @param {Object} instance The dice instance
*/
export function getDiceInstanceRoll(instance) {
let highestDot = -1;
let highestLocator;
for (let locator of instance.getChildTransformNodes()) {
let dif = locator
.getAbsolutePosition()
.subtract(instance.getAbsolutePosition());
let direction = dif.normalize();
const dot = Vector3.Dot(direction, Vector3.Up());
if (dot > highestDot) {
highestDot = dot;
highestLocator = locator;
}
}
return parseInt(highestLocator.name.slice(12));
}
/**
* Find the number facing up on a dice object
* @param {Object} dice The Dice object
*/
export function getDiceRoll(dice) {
let number = getDiceInstanceRoll(dice.instance);
// If the dice is a d100 add the d10
if (dice.type === "d100") {
const d10Number = getDiceInstanceRoll(dice.d10Instance);
// Both zero set to 100
if (d10Number === 0 && number === 0) {
number = 100;
} else {
number += d10Number;
}
} else if (dice.type === "d10" && number === 0) {
number = 10;
}
return { type: dice.type, roll: number };
}
export function getDiceRollTotal(diceRolls) {
return diceRolls.reduce((accumulator, dice) => {
if (dice.roll === "unknown") {
return accumulator;
} else {
return accumulator + dice.roll;
}
}, 0);
}

View File

@ -6,6 +6,7 @@ import { toDegrees, omit } from "./shared";
const snappingThreshold = 1 / 5;
export function getBrushPositionForTool(
map,
brushPosition,
tool,
toolSettings,
@ -14,12 +15,13 @@ export function getBrushPositionForTool(
) {
let position = brushPosition;
const useGridSnappning =
(tool === "drawing" &&
map.snapToGrid &&
((tool === "drawing" &&
(toolSettings.type === "line" ||
toolSettings.type === "rectangle" ||
toolSettings.type === "circle" ||
toolSettings.type === "triangle")) ||
(tool === "fog" && toolSettings.type === "polygon");
(tool === "fog" && toolSettings.type === "polygon"));
if (useGridSnappning) {
// Snap to corners of grid

View File

@ -1,5 +1,7 @@
import React, { useState } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Line, Group, Path, Circle } from "react-konva";
import { lerp } from "./shared";
import * as Vector2 from "./vector2";
// Holes should be wound in the opposite direction as the containing points array
export function HoleyLine({ holes, ...props }) {
@ -140,6 +142,93 @@ export function Tick({ x, y, scale, onClick, cross }) {
);
}
export function Trail({ position, size, duration, segments }) {
const trailRef = useRef();
const pointsRef = useRef([]);
const prevPositionRef = useRef(position);
// Add a new point every time position is changed
useEffect(() => {
if (Vector2.compare(position, prevPositionRef.current, 0.0001)) {
return;
}
pointsRef.current.push({ ...position, lifetime: duration });
prevPositionRef.current = position;
}, [position, duration]);
// Advance lifetime of trail
useEffect(() => {
let prevTime = performance.now();
let request = requestAnimationFrame(animate);
function animate(time) {
request = requestAnimationFrame(animate);
const deltaTime = time - prevTime;
prevTime = time;
if (pointsRef.current.length === 0) {
return;
}
let expired = 0;
for (let point of pointsRef.current) {
point.lifetime -= deltaTime;
if (point.lifetime < 0) {
expired++;
}
}
if (expired > 0) {
pointsRef.current = pointsRef.current.slice(expired);
}
if (trailRef.current) {
trailRef.current.getLayer().draw();
}
}
return () => {
cancelAnimationFrame(request);
};
}, []);
// Custom scene function for drawing a trail from a line
function sceneFunc(context) {
// Resample points to ensure a smooth trail
const resampledPoints = Vector2.resample(pointsRef.current, segments);
for (let i = 1; i < resampledPoints.length; i++) {
const from = resampledPoints[i - 1];
const to = resampledPoints[i];
const alpha = i / resampledPoints.length;
context.beginPath();
context.lineJoin = "round";
context.lineCap = "round";
context.lineWidth = alpha * size;
context.strokeStyle = `hsl(0, 63%, ${lerp(90, 50, alpha)}%)`;
context.moveTo(from.x, from.y);
context.lineTo(to.x, to.y);
context.stroke();
context.closePath();
}
}
return (
<Group>
<Line sceneFunc={sceneFunc} ref={trailRef} />
<Circle
x={position.x}
y={position.y}
fill="hsl(0, 63%, 50%)"
width={size}
height={size}
/>
</Group>
);
}
Trail.defaultProps = {
// Duration of each point in milliseconds
duration: 200,
// Number of segments in the trail, resampled from the points
segments: 20,
};
export function getRelativePointerPosition(node) {
let transform = node.getAbsoluteTransform().copy();
transform.invert();

33
src/helpers/timer.js Normal file
View File

@ -0,0 +1,33 @@
const MILLISECONDS_IN_HOUR = 3600000;
const MILLISECONDS_IN_MINUTE = 60000;
const MILLISECONDS_IN_SECOND = 1000;
/**
* Returns a timers duration in milliseconds
* @param {Object} t The object with an hour, minute and second property
*/
export function getHMSDuration(t) {
if (!t) {
return 0;
}
return (
t.hour * MILLISECONDS_IN_HOUR +
t.minute * MILLISECONDS_IN_MINUTE +
t.second * MILLISECONDS_IN_SECOND
);
}
/**
* Returns an object with an hour, minute and second property
* @param {number} duration The duration in milliseconds
*/
export function getDurationHMS(duration) {
let workingDuration = duration;
const hour = Math.floor(workingDuration / MILLISECONDS_IN_HOUR);
workingDuration -= hour * MILLISECONDS_IN_HOUR;
const minute = Math.floor(workingDuration / MILLISECONDS_IN_MINUTE);
workingDuration -= minute * MILLISECONDS_IN_MINUTE;
const second = Math.floor(workingDuration / MILLISECONDS_IN_SECOND);
return { hour, minute, second };
}

26
src/helpers/useSetting.js Normal file
View File

@ -0,0 +1,26 @@
import { useContext } from "react";
import get from "lodash.get";
import set from "lodash.set";
import SettingsContext from "../contexts/SettingsContext";
/**
* Helper to get and set nested settings that are saved in local storage
* @param {String} path The path to the setting within the Settings object provided by the SettingsContext
*/
function useSetting(path) {
const { settings, setSettings } = useContext(SettingsContext);
const setting = get(settings, path);
const setSetting = (value) =>
setSettings((prev) => {
const updated = set({ ...prev }, path, value);
return updated;
});
return [setting, setSetting];
}
export default useSetting;

View File

@ -1,4 +1,8 @@
import { toRadians, roundTo as roundToNumber } from "./shared";
import {
toRadians,
roundTo as roundToNumber,
lerp as lerpNumber,
} from "./shared";
export function lengthSquared(p) {
return p.x * p.x + p.y * p.y;
@ -238,3 +242,55 @@ export function distance(a, b, type) {
return length(subtract(a, b));
}
}
export function lerp(a, b, alpha) {
return { x: lerpNumber(a.x, b.x, alpha), y: lerpNumber(a.y, b.y, alpha) };
}
/**
* Returns total length of a an array of points treated as a path
* @param {Array} points the array of points in the path
*/
export function pathLength(points) {
let l = 0;
for (let i = 1; i < points.length; i++) {
l += distance(points[i - 1], points[i], "euclidean");
}
return l;
}
/**
* Resample a path to n number of evenly distributed points
* based off of http://depts.washington.edu/acelab/proj/dollar/index.html
* @param {Array} points the points to resample
* @param {number} n the number of new points
*/
export function resample(points, n) {
if (points.length === 0 || n <= 0) {
return [];
}
let localPoints = [...points];
const intervalLength = pathLength(localPoints) / (n - 1);
let resampledPoints = [localPoints[0]];
let currentDistance = 0;
for (let i = 1; i < localPoints.length; i++) {
let d = distance(localPoints[i - 1], localPoints[i], "euclidean");
if (currentDistance + d >= intervalLength) {
let newPoint = lerp(
localPoints[i - 1],
localPoints[i],
(intervalLength - currentDistance) / d
);
resampledPoints.push(newPoint);
localPoints.splice(i, 0, newPoint);
currentDistance = 0;
} else {
currentDistance += d;
}
}
if (resampledPoints.length === n - 1) {
resampledPoints.push(localPoints[localPoints.length - 1]);
}
return resampledPoints;
}

View File

@ -0,0 +1,18 @@
import React from "react";
function DiceRollsIcon() {
return (
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM7.5 18c-.83 0-1.5-.67-1.5-1.5S6.67 15 7.5 15s1.5.67 1.5 1.5S8.33 18 7.5 18zm0-9C6.67 9 6 8.33 6 7.5S6.67 6 7.5 6 9 6.67 9 7.5 8.33 9 7.5 9zm4.5 4.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5 4.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm0-9c-.83 0-1.5-.67-1.5-1.5S15.67 6 16.5 6s1.5.67 1.5 1.5S17.33 9 16.5 9z" />
</svg>
);
}
export default DiceRollsIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function FogPreviewOffIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M24 24H0V0h24z" fill="none" />
<path d="M12 6.5c2.76 0 5 2.24 5 5 0 .51-.1 1-.24 1.46l3.06 3.06c1.39-1.23 2.49-2.77 3.18-4.53C21.27 7.11 17 4 12 4c-1.27 0-2.49.2-3.64.57l2.17 2.17c.47-.14.96-.24 1.47-.24zM2.71 3.16c-.39.39-.39 1.02 0 1.41l1.97 1.97C3.06 7.83 1.77 9.53 1 11.5 2.73 15.89 7 19 12 19c1.52 0 2.97-.3 4.31-.82l2.72 2.72c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L4.13 3.16c-.39-.39-1.03-.39-1.42 0zM12 16.5c-2.76 0-5-2.24-5-5 0-.77.18-1.5.49-2.14l1.57 1.57c-.03.18-.06.37-.06.57 0 1.66 1.34 3 3 3 .2 0 .38-.03.57-.07L14.14 16c-.65.32-1.37.5-2.14.5zm2.97-5.33c-.15-1.4-1.25-2.49-2.64-2.64l2.64 2.64z" />
</svg>
);
}
export default FogPreviewOffIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function FogPreviewOnIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M24 24H0V0h24z" fill="none" />
<path d="M12 4C7 4 2.73 7.11 1 11.5 2.73 15.89 7 19 12 19s9.27-3.11 11-7.5C21.27 7.11 17 4 12 4zm0 12.5c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
</svg>
);
}
export default FogPreviewOnIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function PointerToolIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M6.387 17.964a4 4 0 005.657 0c1.042-1.041 3.564-5.45 7.569-13.225-7.776 4.004-12.184 6.527-13.226 7.568a4 4 0 000 5.657z" />
</svg>
);
}
export default PointerToolIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function ShareDiceOffIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M24 24H0V0h24z" fill="none" />
<path d="M11,8.17L6.49,3.66C8.07,2.61,9.96,2,12,2c5.52,0,10,4.48,10,10c0,2.04-0.61,3.93-1.66,5.51l-1.46-1.46 C19.59,14.87,20,13.48,20,12c0-3.35-2.07-6.22-5-7.41V5c0,1.1-0.9,2-2,2h-2V8.17z M20.49,21.9L20.49,21.9 c-0.39,0.39-1.02,0.39-1.41,0l-1.56-1.56c-2.07,1.37-4.68,2-7.45,1.48c-3.95-0.75-7.13-3.92-7.88-7.88 c-0.52-2.77,0.1-5.38,1.48-7.45L2.1,4.93c-0.39-0.39-0.39-1.02,0-1.41l0,0c0.39-0.39,1.02-0.39,1.41,0l16.97,16.97 C20.88,20.88,20.88,21.51,20.49,21.9z M11,18c-1.1,0-2-0.9-2-2v-1l-4.79-4.79C4.08,10.79,4,11.38,4,12c0,4.08,3.05,7.44,7,7.93V18z" />
</svg>
);
}
export default ShareDiceOffIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function ShareDiceOnIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M24 24H0V0h24z" fill="none" />
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
);
}
export default ShareDiceOnIcon;

View File

@ -0,0 +1,19 @@
import React from "react";
function StartTimerIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
transform="scale(-1 1)"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M16,22c1.1,0,2-0.9,2-2l-0.01-3.18c0-0.53-0.21-1.03-0.58-1.41L14,12l3.41-3.43c0.37-0.37,0.58-0.88,0.58-1.41L18,4 c0-1.1-0.9-2-2-2H8C6.9,2,6,2.9,6,4v3.16C6,7.69,6.21,8.2,6.58,8.58L10,12l-3.41,3.4C6.21,15.78,6,16.29,6,16.82V20 c0,1.1,0.9,2,2,2H16z M8,7.09V5c0-0.55,0.45-1,1-1h6c0.55,0,1,0.45,1,1v2.09c0,0.27-0.11,0.52-0.29,0.71L12,11.5L8.29,7.79 C8.11,7.61,8,7.35,8,7.09z" />
</svg>
);
}
export default StartTimerIcon;

View File

@ -22,6 +22,7 @@ const defaultMapProps = {
// TODO: add support for hex horizontal and hex vertical
gridType: "grid",
showGrid: false,
snapToGrid: true,
quality: "original",
};
@ -184,7 +185,11 @@ function SelectMapModal({
async function handleMapSelect(map) {
await applyMapChanges();
setSelectedMapId(map.id);
if (map) {
setSelectedMapId(map.id);
} else {
setSelectedMapId(null);
}
}
async function handleMapReset(id) {
@ -195,6 +200,13 @@ function SelectMapModal({
}
}
async function handleClose() {
if (selectedMapId) {
await applyMapChanges();
}
onDone();
}
async function handleDone() {
if (imageLoading) {
return;
@ -202,6 +214,8 @@ function SelectMapModal({
if (selectedMapId) {
await applyMapChanges();
onMapChange(selectedMapWithChanges, selectedMapStateWithChanges);
} else {
onMapChange(null, null);
}
onDone();
}
@ -235,7 +249,15 @@ function SelectMapModal({
selectedMapId &&
(!isEmpty(mapSettingChanges) || !isEmpty(mapStateSettingChanges))
) {
await updateMap(selectedMapId, mapSettingChanges);
// Ensure grid values are positive
let verifiedChanges = { ...mapSettingChanges };
if ("gridX" in verifiedChanges) {
verifiedChanges.gridX = verifiedChanges.gridX || 1;
}
if ("gridY" in verifiedChanges) {
verifiedChanges.gridY = verifiedChanges.gridY || 1;
}
await updateMap(selectedMapId, verifiedChanges);
await updateMapState(selectedMapId, mapStateSettingChanges);
setMapSettingChanges({});
@ -250,7 +272,7 @@ function SelectMapModal({
};
return (
<Modal isOpen={isOpen} onRequestClose={handleDone}>
<Modal isOpen={isOpen} onRequestClose={handleClose}>
<ImageDrop onDrop={handleImagesUpload} dropText="Drop map to upload">
<input
onChange={(event) => handleImagesUpload(event.target.files)}

View File

@ -112,7 +112,13 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
async function applyTokenChanges() {
if (selectedTokenId && !isEmpty(tokenSettingChanges)) {
await updateToken(selectedTokenId, tokenSettingChanges);
// Ensure size value is positive
let verifiedChanges = { ...tokenSettingChanges };
if ("defaultSize" in verifiedChanges) {
verifiedChanges.defaultSize = verifiedChanges.defaultSize || 1;
}
await updateToken(selectedTokenId, verifiedChanges);
setTokenSettingChanges({});
}
}

View File

@ -12,11 +12,15 @@ function SettingsModal({ isOpen, onRequestClose }) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
async function handleEraseAllData() {
localStorage.clear();
await database.delete();
window.location.reload();
}
async function handleClearCache() {
// Clear saved settings
localStorage.clear();
// Clear map cache
await database.table("maps").where("owner").notEqual(userId).delete();
// Find all other peoples tokens who aren't benig used in a map state and delete them
const tokens = await database

View File

@ -0,0 +1,127 @@
import React, { useRef } from "react";
import { Box, Label, Input, Button, Flex, Text } from "theme-ui";
import Modal from "../components/Modal";
import { getHMSDuration, getDurationHMS } from "../helpers/timer";
import useSetting from "../helpers/useSetting";
function StartTimerModal({
isOpen,
onRequestClose,
onTimerStart,
onTimerStop,
timer,
}) {
const inputRef = useRef();
function focusInput() {
inputRef.current && inputRef.current.focus();
}
const [hour, setHour] = useSetting("timer.hour");
const [minute, setMinute] = useSetting("timer.minute");
const [second, setSecond] = useSetting("timer.second");
function handleSubmit(event) {
event.preventDefault();
if (timer) {
onTimerStop();
} else {
const duration = getHMSDuration({ hour, minute, second });
onTimerStart({ current: duration, max: duration });
}
}
const inputStyle = {
width: "70px",
border: "none",
":focus": {
outline: "none",
},
fontSize: "32px",
padding: 2,
paddingLeft: 0,
};
function parseValue(value, max) {
const num = parseInt(value);
if (isNaN(num)) {
return 0;
}
return Math.min(num, max);
}
const timerHMS = timer && getDurationHMS(timer.current);
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
onAfterOpen={focusInput}
>
<Flex
sx={{
flexDirection: "column",
justifyContent: "center",
maxWidth: "300px",
flexGrow: 1,
}}
m={2}
>
<Box as="form" onSubmit={handleSubmit}>
<Label py={2}>Start a countdown timer</Label>
<Flex mb={2} sx={{ flexGrow: 1, alignItems: "baseline" }}>
<Text as="label" variant="body2" sx={{ fontSize: "16px" }} p={1}>
H:
</Text>
<Input
sx={inputStyle}
value={`${timer ? timerHMS.hour : hour}`}
onChange={(e) => setHour(parseValue(e.target.value, 24))}
type="number"
disabled={timer}
min={0}
max={24}
/>
<Text as="label" variant="body2" sx={{ fontSize: "16px" }} p={1}>
M:
</Text>
<Input
sx={inputStyle}
value={`${timer ? timerHMS.minute : minute}`}
onChange={(e) => setMinute(parseValue(e.target.value, 59))}
type="number"
ref={inputRef}
disabled={timer}
min={0}
max={59}
/>
<Text as="label" variant="body2" sx={{ fontSize: "16px" }} p={1}>
S:
</Text>
<Input
sx={inputStyle}
value={`${timer ? timerHMS.second : second}`}
onChange={(e) => setSecond(parseValue(e.target.value, 59))}
type="number"
disabled={timer}
min={0}
max={59}
/>
</Flex>
<Flex>
<Button
sx={{ flexGrow: 1 }}
disabled={hour === 0 && minute === 0 && second === 0}
>
{timer ? "Stop Timer" : "Start Timer"}
</Button>
</Flex>
</Box>
</Flex>
</Modal>
);
}
export default StartTimerModal;

View File

@ -427,6 +427,7 @@ function NetworkedMapAndTokens({ session }) {
allowFogDrawing={canEditFogDrawing}
allowMapChange={canChangeMap}
disabledTokens={disabledMapTokens}
session={session}
/>
<Tokens onMapTokenStateCreate={handleMapTokenStateCreate} />
</>

View File

@ -0,0 +1,178 @@
import React, { useState, useContext, useEffect, useRef } from "react";
import { Group } from "react-konva";
import AuthContext from "../contexts/AuthContext";
import MapPointer from "../components/map/MapPointer";
import { isEmpty } from "../helpers/shared";
import { lerp } from "../helpers/vector2";
// Send pointer updates every 33ms
const sendTickRate = 33;
function NetworkedMapPointer({ session, active, gridSize }) {
const { userId } = useContext(AuthContext);
const [pointerState, setPointerState] = useState({});
useEffect(() => {
if (userId && !(userId in pointerState)) {
setPointerState({
[userId]: { position: { x: 0, y: 0 }, visible: false, id: userId },
});
}
}, [userId, pointerState]);
const sessionRef = useRef(session);
useEffect(() => {
sessionRef.current = session;
}, [session]);
// Send pointer updates every sendTickRate to peers to save on bandwidth
// We use requestAnimationFrame as setInterval was being blocked during
// re-renders on Chrome with Windows
const ownPointerUpdateRef = useRef();
useEffect(() => {
let prevTime = performance.now();
let request = requestAnimationFrame(update);
let counter = 0;
function update(time) {
request = requestAnimationFrame(update);
const deltaTime = time - prevTime;
counter += deltaTime;
prevTime = time;
if (counter > sendTickRate) {
counter -= sendTickRate;
if (ownPointerUpdateRef.current && sessionRef.current) {
sessionRef.current.send("pointer", ownPointerUpdateRef.current);
ownPointerUpdateRef.current = null;
}
}
}
return () => {
cancelAnimationFrame(request);
};
}, []);
function updateOwnPointerState(position, visible) {
setPointerState((prev) => ({
...prev,
[userId]: { position, visible, id: userId },
}));
ownPointerUpdateRef.current = { position, visible, id: userId };
}
function handleOwnPointerDown(position) {
updateOwnPointerState(position, true);
}
function handleOwnPointerMove(position) {
updateOwnPointerState(position, true);
}
function handleOwnPointerUp(position) {
updateOwnPointerState(position, false);
}
// Handle pointer data receive
const syncedPointerStateRef = 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,
from: {
...from,
time: performance.now(),
},
to: {
...data,
time: performance.now() + sendTickRate,
},
};
} else {
syncedPointerStateRef.current[data.id] = {
from: null,
to: { ...data, time: performance.now() + sendTickRate },
};
}
}
}
session.on("data", handlePeerData);
return () => {
session.off("data", handlePeerData);
};
});
// Animate to the peer pointer positions
useEffect(() => {
let request = requestAnimationFrame(animate);
function animate(time) {
request = requestAnimationFrame(animate);
let interpolatedPointerState = {};
for (let syncState of Object.values(syncedPointerStateRef.current)) {
if (!syncState.from || !syncState.to) {
continue;
}
const totalInterpTime = syncState.to.time - syncState.from.time;
const currentInterpTime = time - syncState.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
),
};
}
if (alpha > 1 && !syncState.to.visible) {
interpolatedPointerState[syncState.id] = {
id: syncState.id,
visible: syncState.to.visible,
position: syncState.to.position,
};
delete syncedPointerStateRef.current[syncState.to.id];
}
}
if (!isEmpty(interpolatedPointerState)) {
setPointerState((prev) => ({
...prev,
...interpolatedPointerState,
}));
}
}
return () => {
cancelAnimationFrame(request);
};
}, []);
return (
<Group>
{Object.values(pointerState).map((pointer) => (
<MapPointer
key={pointer.id}
gridSize={gridSize}
active={pointer.id === userId ? active : false}
position={pointer.position}
visible={pointer.visible}
onPointerDown={pointer.id === userId && handleOwnPointerDown}
onPointerMove={pointer.id === userId && handleOwnPointerMove}
onPointerUp={pointer.id === userId && handleOwnPointerUp}
/>
))}
</Group>
);
}
export default NetworkedMapPointer;

View File

@ -3,9 +3,10 @@ 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 { isStreamStopped, omit, fromEntries } from "../helpers/shared";
import AuthContext from "../contexts/AuthContext";
import useSetting from "../helpers/useSetting";
import Party from "../components/party/Party";
@ -23,10 +24,16 @@ function NetworkedParty({ gameId, session }) {
const [partyNicknames, setPartyNicknames] = useState({});
const [stream, setStream] = useState(null);
const [partyStreams, setPartyStreams] = useState({});
const [timer, setTimer] = useState(null);
const [partyTimers, setPartyTimers] = useState({});
const [diceRolls, setDiceRolls] = useState([]);
const [partyDiceRolls, setPartyDiceRolls] = useState({});
function handleNicknameChange(nickname) {
setNickname(nickname);
session.send("nickname", { [session.id]: nickname });
const [shareDice, setShareDice] = useSetting("dice.shareDice");
function handleNicknameChange(newNickname) {
setNickname(newNickname);
session.send("nickname", { [session.id]: newNickname });
}
function handleStreamStart(localStream) {
@ -59,16 +66,82 @@ function NetworkedParty({ gameId, session }) {
[session]
);
function handleTimerStart(newTimer) {
setTimer(newTimer);
session.send("timer", { [session.id]: newTimer });
}
function handleTimerStop() {
setTimer(null);
session.send("timer", { [session.id]: null });
}
useEffect(() => {
let prevTime = performance.now();
let request = requestAnimationFrame(update);
let counter = 0;
function update(time) {
request = requestAnimationFrame(update);
const deltaTime = time - prevTime;
prevTime = time;
if (timer) {
counter += deltaTime;
// Update timer every second
if (counter > 1000) {
const newTimer = {
...timer,
current: timer.current - counter,
};
if (newTimer.current < 0) {
setTimer(null);
session.send("timer", { [session.id]: null });
} else {
setTimer(newTimer);
session.send("timer", { [session.id]: newTimer });
}
counter = 0;
}
}
}
return () => {
cancelAnimationFrame(request);
};
}, [timer, session]);
function handleDiceRollsChange(newDiceRolls) {
setDiceRolls(newDiceRolls);
if (shareDice) {
session.send("dice", { [session.id]: newDiceRolls });
}
}
function handleShareDiceChange(newShareDice) {
setShareDice(newShareDice);
if (newShareDice) {
session.send("dice", { [session.id]: diceRolls });
} else {
session.send("dice", { [session.id]: null });
}
}
useEffect(() => {
function handlePeerConnect({ peer, reply }) {
reply("nickname", { [session.id]: nickname });
if (stream) {
peer.connection.addStream(stream);
}
if (timer) {
reply("timer", { [session.id]: timer });
}
if (shareDice) {
reply("dice", { [session.id]: diceRolls });
}
}
function handlePeerDisconnect({ peer }) {
setPartyNicknames((prevNicknames) => omit(prevNicknames, [peer.id]));
setPartyTimers((prevTimers) => omit(prevTimers, [peer.id]));
}
function handlePeerData({ id, data }) {
@ -78,6 +151,26 @@ function NetworkedParty({ gameId, session }) {
...data,
}));
}
if (id === "timer") {
setPartyTimers((prevTimers) => {
const newTimers = { ...prevTimers, ...data };
// filter out timers that are null
const filtered = Object.entries(newTimers).filter(
([, value]) => value !== null
);
return fromEntries(filtered);
});
}
if (id === "dice") {
setPartyDiceRolls((prevDiceRolls) => {
const newRolls = { ...prevDiceRolls, ...data };
// filter out dice rolls that are null
const filtered = Object.entries(newRolls).filter(
([, value]) => value !== null
);
return fromEntries(filtered);
});
}
}
function handlePeerTrackAdded({ peer, stream: remoteStream }) {
@ -111,7 +204,7 @@ function NetworkedParty({ gameId, session }) {
session.off("trackAdded", handlePeerTrackAdded);
session.off("trackRemoved", handlePeerTrackRemoved);
};
}, [session, nickname, stream]);
}, [session, nickname, stream, timer, shareDice, diceRolls]);
useEffect(() => {
if (stream) {
@ -139,6 +232,15 @@ function NetworkedParty({ gameId, session }) {
partyNicknames={partyNicknames}
stream={stream}
partyStreams={partyStreams}
timer={timer}
partyTimers={partyTimers}
onTimerStart={handleTimerStart}
onTimerStop={handleTimerStop}
shareDice={shareDice}
onShareDiceChage={handleShareDiceChange}
diceRolls={diceRolls}
onDiceRollsChange={handleDiceRollsChange}
partyDiceRolls={partyDiceRolls}
/>
);
}

View File

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

View File

@ -18,6 +18,8 @@ const usingDice = raw("../docs/howTo/usingDice.md");
const usingFog = raw("../docs/howTo/usingFog.md");
const usingMeasure = raw("../docs/howTo/usingMeasure.md");
const sharingAudio = raw("../docs/howTo/sharingAudio.md");
const usingPointer = raw("../docs/howTo/usingPointer.md");
const usingTimer = raw("../docs/howTo/usingTimer.md");
function HowTo() {
const location = useLocation();
@ -101,6 +103,22 @@ function HowTo() {
<Markdown source={usingMeasure} assets={assets} />
</Accordion>
</div>
<div id="usingPointer">
<Accordion
heading="Using the Pointer Tool"
defaultOpen={location.hash === "#usingPointer"}
>
<Markdown source={usingPointer} assets={assets} />
</Accordion>
</div>
<div id="usingTimer">
<Accordion
heading="Using the Countdown Timer"
defaultOpen={location.hash === "#usingTimer"}
>
<Markdown source={usingTimer} assets={assets} />
</Accordion>
</div>
<div id="sharingAudio">
<Accordion
heading="Sharing Audio (Experimental)"

View File

@ -16,6 +16,7 @@ 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");
const v150 = raw("../docs/releaseNotes/v1.5.0.md");
function ReleaseNotes() {
return (
@ -39,18 +40,23 @@ function ReleaseNotes() {
<Text mb={2} variant="heading" as="h1" sx={{ fontSize: 5 }}>
Release Notes
</Text>
<div id="v150">
<Accordion heading="v1.5.0" defaultOpen>
<Markdown source={v150} />
</Accordion>
</div>
<div id="v141">
<Accordion heading="v1.4.2" defaultOpen>
<Accordion heading="v1.4.2">
<Markdown source={v142} />
</Accordion>
</div>
<div id="v141">
<Accordion heading="v1.4.1" defaultOpen>
<Accordion heading="v1.4.1">
<Markdown source={v141} />
</Accordion>
</div>
<div id="v140">
<Accordion heading="v1.4.0" defaultOpen>
<Accordion heading="v1.4.0">
<Markdown source={v140} />
</Accordion>
</div>

36
src/settings.js Normal file
View File

@ -0,0 +1,36 @@
import Settings from "./helpers/Settings";
function loadVersions(settings) {
settings.version(1, () => ({
fog: {
type: "polygon",
useEdgeSnapping: false,
useFogCut: false,
preview: false,
},
drawing: {
color: "red",
type: "brush",
useBlending: true,
},
measure: {
type: "chebyshev",
scale: "5ft",
},
timer: {
hour: 0,
minute: 0,
second: 0,
},
dice: {
shareDice: false,
style: "galaxy",
},
}));
}
export function getSettings() {
let settings = new Settings("OwlbearRodeoSettings");
loadVersions(settings);
return settings;
}

View File

@ -179,6 +179,11 @@ export default {
borderColor: "text",
},
},
slider: {
"&:disabled": {
opacity: 0.5,
},
},
},
buttons: {
primary: {

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

@ -24,7 +24,7 @@ import ooze from "./Ooze.png";
import paladin from "./Paladin.png";
import plant from "./Plant.png";
import ranger from "./Ranger.png";
import rouge from "./Rouge.png";
import rogue from "./Rogue.png";
import shapechanger from "./Shapechanger.png";
import sorcerer from "./Sorcerer.png";
import titan from "./Titan.png";
@ -42,7 +42,7 @@ export const tokenSources = {
monk,
paladin,
ranger,
rouge,
rogue,
sorcerer,
warlock,
wizard,

Some files were not shown because too many files have changed in this diff Show More