+ {children}
+ {dragging && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = "copy";
+ }}
+ onDrop={handleImageDrop}
+ >
+
+ {dropText || "Drop image to upload"}
+
+
+ )}
+
+ );
+}
+
+export default ImageDrop;
diff --git a/src/components/dice/DiceButton.js b/src/components/dice/DiceButton.js
new file mode 100644
index 0000000..a7f1f69
--- /dev/null
+++ b/src/components/dice/DiceButton.js
@@ -0,0 +1,21 @@
+import React from "react";
+import { IconButton } from "theme-ui";
+
+import Count from "./DiceButtonCount";
+
+function DiceButton({ title, children, count, onClick }) {
+ return (
+
+ {children}
+ {count && {count}}
+
+ );
+}
+
+export default DiceButton;
diff --git a/src/components/dice/DiceButtonCount.js b/src/components/dice/DiceButtonCount.js
new file mode 100644
index 0000000..b18caba
--- /dev/null
+++ b/src/components/dice/DiceButtonCount.js
@@ -0,0 +1,29 @@
+import React from "react";
+import { Box, Text } from "theme-ui";
+
+function DiceButtonCount({ children }) {
+ return (
+
+
+ {children}×
+
+
+ );
+}
+
+export default DiceButtonCount;
diff --git a/src/components/dice/DiceButtons.js b/src/components/dice/DiceButtons.js
new file mode 100644
index 0000000..c2900da
--- /dev/null
+++ b/src/components/dice/DiceButtons.js
@@ -0,0 +1,134 @@
+import React, { useState, useEffect } from "react";
+import { Flex, IconButton } from "theme-ui";
+
+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 ExpandMoreDiceTrayIcon from "../../icons/ExpandMoreDiceTrayIcon";
+
+import DiceButton from "./DiceButton";
+import SelectDiceButton from "./SelectDiceButton";
+
+import Divider from "../Divider";
+
+import { dice } from "../../dice";
+
+function DiceButtons({
+ diceRolls,
+ onDiceAdd,
+ onDiceLoad,
+ diceTraySize,
+ onDiceTraySizeChange,
+}) {
+ const [currentDice, setCurrentDice] = useState(dice[0]);
+
+ useEffect(() => {
+ const initialDice = dice[0];
+ onDiceLoad(initialDice);
+ setCurrentDice(initialDice);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const diceCounts = {};
+ for (let dice of diceRolls) {
+ if (dice.type in diceCounts) {
+ diceCounts[dice.type] += 1;
+ } else {
+ diceCounts[dice.type] = 1;
+ }
+ }
+
+ async function handleDiceChange(dice) {
+ await onDiceLoad(dice);
+ setCurrentDice(dice);
+ }
+
+ return (
+
+
+
+ onDiceAdd(currentDice.class, "d20")}
+ >
+
+
+ onDiceAdd(currentDice.class, "d12")}
+ >
+
+
+ onDiceAdd(currentDice.class, "d10")}
+ >
+
+
+ onDiceAdd(currentDice.class, "d8")}
+ >
+
+
+ onDiceAdd(currentDice.class, "d6")}
+ >
+
+
+ onDiceAdd(currentDice.class, "d4")}
+ >
+
+
+ onDiceAdd(currentDice.class, "d100")}
+ >
+
+
+
+
+ onDiceTraySizeChange(diceTraySize === "single" ? "double" : "single")
+ }
+ >
+
+
+
+ );
+}
+
+export default DiceButtons;
diff --git a/src/components/dice/DiceControls.js b/src/components/dice/DiceControls.js
new file mode 100644
index 0000000..9baabc9
--- /dev/null
+++ b/src/components/dice/DiceControls.js
@@ -0,0 +1,130 @@
+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 (
+ <>
+
+ {
+ onDiceClear();
+ setDiceRolls([]);
+ }}
+ onDiceReroll={onDiceReroll}
+ />
+
+
+ {
+ onDiceAdd(style, type);
+ setDiceRolls((prevRolls) => [
+ ...prevRolls,
+ { type, roll: "unknown" },
+ ]);
+ }}
+ onDiceLoad={onDiceLoad}
+ onDiceTraySizeChange={onDiceTraySizeChange}
+ diceTraySize={diceTraySize}
+ />
+
+ >
+ );
+}
+
+export default DiceControls;
diff --git a/src/components/dice/DiceInteraction.js b/src/components/dice/DiceInteraction.js
new file mode 100644
index 0000000..e573352
--- /dev/null
+++ b/src/components/dice/DiceInteraction.js
@@ -0,0 +1,155 @@
+import React, { useRef, useEffect } from "react";
+import * as BABYLON from "babylonjs";
+import * as AMMO from "ammo.js";
+import "babylonjs-loaders";
+import ReactResizeDetector from "react-resize-detector";
+
+import usePreventTouch from "../../helpers/usePreventTouch";
+
+const diceThrowSpeed = 2;
+
+function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
+ const sceneRef = useRef();
+ const engineRef = useRef();
+ const canvasRef = useRef();
+ const containerRef = useRef();
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const engine = new BABYLON.Engine(canvas, true, {
+ preserveDrawingBuffer: true,
+ stencil: true,
+ });
+ const scene = new BABYLON.Scene(engine);
+ scene.clearColor = new BABYLON.Color4(0, 0, 0, 0);
+ // Enable physics
+ scene.enablePhysics(
+ new BABYLON.Vector3(0, -98, 0),
+ new BABYLON.AmmoJSPlugin(true, AMMO)
+ );
+
+ let camera = new BABYLON.TargetCamera(
+ "camera",
+ new BABYLON.Vector3(0, 33.5, 0),
+ scene
+ );
+ camera.fov = 0.65;
+ camera.setTarget(BABYLON.Vector3.Zero());
+
+ onSceneMount && onSceneMount({ scene, engine, canvas });
+
+ engineRef.current = engine;
+ sceneRef.current = scene;
+
+ engine.runRenderLoop(() => {
+ const scene = sceneRef.current;
+ const selectedMesh = selectedMeshRef.current;
+ if (selectedMesh && scene) {
+ const ray = scene.createPickingRay(
+ scene.pointerX,
+ scene.pointerY,
+ BABYLON.Matrix.Identity(),
+ camera
+ );
+ const currentPosition = selectedMesh.getAbsolutePosition();
+ let newPosition = ray.origin.scale(camera.globalPosition.y);
+ newPosition.y = currentPosition.y;
+ const delta = newPosition.subtract(currentPosition);
+ selectedMesh.setAbsolutePosition(newPosition);
+ const velocity = delta.scale(1000 / scene.deltaTime);
+ selectedMeshVelocityWindowRef.current = selectedMeshVelocityWindowRef.current.slice(
+ Math.max(
+ selectedMeshVelocityWindowRef.current.length -
+ selectedMeshVelocityWindowSize,
+ 0
+ )
+ );
+ selectedMeshVelocityWindowRef.current.push(velocity);
+ }
+ });
+ }, [onSceneMount]);
+
+ const selectedMeshRef = useRef();
+ const selectedMeshVelocityWindowRef = useRef([]);
+ const selectedMeshVelocityWindowSize = 4;
+ 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()
+ );
+ selectedMeshRef.current = pickInfo.pickedMesh;
+ }
+ }
+ onPointerDown();
+ }
+
+ function handlePointerUp() {
+ const selectedMesh = selectedMeshRef.current;
+ const velocityWindow = selectedMeshVelocityWindowRef.current;
+ const scene = sceneRef.current;
+ if (selectedMesh && scene) {
+ // Average velocity window
+ let velocity = BABYLON.Vector3.Zero();
+ for (let v of velocityWindow) {
+ velocity.addInPlace(v);
+ }
+ if (velocityWindow.length > 0) {
+ velocity.scaleInPlace(1 / velocityWindow.length);
+ }
+
+ selectedMesh.physicsImpostor.applyImpulse(
+ velocity.scale(diceThrowSpeed * selectedMesh.physicsImpostor.mass),
+ selectedMesh.physicsImpostor.getObjectCenter()
+ );
+ }
+ selectedMeshRef.current = null;
+ selectedMeshVelocityWindowRef.current = [];
+
+ onPointerUp();
+ }
+
+ function handleResize(width, height) {
+ const engine = engineRef.current;
+ engine.resize();
+ canvasRef.current.width = width;
+ canvasRef.current.height = height;
+ }
+
+ usePreventTouch(containerRef);
+
+ return (
+
+
+
+
+
+ );
+}
+
+DiceInteraction.defaultProps = {
+ onPointerDown() {},
+ onPointerUp() {},
+};
+
+export default DiceInteraction;
diff --git a/src/components/dice/DiceResults.js b/src/components/dice/DiceResults.js
new file mode 100644
index 0000000..7ce4a2d
--- /dev/null
+++ b/src/components/dice/DiceResults.js
@@ -0,0 +1,97 @@
+import React, { useState } from "react";
+import { Flex, Text, Button, IconButton } from "theme-ui";
+
+import ClearDiceIcon from "../../icons/ClearDiceIcon";
+import RerollDiceIcon from "../../icons/RerollDiceIcon";
+
+const maxDiceRollsShown = 6;
+
+function DiceResults({ diceRolls, onDiceClear, onDiceReroll }) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ if (
+ diceRolls.map((dice) => dice.roll).includes("unknown") ||
+ diceRolls.length === 0
+ ) {
+ return null;
+ }
+
+ let rolls = [];
+ if (diceRolls.length > 1) {
+ rolls = diceRolls.map((dice, index) => (
+
+
+ {dice.roll}
+
+
+ {index === diceRolls.length - 1 ? "" : "+"}
+
+
+ ));
+ }
+
+ return (
+
+
+
+
+
+
+ {diceRolls.reduce((accumulator, dice) => accumulator + dice.roll, 0)}
+
+ {rolls.length > maxDiceRollsShown ? (
+
+ ) : (
+ {rolls}
+ )}
+
+
+
+
+
+ );
+}
+
+export default DiceResults;
diff --git a/src/components/dice/DiceTile.js b/src/components/dice/DiceTile.js
new file mode 100644
index 0000000..39f0d9d
--- /dev/null
+++ b/src/components/dice/DiceTile.js
@@ -0,0 +1,55 @@
+import React from "react";
+import { Flex, Image, Text } from "theme-ui";
+
+function DiceTile({ dice, isSelected, onDiceSelect, onDone }) {
+ return (
+ onDiceSelect(dice)}
+ sx={{
+ borderColor: "primary",
+ borderStyle: isSelected ? "solid" : "none",
+ borderWidth: "4px",
+ position: "relative",
+ width: "150px",
+ height: "150px",
+ borderRadius: "4px",
+ justifyContent: "center",
+ alignItems: "center",
+ cursor: "pointer",
+ }}
+ m={2}
+ bg="muted"
+ onDoubleClick={() => onDone(dice)}
+ >
+
+
+
+ {dice.name}
+
+
+
+ );
+}
+
+export default DiceTile;
diff --git a/src/components/dice/DiceTiles.js b/src/components/dice/DiceTiles.js
new file mode 100644
index 0000000..0965cd6
--- /dev/null
+++ b/src/components/dice/DiceTiles.js
@@ -0,0 +1,33 @@
+import React from "react";
+import { Flex } from "theme-ui";
+import SimpleBar from "simplebar-react";
+
+import DiceTile from "./DiceTile";
+
+function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
+ return (
+
+
+ {dice.map((dice) => (
+
+ ))}
+
+
+ );
+}
+
+export default DiceTiles;
diff --git a/src/components/dice/DiceTrayOverlay.js b/src/components/dice/DiceTrayOverlay.js
new file mode 100644
index 0000000..da38cc5
--- /dev/null
+++ b/src/components/dice/DiceTrayOverlay.js
@@ -0,0 +1,262 @@
+import React, {
+ useRef,
+ useCallback,
+ useEffect,
+ useContext,
+ useState,
+} from "react";
+import * as BABYLON from "babylonjs";
+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 DiceTray from "../../dice/diceTray/DiceTray";
+
+import DiceLoadingContext from "../../contexts/DiceLoadingContext";
+
+function DiceTrayOverlay({ isOpen }) {
+ const sceneRef = useRef();
+ const shadowGeneratorRef = useRef();
+ const diceRefs = useRef([]);
+ const sceneVisibleRef = useRef(false);
+ const sceneInteractionRef = useRef(false);
+ // Add to the counter to ingore sleep values
+ const sceneKeepAwakeRef = useRef(0);
+ const diceTrayRef = useRef();
+
+ const [diceTraySize, setDiceTraySize] = useState("single");
+ const { assetLoadStart, assetLoadFinish, isLoading } = useContext(
+ DiceLoadingContext
+ );
+
+ function handleAssetLoadStart() {
+ assetLoadStart();
+ }
+
+ function handleAssetLoadFinish() {
+ assetLoadFinish();
+ forceRender();
+ }
+
+ // Forces rendering for 1 second
+ function forceRender() {
+ // Force rerender
+ sceneKeepAwakeRef.current++;
+ let triggered = false;
+ let timeout = setTimeout(() => {
+ sceneKeepAwakeRef.current--;
+ triggered = true;
+ }, 1000);
+
+ return () => {
+ clearTimeout(timeout);
+ if (!triggered) {
+ sceneKeepAwakeRef.current--;
+ }
+ };
+ }
+
+ // Force render when changing dice tray size
+ useEffect(() => {
+ const diceTray = diceTrayRef.current;
+ let cleanup;
+ if (diceTray) {
+ diceTray.size = diceTraySize;
+ cleanup = forceRender();
+ }
+ return cleanup;
+ }, [diceTraySize]);
+
+ useEffect(() => {
+ let cleanup;
+ if (isOpen) {
+ sceneVisibleRef.current = true;
+ cleanup = forceRender();
+ } else {
+ sceneVisibleRef.current = false;
+ }
+
+ return cleanup;
+ }, [isOpen]);
+
+ const handleSceneMount = useCallback(async ({ scene, engine }) => {
+ sceneRef.current = scene;
+ await initializeScene(scene);
+ engine.runRenderLoop(() => update(scene));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ async function initializeScene(scene) {
+ handleAssetLoadStart();
+ let light = new BABYLON.DirectionalLight(
+ "DirectionalLight",
+ new BABYLON.Vector3(-0.5, -1, -0.5),
+ scene
+ );
+ light.position = new BABYLON.Vector3(5, 10, 5);
+ light.shadowMinZ = 1;
+ light.shadowMaxZ = 50;
+ let shadowGenerator = new BABYLON.ShadowGenerator(1024, light);
+ shadowGenerator.useCloseExponentialShadowMap = true;
+ shadowGenerator.darkness = 0.7;
+ shadowGeneratorRef.current = shadowGenerator;
+
+ scene.environmentTexture = BABYLON.CubeTexture.CreateFromPrefilteredData(
+ environment,
+ scene
+ );
+ scene.environmentIntensity = 1.0;
+
+ let diceTray = new DiceTray("single", scene, shadowGenerator);
+ await diceTray.load();
+ diceTrayRef.current = diceTray;
+ handleAssetLoadFinish();
+ }
+
+ function update(scene) {
+ function getDiceSpeed(dice) {
+ const diceSpeed = dice.instance.physicsImpostor
+ .getLinearVelocity()
+ .length();
+ // If the dice is a d100 check the d10 as well
+ if (dice.type === "d100") {
+ const d10Speed = dice.d10Instance.physicsImpostor
+ .getLinearVelocity()
+ .length();
+ return Math.max(diceSpeed, d10Speed);
+ } else {
+ return diceSpeed;
+ }
+ }
+
+ const die = diceRefs.current;
+ const sceneVisible = sceneVisibleRef.current;
+ if (!sceneVisible) {
+ return;
+ }
+ const forceSceneRender = sceneKeepAwakeRef.current > 0;
+ const sceneInteraction = sceneInteractionRef.current;
+ const diceAwake = die.map((dice) => dice.asleep).includes(false);
+ // Return early if scene doesn't need to be re-rendered
+ if (!forceSceneRender && !sceneInteraction && !diceAwake) {
+ return;
+ }
+
+ for (let i = 0; i < die.length; i++) {
+ const dice = die[i];
+ const speed = getDiceSpeed(dice);
+ // If the speed has been below 0.01 for 1s set dice to sleep
+ if (speed < 0.01 && !dice.sleepTimout) {
+ dice.sleepTimout = setTimeout(() => {
+ dice.asleep = true;
+ }, 1000);
+ } else if (speed > 0.5 && (dice.asleep || dice.sleepTimout)) {
+ dice.asleep = false;
+ clearTimeout(dice.sleepTimout);
+ dice.sleepTimout = null;
+ }
+ }
+
+ if (scene) {
+ scene.render();
+ }
+ }
+
+ function handleDiceAdd(style, type) {
+ const scene = sceneRef.current;
+ const shadowGenerator = shadowGeneratorRef.current;
+ if (scene && shadowGenerator) {
+ const instance = style.createInstance(type, scene);
+ shadowGenerator.addShadowCaster(instance);
+ Dice.roll(instance);
+ let dice = { type, instance, asleep: false };
+ // If we have a d100 add a d10 as well
+ if (type === "d100") {
+ const d10Instance = style.createInstance("d10", scene);
+ shadowGenerator.addShadowCaster(d10Instance);
+ Dice.roll(d10Instance);
+ dice.d10Instance = d10Instance;
+ }
+ diceRefs.current.push(dice);
+ }
+ }
+
+ function handleDiceClear() {
+ const die = diceRefs.current;
+ for (let dice of die) {
+ dice.instance.dispose();
+ if (dice.type === "d100") {
+ dice.d10Instance.dispose();
+ }
+ }
+ diceRefs.current = [];
+ forceRender();
+ }
+
+ function handleDiceReroll() {
+ const die = diceRefs.current;
+ for (let dice of die) {
+ Dice.roll(dice.instance);
+ if (dice.type === "d100") {
+ Dice.roll(dice.d10Instance);
+ }
+ dice.asleep = false;
+ }
+ }
+
+ async function handleDiceLoad(dice) {
+ handleAssetLoadStart();
+ const scene = sceneRef.current;
+ if (scene) {
+ await dice.class.load(scene);
+ }
+ handleAssetLoadFinish();
+ }
+
+ return (
+
+ {
+ sceneInteractionRef.current = true;
+ }}
+ onPointerUp={() => {
+ sceneInteractionRef.current = false;
+ }}
+ />
+
+ {isLoading && }
+
+ );
+}
+
+export default DiceTrayOverlay;
diff --git a/src/components/dice/SelectDiceButton.js b/src/components/dice/SelectDiceButton.js
new file mode 100644
index 0000000..933ccee
--- /dev/null
+++ b/src/components/dice/SelectDiceButton.js
@@ -0,0 +1,42 @@
+import React, { useState } from "react";
+import { IconButton } from "theme-ui";
+
+import SelectDiceIcon from "../../icons/SelectDiceIcon";
+import SelectDiceModal from "../../modals/SelectDiceModal";
+
+function SelectDiceButton({ onDiceChange, currentDice }) {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ function openModal() {
+ setIsModalOpen(true);
+ }
+ function closeModal() {
+ setIsModalOpen(false);
+ }
+
+ function handleDone(dice) {
+ onDiceChange(dice);
+ closeModal();
+ }
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+export default SelectDiceButton;
diff --git a/src/components/map/Map.js b/src/components/map/Map.js
index 965afef..f44e5e1 100644
--- a/src/components/map/Map.js
+++ b/src/components/map/Map.js
@@ -1,26 +1,24 @@
-import React, { useRef, useEffect, useState } from "react";
-import { Box, Image } from "theme-ui";
+import React, { useState, useContext, useEffect } from "react";
-import ProxyToken from "../token/ProxyToken";
-import TokenMenu from "../token/TokenMenu";
+import MapControls from "./MapControls";
+import MapInteraction from "./MapInteraction";
import MapToken from "./MapToken";
import MapDrawing from "./MapDrawing";
import MapFog from "./MapFog";
-import MapControls from "./MapControls";
+import MapDice from "./MapDice";
+
+import TokenDataContext from "../../contexts/TokenDataContext";
+import MapLoadingContext from "../../contexts/MapLoadingContext";
+
+import TokenMenu from "../token/TokenMenu";
+import TokenDragOverlay from "../token/TokenDragOverlay";
+import LoadingOverlay from "../LoadingOverlay";
import { omit } from "../../helpers/shared";
-import useDataSource from "../../helpers/useDataSource";
-import MapInteraction from "./MapInteraction";
-
-import { mapSources as defaultMapSources } from "../../maps";
-
-const mapTokenProxyClassName = "map-token__proxy";
-const mapTokenMenuClassName = "map-token__menu";
function Map({
map,
mapState,
- tokens,
onMapTokenStateChange,
onMapTokenStateRemove,
onMapChange,
@@ -34,23 +32,17 @@ function Map({
allowMapDrawing,
allowFogDrawing,
disabledTokens,
- loading,
}) {
- const mapSource = useDataSource(map, defaultMapSources);
+ const { tokensById } = useContext(TokenDataContext);
+ const { isLoading } = useContext(MapLoadingContext);
- function handleProxyDragEnd(isOnMap, tokenState) {
- if (isOnMap && onMapTokenStateChange) {
- onMapTokenStateChange(tokenState);
- }
-
- if (!isOnMap && onMapTokenStateRemove) {
- onMapTokenStateRemove(tokenState);
- }
- }
-
- /**
- * Map drawing
- */
+ const gridX = map && map.gridX;
+ const gridY = map && map.gridY;
+ const gridSizeNormalized = {
+ x: gridX ? 1 / gridX : 0,
+ y: gridY ? 1 / gridY : 0,
+ };
+ const tokenSizePercent = gridSizeNormalized.x;
const [selectedToolId, setSelectedToolId] = useState("pan");
const [toolSettings, setToolSettings] = useState({
@@ -100,6 +92,7 @@ function Map({
}
const [mapShapes, setMapShapes] = useState([]);
+
function handleMapShapeAdd(shape) {
onMapDraw({ type: "add", shapes: [shape] });
}
@@ -109,6 +102,7 @@ function Map({
}
const [fogShapes, setFogShapes] = useState([]);
+
function handleFogShapeAdd(shape) {
onFogDraw({ type: "add", shapes: [shape] });
}
@@ -190,97 +184,12 @@ function Map({
disabledSettings.fog.push("redo");
}
- /**
- * Member setup
- */
-
- const mapRef = useRef(null);
-
- const gridX = map && map.gridX;
- const gridY = map && map.gridY;
- const gridSizeNormalized = { x: 1 / gridX || 0, y: 1 / gridY || 0 };
- const tokenSizePercent = gridSizeNormalized.x * 100;
- const aspectRatio = (map && map.width / map.height) || 1;
-
- const mapImage = (
-
-
-
- );
-
- const mapTokens = (
-
- {mapState &&
- Object.values(mapState.tokens).map((tokenState) => (
- token.id === tokenState.tokenId)}
- tokenState={tokenState}
- tokenSizePercent={tokenSizePercent}
- className={`${mapTokenProxyClassName} ${mapTokenMenuClassName}`}
- />
- ))}
-
- );
-
- const mapDrawing = (
-
- );
-
- const mapFog = (
-
- );
-
const mapControls = (
);
+
+ const [isTokenMenuOpen, setIsTokenMenuOpen] = useState(false);
+ const [tokenMenuOptions, setTokenMenuOptions] = useState({});
+ const [draggingTokenOptions, setDraggingTokenOptions] = useState();
+ function handleTokenMenuOpen(tokenStateId, tokenImage) {
+ setTokenMenuOptions({ tokenStateId, tokenImage });
+ setIsTokenMenuOpen(true);
+ }
+
+ // Sort so vehicles render below other tokens
+ function sortMapTokenStates(a, b) {
+ const tokenA = tokensById[a.tokenId];
+ const tokenB = tokensById[b.tokenId];
+ if (tokenA && tokenB) {
+ return tokenB.isVehicle - tokenA.isVehicle;
+ } else if (tokenA) {
+ return 1;
+ } else if (tokenB) {
+ return -1;
+ } else {
+ return 0;
+ }
+ }
+
+ const mapTokens =
+ mapState &&
+ Object.values(mapState.tokens)
+ .sort(sortMapTokenStates)
+ .map((tokenState) => (
+
+ setDraggingTokenOptions({ tokenState, tokenImage: e.target })
+ }
+ onTokenDragEnd={() => setDraggingTokenOptions(null)}
+ draggable={
+ (selectedToolId === "pan" || selectedToolId === "erase") &&
+ !(tokenState.id in disabledTokens)
+ }
+ mapState={mapState}
+ />
+ ));
+
+ const tokenMenu = (
+ setIsTokenMenuOpen(false)}
+ onTokenStateChange={onMapTokenStateChange}
+ tokenState={mapState && mapState.tokens[tokenMenuOptions.tokenStateId]}
+ tokenImage={tokenMenuOptions.tokenImage}
+ />
+ );
+
+ const tokenDragOverlay = draggingTokenOptions && (
+ {
+ onMapTokenStateRemove(state);
+ setDraggingTokenOptions(null);
+ }}
+ onTokenStateChange={onMapTokenStateChange}
+ tokenState={draggingTokenOptions && draggingTokenOptions.tokenState}
+ tokenImage={draggingTokenOptions && draggingTokenOptions.tokenImage}
+ token={tokensById[draggingTokenOptions.tokenState.tokenId]}
+ mapState={mapState}
+ />
+ );
+
+ const mapDrawing = (
+
+ );
+
+ const mapFog = (
+
+ );
+
return (
- <>
-
- {map && mapImage}
- {map && mapDrawing}
- {map && mapFog}
- {map && mapTokens}
-
-
-
- >
+
+ {mapControls}
+ {tokenMenu}
+ {tokenDragOverlay}
+
+ {isLoading && }
+ >
+ }
+ selectedToolId={selectedToolId}
+ >
+ {mapDrawing}
+ {mapTokens}
+ {mapFog}
+
);
}
diff --git a/src/components/map/MapControls.js b/src/components/map/MapControls.js
index 7399798..93c2a25 100644
--- a/src/components/map/MapControls.js
+++ b/src/components/map/MapControls.js
@@ -1,8 +1,8 @@
-import React, { useState, Fragment, useEffect, useRef } from "react";
+import React, { useState, Fragment } from "react";
import { IconButton, Flex, Box } from "theme-ui";
import RadioIconButton from "./controls/RadioIconButton";
-import Divider from "./controls/Divider";
+import Divider from "../Divider";
import SelectMapButton from "./SelectMapButton";
@@ -22,6 +22,7 @@ function MapContols({
onMapChange,
onMapStateChange,
currentMap,
+ currentMapState,
selectedToolId,
onSelectedToolChange,
toolSettings,
@@ -73,6 +74,7 @@ function MapContols({
onMapChange={onMapChange}
onMapStateChange={onMapStateChange}
currentMap={currentMap}
+ currentMapState={currentMapState}
/>
),
},
@@ -144,9 +146,6 @@ function MapContols({
);
}
- const controlsRef = useRef();
- const settingsRef = useRef();
-
function getToolSettings() {
const Settings = toolsById[selectedToolId].SettingsComponent;
if (Settings) {
@@ -161,7 +160,6 @@ function MapContols({
borderRadius: "4px",
}}
p={1}
- ref={settingsRef}
>
{
- function stopPropagation(e) {
- e.stopPropagation();
- }
- const controls = controlsRef.current;
- if (controls) {
- controls.addEventListener("mousedown", stopPropagation);
- controls.addEventListener("touchstart", stopPropagation);
- }
- const settings = settingsRef.current;
- if (settings) {
- settings.addEventListener("mousedown", stopPropagation);
- settings.addEventListener("touchstart", stopPropagation);
- }
-
- return () => {
- if (controls) {
- controls.removeEventListener("mousedown", stopPropagation);
- controls.removeEventListener("touchstart", stopPropagation);
- }
- if (settings) {
- settings.removeEventListener("mousedown", stopPropagation);
- settings.removeEventListener("touchstart", stopPropagation);
- }
- };
- });
-
return (
<>
{controls}
diff --git a/src/components/map/MapDice.js b/src/components/map/MapDice.js
new file mode 100644
index 0000000..e40f109
--- /dev/null
+++ b/src/components/map/MapDice.js
@@ -0,0 +1,46 @@
+import React, { useState } from "react";
+import { Flex, IconButton } from "theme-ui";
+
+import ExpandMoreDiceIcon from "../../icons/ExpandMoreDiceIcon";
+import DiceTrayOverlay from "../dice/DiceTrayOverlay";
+
+import { DiceLoadingProvider } from "../../contexts/DiceLoadingContext";
+
+function MapDice() {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ return (
+
+ setIsExpanded(!isExpanded)}
+ sx={{
+ display: "block",
+ backgroundColor: "overlay",
+ borderRadius: "50%",
+ pointerEvents: "all",
+ }}
+ m={2}
+ >
+
+
+
+
+
+
+ );
+}
+
+export default MapDice;
diff --git a/src/components/map/MapDrawing.js b/src/components/map/MapDrawing.js
index fbc5416..922b1e4 100644
--- a/src/components/map/MapDrawing.js
+++ b/src/components/map/MapDrawing.js
@@ -1,260 +1,255 @@
-import React, { useRef, useEffect, useState, useContext } from "react";
+import React, { useContext, useState, useCallback } from "react";
import shortid from "shortid";
+import { Group, Line, Rect, Circle } from "react-konva";
+
+import MapInteractionContext from "../../contexts/MapInteractionContext";
import { compare as comparePoints } from "../../helpers/vector2";
import {
getBrushPositionForTool,
getDefaultShapeData,
getUpdatedShapeData,
- isShapeHovered,
- drawShape,
simplifyPoints,
- getRelativePointerPosition,
+ getStrokeWidth,
} from "../../helpers/drawing";
-import MapInteractionContext from "../../contexts/MapInteractionContext";
+import colors from "../../helpers/colors";
+import useMapBrush from "../../helpers/useMapBrush";
function MapDrawing({
- width,
- height,
- selectedTool,
- toolSettings,
shapes,
onShapeAdd,
onShapeRemove,
+ selectedToolId,
+ selectedToolSettings,
gridSize,
}) {
- const canvasRef = useRef();
- const containerRef = useRef();
-
- const [isPointerDown, setIsPointerDown] = useState(false);
+ const { stageScale, mapWidth, mapHeight } = useContext(MapInteractionContext);
const [drawingShape, setDrawingShape] = useState(null);
- const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
- const shouldHover = selectedTool === "erase";
+ const shouldHover = selectedToolId === "erase";
const isEditing =
- selectedTool === "brush" ||
- selectedTool === "shape" ||
- selectedTool === "erase";
+ selectedToolId === "brush" ||
+ selectedToolId === "shape" ||
+ selectedToolId === "erase";
- const { scaleRef } = useContext(MapInteractionContext);
-
- // Reset pointer position when tool changes
- useEffect(() => {
- setPointerPosition({ x: -1, y: -1 });
- }, [selectedTool]);
-
- function handleStart(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 1) {
- setIsPointerDown(false);
- setDrawingShape(null);
- return;
- }
- const pointer = event.touches ? event.touches[0] : event;
- const position = getRelativePointerPosition(pointer, containerRef.current);
- setPointerPosition(position);
- setIsPointerDown(true);
- const brushPosition = getBrushPositionForTool(
- position,
- selectedTool,
- toolSettings,
- gridSize,
- shapes
- );
- const commonShapeData = {
- color: toolSettings && toolSettings.color,
- blend: toolSettings && toolSettings.useBlending,
- id: shortid.generate(),
- };
- if (selectedTool === "brush") {
- setDrawingShape({
- type: "path",
- pathType: toolSettings.type,
- data: { points: [brushPosition] },
- strokeWidth: toolSettings.type === "stroke" ? 1 : 0,
- ...commonShapeData,
- });
- } else if (selectedTool === "shape") {
- setDrawingShape({
- type: "shape",
- shapeType: toolSettings.type,
- data: getDefaultShapeData(toolSettings.type, brushPosition),
- strokeWidth: 0,
- ...commonShapeData,
- });
- }
- }
-
- function handleMove(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 1) {
- return;
- }
- const pointer = event.touches ? event.touches[0] : event;
- // Set pointer position every frame for erase tool and fog
- if (shouldHover) {
- const position = getRelativePointerPosition(
- pointer,
- containerRef.current
- );
- setPointerPosition(position);
- }
- if (isPointerDown) {
- const position = getRelativePointerPosition(
- pointer,
- containerRef.current
- );
- setPointerPosition(position);
- const brushPosition = getBrushPositionForTool(
- position,
- selectedTool,
- toolSettings,
- gridSize,
- shapes
- );
- if (selectedTool === "brush") {
- setDrawingShape((prevShape) => {
- const prevPoints = prevShape.data.points;
- if (
- comparePoints(
- prevPoints[prevPoints.length - 1],
- brushPosition,
- 0.001
- )
- ) {
- return prevShape;
- }
- const simplified = simplifyPoints(
- [...prevPoints, brushPosition],
- gridSize,
- scaleRef.current
- );
- return {
- ...prevShape,
- data: { points: simplified },
- };
- });
- } else if (selectedTool === "shape") {
- setDrawingShape((prevShape) => ({
- ...prevShape,
- data: getUpdatedShapeData(
- prevShape.shapeType,
- prevShape.data,
- brushPosition,
- gridSize
- ),
- }));
- }
- }
- }
-
- function handleStop(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 0) {
- return;
- }
- if (selectedTool === "brush" && drawingShape) {
- if (drawingShape.data.points.length > 1) {
- onShapeAdd(drawingShape);
- }
- } else if (selectedTool === "shape" && drawingShape) {
- onShapeAdd(drawingShape);
- }
-
- if (selectedTool === "erase" && hoveredShapeRef.current && isPointerDown) {
- onShapeRemove(hoveredShapeRef.current.id);
- }
- setIsPointerDown(false);
- setDrawingShape(null);
- }
-
- // Add listeners for draw events on map to allow drawing past the bounds
- // of the container
- useEffect(() => {
- const map = document.querySelector(".map");
- map.addEventListener("mousedown", handleStart);
- map.addEventListener("mousemove", handleMove);
- map.addEventListener("mouseup", handleStop);
- map.addEventListener("touchstart", handleStart);
- map.addEventListener("touchmove", handleMove);
- map.addEventListener("touchend", handleStop);
-
- return () => {
- map.removeEventListener("mousedown", handleStart);
- map.removeEventListener("mousemove", handleMove);
- map.removeEventListener("mouseup", handleStop);
- map.removeEventListener("touchstart", handleStart);
- map.removeEventListener("touchmove", handleMove);
- map.removeEventListener("touchend", handleStop);
- };
- });
-
- /**
- * Rendering
- */
- const hoveredShapeRef = useRef(null);
- useEffect(() => {
- const canvas = canvasRef.current;
- if (canvas) {
- const context = canvas.getContext("2d");
-
- context.clearRect(0, 0, width, height);
- let hoveredShape = null;
- for (let shape of shapes) {
- if (shouldHover) {
- if (isShapeHovered(shape, context, pointerPosition, width, height)) {
- hoveredShape = shape;
- }
+ const handleShapeDraw = useCallback(
+ (brushState, mapBrushPosition) => {
+ function startShape() {
+ const brushPosition = getBrushPositionForTool(
+ mapBrushPosition,
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
+ );
+ const commonShapeData = {
+ color: selectedToolSettings && selectedToolSettings.color,
+ blend: selectedToolSettings && selectedToolSettings.useBlending,
+ id: shortid.generate(),
+ };
+ if (selectedToolId === "brush") {
+ setDrawingShape({
+ type: "path",
+ pathType: selectedToolSettings.type,
+ data: { points: [brushPosition] },
+ strokeWidth: selectedToolSettings.type === "stroke" ? 1 : 0,
+ ...commonShapeData,
+ });
+ } else if (selectedToolId === "shape") {
+ setDrawingShape({
+ type: "shape",
+ shapeType: selectedToolSettings.type,
+ data: getDefaultShapeData(selectedToolSettings.type, brushPosition),
+ strokeWidth: 0,
+ ...commonShapeData,
+ });
}
- drawShape(shape, context, gridSize, width, height);
}
- if (drawingShape) {
- drawShape(drawingShape, context, gridSize, width, height);
+
+ function continueShape() {
+ const brushPosition = getBrushPositionForTool(
+ mapBrushPosition,
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
+ );
+ if (selectedToolId === "brush") {
+ setDrawingShape((prevShape) => {
+ const prevPoints = prevShape.data.points;
+ if (
+ comparePoints(
+ prevPoints[prevPoints.length - 1],
+ brushPosition,
+ 0.001
+ )
+ ) {
+ return prevShape;
+ }
+ const simplified = simplifyPoints(
+ [...prevPoints, brushPosition],
+ gridSize,
+ stageScale
+ );
+ return {
+ ...prevShape,
+ data: { points: simplified },
+ };
+ });
+ } else if (selectedToolId === "shape") {
+ setDrawingShape((prevShape) => ({
+ ...prevShape,
+ data: getUpdatedShapeData(
+ prevShape.shapeType,
+ prevShape.data,
+ brushPosition,
+ gridSize
+ ),
+ }));
+ }
}
- if (hoveredShape) {
- const shape = { ...hoveredShape, color: "#BB99FF", blend: true };
- drawShape(shape, context, gridSize, width, height);
+
+ function endShape() {
+ if (selectedToolId === "brush" && drawingShape) {
+ if (drawingShape.data.points.length > 1) {
+ onShapeAdd(drawingShape);
+ }
+ } else if (selectedToolId === "shape" && drawingShape) {
+ onShapeAdd(drawingShape);
+ }
+ setDrawingShape(null);
}
- hoveredShapeRef.current = hoveredShape;
+
+ switch (brushState) {
+ case "first":
+ startShape();
+ return;
+ case "drawing":
+ continueShape();
+ return;
+ case "last":
+ endShape();
+ return;
+ default:
+ return;
+ }
+ },
+ [
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ stageScale,
+ onShapeAdd,
+ shapes,
+ drawingShape,
+ ]
+ );
+
+ useMapBrush(isEditing, handleShapeDraw);
+
+ function handleShapeClick(_, shape) {
+ if (selectedToolId === "erase") {
+ onShapeRemove(shape.id);
}
- }, [
- shapes,
- width,
- height,
- pointerPosition,
- isPointerDown,
- selectedTool,
- drawingShape,
- gridSize,
- shouldHover,
- ]);
+ }
+
+ function handleShapeMouseOver(event, shape) {
+ if (shouldHover) {
+ const path = event.target;
+ const hoverColor = "#BB99FF";
+ path.fill(hoverColor);
+ if (shape.type === "path") {
+ path.stroke(hoverColor);
+ }
+ path.getLayer().draw();
+ }
+ }
+
+ function handleShapeMouseOut(event, shape) {
+ if (shouldHover) {
+ const path = event.target;
+ const color = colors[shape.color] || shape.color;
+ path.fill(color);
+ if (shape.type === "path") {
+ path.stroke(color);
+ }
+ path.getLayer().draw();
+ }
+ }
+
+ function renderShape(shape) {
+ const defaultProps = {
+ key: shape.id,
+ onMouseOver: (e) => handleShapeMouseOver(e, shape),
+ onMouseOut: (e) => handleShapeMouseOut(e, shape),
+ onClick: (e) => handleShapeClick(e, shape),
+ onTap: (e) => handleShapeClick(e, shape),
+ fill: colors[shape.color] || shape.color,
+ opacity: shape.blend ? 0.5 : 1,
+ };
+ if (shape.type === "path") {
+ return (
+ [...acc, point.x * mapWidth, point.y * mapHeight],
+ []
+ )}
+ stroke={colors[shape.color] || shape.color}
+ tension={0.5}
+ closed={shape.pathType === "fill"}
+ fillEnabled={shape.pathType === "fill"}
+ lineCap="round"
+ strokeWidth={getStrokeWidth(
+ shape.strokeWidth,
+ gridSize,
+ mapWidth,
+ mapHeight
+ )}
+ {...defaultProps}
+ />
+ );
+ } else if (shape.type === "shape") {
+ if (shape.shapeType === "rectangle") {
+ return (
+
+ );
+ } else if (shape.shapeType === "circle") {
+ const minSide = mapWidth < mapHeight ? mapWidth : mapHeight;
+ return (
+
+ );
+ } else if (shape.shapeType === "triangle") {
+ return (
+ [...acc, point.x * mapWidth, point.y * mapHeight],
+ []
+ )}
+ closed={true}
+ {...defaultProps}
+ />
+ );
+ }
+ }
+ }
return (
-
-
-
+
+ {shapes.map(renderShape)}
+ {drawingShape && renderShape(drawingShape)}
+
);
}
diff --git a/src/components/map/MapFog.js b/src/components/map/MapFog.js
index c0cbfba..fbb8782 100644
--- a/src/components/map/MapFog.js
+++ b/src/components/map/MapFog.js
@@ -1,275 +1,213 @@
-import React, { useRef, useEffect, useState, useContext } from "react";
+import React, { useContext, useState, useCallback } from "react";
import shortid from "shortid";
+import { Group, Line } from "react-konva";
+import useImage from "use-image";
+
+import diagonalPattern from "../../images/DiagonalPattern.png";
+
+import MapInteractionContext from "../../contexts/MapInteractionContext";
import { compare as comparePoints } from "../../helpers/vector2";
import {
getBrushPositionForTool,
- isShapeHovered,
- drawShape,
simplifyPoints,
- getRelativePointerPosition,
+ getStrokeWidth,
} from "../../helpers/drawing";
-import MapInteractionContext from "../../contexts/MapInteractionContext";
-
-import diagonalPattern from "../../images/DiagonalPattern.png";
+import colors from "../../helpers/colors";
+import useMapBrush from "../../helpers/useMapBrush";
function MapFog({
- width,
- height,
- isEditing,
- toolSettings,
shapes,
onShapeAdd,
onShapeRemove,
onShapeEdit,
+ selectedToolId,
+ selectedToolSettings,
gridSize,
}) {
- const canvasRef = useRef();
- const containerRef = useRef();
-
- const [isPointerDown, setIsPointerDown] = useState(false);
+ const { stageScale, mapWidth, mapHeight } = useContext(MapInteractionContext);
const [drawingShape, setDrawingShape] = useState(null);
- const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
+ const isEditing = selectedToolId === "fog";
const shouldHover =
isEditing &&
- (toolSettings.type === "toggle" || toolSettings.type === "remove");
+ (selectedToolSettings.type === "toggle" ||
+ selectedToolSettings.type === "remove");
- const { scaleRef } = useContext(MapInteractionContext);
+ const [patternImage] = useImage(diagonalPattern);
- // Reset pointer position when tool changes
- useEffect(() => {
- setPointerPosition({ x: -1, y: -1 });
- }, [isEditing, toolSettings]);
-
- function handleStart(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 1) {
- setIsPointerDown(false);
- setDrawingShape(null);
- return;
- }
- const pointer = event.touches ? event.touches[0] : event;
- const position = getRelativePointerPosition(pointer, containerRef.current);
- setPointerPosition(position);
- setIsPointerDown(true);
- const brushPosition = getBrushPositionForTool(
- position,
- "fog",
- toolSettings,
- gridSize,
- shapes
- );
- if (isEditing && toolSettings.type === "add") {
- setDrawingShape({
- type: "fog",
- data: { points: [brushPosition] },
- strokeWidth: 0.5,
- color: "black",
- blend: true, // Blend while drawing
- id: shortid.generate(),
- visible: true,
- });
- }
- }
-
- function handleMove(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 1) {
- return;
- }
- const pointer = event.touches ? event.touches[0] : event;
- const position = getRelativePointerPosition(pointer, containerRef.current);
- // Set pointer position every frame for erase tool and fog
- if (shouldHover) {
- setPointerPosition(position);
- }
- if (isPointerDown) {
- setPointerPosition(position);
- const brushPosition = getBrushPositionForTool(
- position,
- "fog",
- toolSettings,
- gridSize,
- shapes
- );
- if (isEditing && toolSettings.type === "add" && drawingShape) {
- setDrawingShape((prevShape) => {
- const prevPoints = prevShape.data.points;
- if (
- comparePoints(
- prevPoints[prevPoints.length - 1],
- brushPosition,
- 0.001
- )
- ) {
- return prevShape;
- }
- return {
- ...prevShape,
- data: { points: [...prevPoints, brushPosition] },
- };
- });
- }
- }
- }
-
- function handleStop(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 0) {
- return;
- }
- if (isEditing && toolSettings.type === "add" && drawingShape) {
- if (drawingShape.data.points.length > 1) {
- const shape = {
- ...drawingShape,
- data: {
- points: simplifyPoints(
- drawingShape.data.points,
- gridSize,
- // Downscale fog as smoothing doesn't currently work with edge snapping
- scaleRef.current / 2
- ),
- },
- blend: false,
- };
- onShapeAdd(shape);
- }
- }
-
- if (hoveredShapeRef.current && isPointerDown) {
- if (toolSettings.type === "remove") {
- onShapeRemove(hoveredShapeRef.current.id);
- } else if (toolSettings.type === "toggle") {
- onShapeEdit({
- ...hoveredShapeRef.current,
- visible: !hoveredShapeRef.current.visible,
- });
- }
- }
- setDrawingShape(null);
- setIsPointerDown(false);
- }
-
- // Add listeners for draw events on map to allow drawing past the bounds
- // of the container
- useEffect(() => {
- const map = document.querySelector(".map");
- map.addEventListener("mousedown", handleStart);
- map.addEventListener("mousemove", handleMove);
- map.addEventListener("mouseup", handleStop);
- map.addEventListener("touchstart", handleStart);
- map.addEventListener("touchmove", handleMove);
- map.addEventListener("touchend", handleStop);
-
- return () => {
- map.removeEventListener("mousedown", handleStart);
- map.removeEventListener("mousemove", handleMove);
- map.removeEventListener("mouseup", handleStop);
- map.removeEventListener("touchstart", handleStart);
- map.removeEventListener("touchmove", handleMove);
- map.removeEventListener("touchend", handleStop);
- };
- });
-
- /**
- * Rendering
- */
- const hoveredShapeRef = useRef(null);
- const diagonalPatternRef = useRef();
-
- useEffect(() => {
- let image = new Image();
- image.src = diagonalPattern;
- diagonalPatternRef.current = image;
- }, []);
-
- useEffect(() => {
- const canvas = canvasRef.current;
- if (canvas) {
- const context = canvas.getContext("2d");
-
- context.clearRect(0, 0, width, height);
- let hoveredShape = null;
- if (isEditing) {
- const editPattern = context.createPattern(
- diagonalPatternRef.current,
- "repeat"
+ const handleShapeDraw = useCallback(
+ (brushState, mapBrushPosition) => {
+ function startShape() {
+ const brushPosition = getBrushPositionForTool(
+ mapBrushPosition,
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
);
- for (let shape of shapes) {
- if (shouldHover) {
- if (
- isShapeHovered(shape, context, pointerPosition, width, height)
- ) {
- hoveredShape = shape;
- }
- }
- drawShape(
- {
- ...shape,
- blend: true,
- color: shape.visible ? "black" : editPattern,
- },
- context,
- gridSize,
- width,
- height
- );
- }
- if (drawingShape) {
- drawShape(drawingShape, context, gridSize, width, height);
- }
- if (hoveredShape) {
- const shape = { ...hoveredShape, color: "#BB99FF", blend: true };
- drawShape(shape, context, gridSize, width, height);
- }
- } else {
- // Not editing
- for (let shape of shapes) {
- if (shape.visible) {
- drawShape(shape, context, gridSize, width, height);
- }
+ if (selectedToolSettings.type === "add") {
+ setDrawingShape({
+ type: "fog",
+ data: { points: [brushPosition] },
+ strokeWidth: 0.5,
+ color: "black",
+ blend: false,
+ id: shortid.generate(),
+ visible: true,
+ });
}
}
- hoveredShapeRef.current = hoveredShape;
+
+ function continueShape() {
+ const brushPosition = getBrushPositionForTool(
+ mapBrushPosition,
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
+ );
+ if (selectedToolSettings.type === "add") {
+ setDrawingShape((prevShape) => {
+ const prevPoints = prevShape.data.points;
+ if (
+ comparePoints(
+ prevPoints[prevPoints.length - 1],
+ brushPosition,
+ 0.001
+ )
+ ) {
+ return prevShape;
+ }
+ return {
+ ...prevShape,
+ data: { points: [...prevPoints, brushPosition] },
+ };
+ });
+ }
+ }
+
+ function endShape() {
+ if (selectedToolSettings.type === "add" && drawingShape) {
+ if (drawingShape.data.points.length > 1) {
+ const shape = {
+ ...drawingShape,
+ data: {
+ points: simplifyPoints(
+ drawingShape.data.points,
+ gridSize,
+ // Downscale fog as smoothing doesn't currently work with edge snapping
+ stageScale / 2
+ ),
+ },
+ };
+ onShapeAdd(shape);
+ }
+ }
+ setDrawingShape(null);
+ }
+
+ switch (brushState) {
+ case "first":
+ startShape();
+ return;
+ case "drawing":
+ continueShape();
+ return;
+ case "last":
+ endShape();
+ return;
+ default:
+ return;
+ }
+ },
+ [
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ stageScale,
+ onShapeAdd,
+ shapes,
+ drawingShape,
+ ]
+ );
+
+ useMapBrush(isEditing, handleShapeDraw);
+
+ function handleShapeClick(_, shape) {
+ if (!isEditing) {
+ return;
}
- }, [
- shapes,
- width,
- height,
- pointerPosition,
- isEditing,
- drawingShape,
- gridSize,
- shouldHover,
- ]);
+
+ if (selectedToolSettings.type === "remove") {
+ onShapeRemove(shape.id);
+ } else if (selectedToolSettings.type === "toggle") {
+ onShapeEdit({ ...shape, visible: !shape.visible });
+ }
+ }
+
+ function handleShapeMouseOver(event, shape) {
+ if (shouldHover) {
+ const path = event.target;
+ if (shape.visible) {
+ const hoverColor = "#BB99FF";
+ path.fill(hoverColor);
+ } else {
+ path.opacity(1);
+ }
+ path.getLayer().draw();
+ }
+ }
+
+ function handleShapeMouseOut(event, shape) {
+ if (shouldHover) {
+ const path = event.target;
+ if (shape.visible) {
+ const color = colors[shape.color] || shape.color;
+ path.fill(color);
+ } else {
+ path.opacity(0.5);
+ }
+ path.getLayer().draw();
+ }
+ }
+
+ function renderShape(shape) {
+ return (
+ handleShapeMouseOver(e, shape)}
+ onMouseOut={(e) => handleShapeMouseOut(e, shape)}
+ onClick={(e) => handleShapeClick(e, shape)}
+ onTap={(e) => handleShapeClick(e, shape)}
+ points={shape.data.points.reduce(
+ (acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
+ []
+ )}
+ stroke={colors[shape.color] || shape.color}
+ fill={colors[shape.color] || shape.color}
+ closed
+ lineCap="round"
+ strokeWidth={getStrokeWidth(
+ shape.strokeWidth,
+ gridSize,
+ mapWidth,
+ mapHeight
+ )}
+ visible={isEditing || shape.visible}
+ opacity={isEditing ? 0.5 : 1}
+ fillPatternImage={patternImage}
+ fillPriority={isEditing && !shape.visible ? "pattern" : "color"}
+ />
+ );
+ }
return (
-
-
-
+
+ {shapes.map(renderShape)}
+ {drawingShape && renderShape(drawingShape)}
+
);
}
diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js
index 06e0cd0..60e929a 100644
--- a/src/components/map/MapInteraction.js
+++ b/src/components/map/MapInteraction.js
@@ -1,166 +1,248 @@
-import React, { useRef, useEffect } from "react";
+import React, { useRef, useEffect, useState, useContext } from "react";
import { Box } from "theme-ui";
-import interact from "interactjs";
-import normalizeWheel from "normalize-wheel";
+import { useGesture } from "react-use-gesture";
+import ReactResizeDetector from "react-resize-detector";
+import useImage from "use-image";
+import { Stage, Layer, Image } from "react-konva";
+
+import usePreventOverscroll from "../../helpers/usePreventOverscroll";
+import useDataSource from "../../helpers/useDataSource";
+
+import { mapSources as defaultMapSources } from "../../maps";
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
+import MapStageContext from "../../contexts/MapStageContext";
+import AuthContext from "../../contexts/AuthContext";
-import LoadingOverlay from "../LoadingOverlay";
-
-const zoomSpeed = -0.001;
+const wheelZoomSpeed = -0.001;
+const touchZoomSpeed = 0.005;
const minZoom = 0.1;
const maxZoom = 5;
-function MapInteraction({
- map,
- aspectRatio,
- isEnabled,
- children,
- controls,
- loading,
-}) {
- const mapContainerRef = useRef();
- const mapMoveContainerRef = useRef();
- const mapTranslateRef = useRef({ x: 0, y: 0 });
- const mapScaleRef = useRef(1);
- function setTranslateAndScale(newTranslate, newScale) {
- const moveContainer = mapMoveContainerRef.current;
- moveContainer.style.transform = `translate(${newTranslate.x}px, ${newTranslate.y}px) scale(${newScale})`;
- mapScaleRef.current = newScale;
- mapTranslateRef.current = newTranslate;
- }
+function MapInteraction({ map, children, controls, selectedToolId }) {
+ const mapSource = useDataSource(map, defaultMapSources);
+ const [mapSourceImage] = useImage(mapSource);
+ const [stageWidth, setStageWidth] = useState(1);
+ const [stageHeight, setStageHeight] = useState(1);
+ const [stageScale, setStageScale] = useState(1);
+ // "none" | "first" | "dragging" | "last"
+ const [stageDragState, setStageDragState] = useState("none");
+ const [preventMapInteraction, setPreventMapInteraction] = useState(false);
+
+ const stageWidthRef = useRef(stageWidth);
+ const stageHeightRef = useRef(stageHeight);
+ // Avoid state udpates when panning the map by using a ref and updating the konva element directly
+ const stageTranslateRef = useRef({ x: 0, y: 0 });
+ const mapDragPositionRef = useRef({ x: 0, y: 0 });
+
+ // Reset transform when map changes
useEffect(() => {
- function handleMove(event, isGesture) {
- const scale = mapScaleRef.current;
- const translate = mapTranslateRef.current;
+ const layer = mapLayerRef.current;
+ if (map && layer) {
+ const mapHeight = stageWidthRef.current * (map.height / map.width);
+ const newTranslate = {
+ x: 0,
+ y: -(mapHeight - stageHeightRef.current) / 2,
+ };
+ layer.x(newTranslate.x);
+ layer.y(newTranslate.y);
+ layer.draw();
+ stageTranslateRef.current = newTranslate;
- let newScale = scale;
- let newTranslate = translate;
-
- if (isGesture) {
- newScale = Math.max(Math.min(scale + event.ds, maxZoom), minZoom);
- }
-
- if (isEnabled || isGesture) {
- newTranslate = {
- x: translate.x + event.dx,
- y: translate.y + event.dy,
- };
- }
- setTranslateAndScale(newTranslate, newScale);
+ setStageScale(1);
}
- const mapInteract = interact(".map")
- .gesturable({
- listeners: {
- move: (e) => handleMove(e, true),
- },
- })
- .draggable({
- inertia: true,
- listeners: {
- move: (e) => handleMove(e, false),
- },
- cursorChecker: () => {
- return isEnabled && map ? "move" : "default";
- },
- })
- .on("doubletap", (event) => {
- event.preventDefault();
- if (isEnabled) {
- setTranslateAndScale({ x: 0, y: 0 }, 1);
- }
- });
-
- return () => {
- mapInteract.unset();
- };
- }, [isEnabled, map]);
-
- // Reset map transform when map changes
- useEffect(() => {
- setTranslateAndScale({ x: 0, y: 0 }, 1);
}, [map]);
- // Bind the wheel event of the map via a ref
- // in order to support non-passive event listening
- // to allow the track pad zoom to be interrupted
- // see https://github.com/facebook/react/issues/14856
- useEffect(() => {
- const mapContainer = mapContainerRef.current;
+ // Convert a client space XY to be normalized to the map image
+ function getMapDragPosition(xy) {
+ const [x, y] = xy;
+ const container = containerRef.current;
+ const mapImage = mapImageRef.current;
+ if (container && mapImage) {
+ const containerRect = container.getBoundingClientRect();
+ const mapRect = mapImage.getClientRect();
- function handleZoom(event) {
- // Stop overscroll on chrome and safari
- // also stop pinch to zoom on chrome
- event.preventDefault();
+ const offsetX = x - containerRect.left - mapRect.x;
+ const offsetY = y - containerRect.top - mapRect.y;
- // Try and normalize the wheel event to prevent OS differences for zoom speed
- const normalized = normalizeWheel(event);
+ const normalizedX = offsetX / mapRect.width;
+ const normalizedY = offsetY / mapRect.height;
- const scale = mapScaleRef.current;
- const translate = mapTranslateRef.current;
-
- const deltaY = normalized.pixelY * zoomSpeed;
- const newScale = Math.max(Math.min(scale + deltaY, maxZoom), minZoom);
-
- setTranslateAndScale(translate, newScale);
+ return { x: normalizedX, y: normalizedY };
}
+ }
- if (mapContainer) {
- mapContainer.addEventListener("wheel", handleZoom, {
- passive: false,
- });
- }
+ const pinchPreviousDistanceRef = useRef();
+ const pinchPreviousOriginRef = useRef();
+ const isInteractingCanvas = useRef(false);
- return () => {
- if (mapContainer) {
- mapContainer.removeEventListener("wheel", handleZoom);
+ const bind = useGesture({
+ onWheelStart: ({ event }) => {
+ isInteractingCanvas.current =
+ event.target === mapLayerRef.current.getCanvas()._canvas;
+ },
+ onWheel: ({ delta }) => {
+ if (preventMapInteraction || !isInteractingCanvas.current) {
+ return;
}
- };
- }, []);
+ const newScale = Math.min(
+ Math.max(stageScale + delta[1] * wheelZoomSpeed, minZoom),
+ maxZoom
+ );
+ setStageScale(newScale);
+ },
+ onPinch: ({ da, origin, first }) => {
+ const [distance] = da;
+ const [originX, originY] = origin;
+ if (first) {
+ pinchPreviousDistanceRef.current = distance;
+ pinchPreviousOriginRef.current = { x: originX, y: originY };
+ }
+
+ // Apply scale
+ const distanceDelta = distance - pinchPreviousDistanceRef.current;
+ const originXDelta = originX - pinchPreviousOriginRef.current.x;
+ const originYDelta = originY - pinchPreviousOriginRef.current.y;
+ const newScale = Math.min(
+ Math.max(stageScale + distanceDelta * touchZoomSpeed, minZoom),
+ maxZoom
+ );
+ setStageScale(newScale);
+
+ // Apply translate
+ const stageTranslate = stageTranslateRef.current;
+ const layer = mapLayerRef.current;
+ const newTranslate = {
+ x: stageTranslate.x + originXDelta / newScale,
+ y: stageTranslate.y + originYDelta / newScale,
+ };
+ layer.x(newTranslate.x);
+ layer.y(newTranslate.y);
+ layer.draw();
+ stageTranslateRef.current = newTranslate;
+
+ pinchPreviousDistanceRef.current = distance;
+ pinchPreviousOriginRef.current = { x: originX, y: originY };
+ },
+ onDragStart: ({ event }) => {
+ isInteractingCanvas.current =
+ event.target === mapLayerRef.current.getCanvas()._canvas;
+ },
+ onDrag: ({ delta, xy, first, last, pinching }) => {
+ if (preventMapInteraction || pinching || !isInteractingCanvas.current) {
+ return;
+ }
+
+ const [dx, dy] = delta;
+ const stageTranslate = stageTranslateRef.current;
+ const layer = mapLayerRef.current;
+ if (selectedToolId === "pan") {
+ const newTranslate = {
+ x: stageTranslate.x + dx / stageScale,
+ y: stageTranslate.y + dy / stageScale,
+ };
+ layer.x(newTranslate.x);
+ layer.y(newTranslate.y);
+ layer.draw();
+ stageTranslateRef.current = newTranslate;
+ }
+ mapDragPositionRef.current = getMapDragPosition(xy);
+ const newDragState = first ? "first" : last ? "last" : "dragging";
+ if (stageDragState !== newDragState) {
+ setStageDragState(newDragState);
+ }
+ },
+ onDragEnd: () => {
+ setStageDragState("none");
+ },
+ });
+
+ function handleResize(width, height) {
+ setStageWidth(width);
+ setStageHeight(height);
+ stageWidthRef.current = width;
+ stageHeightRef.current = height;
+ }
+
+ function getCursorForTool(tool) {
+ switch (tool) {
+ case "pan":
+ return "move";
+ case "fog":
+ case "brush":
+ case "shape":
+ return "crosshair";
+ default:
+ return "default";
+ }
+ }
+
+ const containerRef = useRef();
+ usePreventOverscroll(containerRef);
+
+ const mapWidth = stageWidth;
+ const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
+
+ const mapStageRef = useContext(MapStageContext);
+ const mapLayerRef = useRef();
+ const mapImageRef = useRef();
+
+ const auth = useContext(AuthContext);
+
+ const mapInteraction = {
+ stageScale,
+ stageWidth,
+ stageHeight,
+ stageDragState,
+ setPreventMapInteraction,
+ mapWidth,
+ mapHeight,
+ mapDragPositionRef,
+ };
return (
-
-
-
-
- {children}
-
-
-
- {controls}
- {loading && }
+
+
+
+
+ {/* Forward auth context to konva elements */}
+
+
+ {children}
+
+
+
+
+
+
+ {controls}
+
);
}
diff --git a/src/components/map/MapMenu.js b/src/components/map/MapMenu.js
index 6ddfd75..2c79ddb 100644
--- a/src/components/map/MapMenu.js
+++ b/src/components/map/MapMenu.js
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from "react";
import Modal from "react-modal";
-
import { useThemeUI } from "theme-ui";
function MapMenu({
@@ -45,6 +44,7 @@ function MapMenu({
{ once: true }
);
}
+
return () => {
if (modalContentNode) {
document.body.removeEventListener("pointerdown", handlePointerDown);
diff --git a/src/components/map/MapSettings.js b/src/components/map/MapSettings.js
index 230d327..dcefccb 100644
--- a/src/components/map/MapSettings.js
+++ b/src/components/map/MapSettings.js
@@ -34,7 +34,7 @@ function MapSettings({
onChange={(e) =>
onSettingsChange("gridX", parseInt(e.target.value))
}
- disabled={map === null || map.type === "default"}
+ disabled={!map || map.type === "default"}
min={1}
my={1}
/>
@@ -48,7 +48,7 @@ function MapSettings({
onChange={(e) =>
onSettingsChange("gridY", parseInt(e.target.value))
}
- disabled={map === null || map.type === "default"}
+ disabled={!map || map.type === "default"}
min={1}
my={1}
/>
@@ -61,19 +61,15 @@ function MapSettings({
diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js
index 6d8309a..9f67bba 100644
--- a/src/components/map/MapToken.js
+++ b/src/components/map/MapToken.js
@@ -1,69 +1,230 @@
-import React, { useRef } from "react";
-import { Box, Image } from "theme-ui";
+import React, { useContext, useState, useEffect, useRef } from "react";
+import { Image as KonvaImage, Group } from "react-konva";
+import { useSpring, animated } from "react-spring/konva";
+import useImage from "use-image";
-import TokenLabel from "../token/TokenLabel";
-import TokenStatus from "../token/TokenStatus";
-
-import usePreventTouch from "../../helpers/usePreventTouch";
import useDataSource from "../../helpers/useDataSource";
+import useDebounce from "../../helpers/useDebounce";
+import usePrevious from "../../helpers/usePrevious";
-import { tokenSources } from "../../tokens";
+import AuthContext from "../../contexts/AuthContext";
+import MapInteractionContext from "../../contexts/MapInteractionContext";
-function MapToken({ token, tokenState, tokenSizePercent, className }) {
- const imageSource = useDataSource(token, tokenSources);
+import TokenStatus from "../token/TokenStatus";
+import TokenLabel from "../token/TokenLabel";
+import { tokenSources, unknownSource } from "../../tokens";
+
+function MapToken({
+ token,
+ tokenState,
+ tokenSizePercent,
+ onTokenStateChange,
+ onTokenMenuOpen,
+ onTokenDragStart,
+ onTokenDragEnd,
+ draggable,
+ mapState,
+}) {
+ const { userId } = useContext(AuthContext);
+ const {
+ setPreventMapInteraction,
+ mapWidth,
+ mapHeight,
+ stageScale,
+ } = useContext(MapInteractionContext);
+
+ const tokenSource = useDataSource(token, tokenSources, unknownSource);
+ const [tokenSourceImage, tokenSourceStatus] = useImage(tokenSource);
+ const [tokenAspectRatio, setTokenAspectRatio] = useState(1);
+
+ useEffect(() => {
+ if (tokenSourceImage) {
+ setTokenAspectRatio(tokenSourceImage.width / tokenSourceImage.height);
+ }
+ }, [tokenSourceImage]);
+
+ function handleDragStart(event) {
+ const tokenImage = event.target;
+ const tokenImageRect = tokenImage.getClientRect();
+
+ if (token.isVehicle) {
+ // Find all other tokens on the map
+ const layer = tokenImage.getLayer();
+ const tokens = layer.find(".token");
+ for (let other of tokens) {
+ if (other === tokenImage) {
+ continue;
+ }
+ const otherRect = other.getClientRect();
+ const otherCenter = {
+ x: otherRect.x + otherRect.width / 2,
+ y: otherRect.y + otherRect.height / 2,
+ };
+ // Check the other tokens center overlaps this tokens bounding box
+ if (
+ otherCenter.x > tokenImageRect.x &&
+ otherCenter.x < tokenImageRect.x + tokenImageRect.width &&
+ otherCenter.y > tokenImageRect.y &&
+ otherCenter.y < tokenImageRect.y + tokenImageRect.height
+ ) {
+ // Save and restore token position after moving layer
+ const position = other.absolutePosition();
+ other.moveTo(tokenImage);
+ other.absolutePosition(position);
+ }
+ }
+ }
+
+ onTokenDragStart(event);
+ }
+
+ function handleDragEnd(event) {
+ const tokenImage = event.target;
+
+ const mountChanges = {};
+ if (token.isVehicle) {
+ const layer = tokenImage.getLayer();
+ const mountedTokens = tokenImage.find(".token");
+ for (let mountedToken of mountedTokens) {
+ // Save and restore token position after moving layer
+ const position = mountedToken.absolutePosition();
+ mountedToken.moveTo(layer);
+ mountedToken.absolutePosition(position);
+ mountChanges[mountedToken.id()] = {
+ ...mapState.tokens[mountedToken.id()],
+ x: mountedToken.x() / mapWidth,
+ y: mountedToken.y() / mapHeight,
+ lastEditedBy: userId,
+ };
+ }
+ }
+
+ setPreventMapInteraction(false);
+ onTokenStateChange({
+ ...mountChanges,
+ [tokenState.id]: {
+ ...tokenState,
+ x: tokenImage.x() / mapWidth,
+ y: tokenImage.y() / mapHeight,
+ lastEditedBy: userId,
+ },
+ });
+ onTokenDragEnd(event);
+ }
+
+ function handleClick(event) {
+ if (draggable) {
+ const tokenImage = event.target;
+ onTokenMenuOpen(tokenState.id, tokenImage);
+ }
+ }
+
+ const [tokenOpacity, setTokenOpacity] = useState(1);
+ function handlePointerDown() {
+ if (draggable) {
+ setPreventMapInteraction(true);
+ }
+ }
+
+ function handlePointerUp() {
+ if (draggable) {
+ setPreventMapInteraction(false);
+ }
+ }
+
+ function handlePointerOver() {
+ if (!draggable) {
+ setTokenOpacity(0.5);
+ }
+ }
+
+ function handlePointerOut() {
+ if (!draggable) {
+ setTokenOpacity(1.0);
+ }
+ }
+
+ const tokenWidth = tokenSizePercent * mapWidth * tokenState.size;
+ const tokenHeight =
+ tokenSizePercent * (mapWidth / tokenAspectRatio) * tokenState.size;
+
+ const debouncedStageScale = useDebounce(stageScale, 50);
const imageRef = useRef();
- // Stop touch to prevent 3d touch gesutre on iOS
- usePreventTouch(imageRef);
+ useEffect(() => {
+ const image = imageRef.current;
+ if (
+ image &&
+ tokenSourceStatus === "loaded" &&
+ tokenWidth > 0 &&
+ tokenHeight > 0
+ ) {
+ image.cache({
+ pixelRatio: debouncedStageScale * window.devicePixelRatio,
+ });
+ image.drawHitFromCache();
+ // Force redraw
+ image.getLayer().draw();
+ }
+ }, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus]);
+
+ // Animate to new token positions if edited by others
+ const tokenX = tokenState.x * mapWidth;
+ const tokenY = tokenState.y * mapHeight;
+ const previousWidth = usePrevious(mapWidth);
+ const previousHeight = usePrevious(mapHeight);
+ const resized = mapWidth !== previousWidth || mapHeight !== previousHeight;
+ const skipAnimation = tokenState.lastEditedBy === userId || resized;
+ const props = useSpring({
+ x: tokenX,
+ y: tokenY,
+ immediate: skipAnimation,
+ });
return (
-
-
-
-
- {tokenState.statuses && (
-
- )}
- {tokenState.label && }
-
-
-
+
+
+
+
+
+
);
}
diff --git a/src/components/map/SelectMapButton.js b/src/components/map/SelectMapButton.js
index 2e9dc87..fb8c81f 100644
--- a/src/components/map/SelectMapButton.js
+++ b/src/components/map/SelectMapButton.js
@@ -1,16 +1,26 @@
-import React, { useState } from "react";
+import React, { useState, useContext } from "react";
import { IconButton } from "theme-ui";
import SelectMapModal from "../../modals/SelectMapModal";
import SelectMapIcon from "../../icons/SelectMapIcon";
-function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) {
- const [isAddModalOpen, setIsAddModalOpen] = useState(false);
+import MapDataContext from "../../contexts/MapDataContext";
+
+function SelectMapButton({
+ onMapChange,
+ onMapStateChange,
+ currentMap,
+ currentMapState,
+}) {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const { updateMapState } = useContext(MapDataContext);
function openModal() {
- setIsAddModalOpen(true);
+ currentMapState && updateMapState(currentMapState.mapId, currentMapState);
+ setIsModalOpen(true);
}
function closeModal() {
- setIsAddModalOpen(false);
+ setIsModalOpen(false);
}
function handleDone() {
@@ -27,7 +37,7 @@ function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) {
diff --git a/src/components/token/ProxyToken.js b/src/components/token/ProxyToken.js
index 0ed984d..d9419bf 100644
--- a/src/components/token/ProxyToken.js
+++ b/src/components/token/ProxyToken.js
@@ -1,12 +1,11 @@
-import React, { useEffect, useRef, useState } from "react";
+import React, { useEffect, useRef, useState, useContext } from "react";
import ReactDOM from "react-dom";
import { Image, Box } from "theme-ui";
import interact from "interactjs";
import usePortal from "../../helpers/usePortal";
-import TokenLabel from "./TokenLabel";
-import TokenStatus from "./TokenStatus";
+import MapStageContext from "../../contexts/MapStageContext";
/**
* @callback onProxyDragEnd
@@ -19,46 +18,33 @@ import TokenStatus from "./TokenStatus";
* @param {string} tokenClassName The class name to attach the interactjs handler to
* @param {onProxyDragEnd} onProxyDragEnd Called when the proxy token is dropped
* @param {Object} tokens An optional mapping of tokens to use as a base when calling OnProxyDragEnd
- * @param {Object} disabledTokens An optional mapping of tokens that shouldn't allow movement
*/
-function ProxyToken({
- tokenClassName,
- onProxyDragEnd,
- tokens,
- disabledTokens,
-}) {
+function ProxyToken({ tokenClassName, onProxyDragEnd, tokens }) {
const proxyContainer = usePortal("root");
const [imageSource, setImageSource] = useState("");
- const [tokenId, setTokenId] = useState(null);
const proxyRef = useRef();
// Store the tokens in a ref and access in the interactjs loop
// This is needed to stop interactjs from creating multiple listeners
const tokensRef = useRef(tokens);
- const disabledTokensRef = useRef(disabledTokens);
useEffect(() => {
tokensRef.current = tokens;
- disabledTokensRef.current = disabledTokens;
- }, [tokens, disabledTokens]);
+ }, [tokens]);
const proxyOnMap = useRef(false);
+ const mapStageRef = useContext(MapStageContext);
useEffect(() => {
interact(`.${tokenClassName}`).draggable({
listeners: {
start: (event) => {
let target = event.target;
- const id = target.dataset.id;
- if (id in disabledTokensRef.current) {
- return;
- }
// Hide the token and copy it's image to the proxy
target.parentElement.style.opacity = "0.25";
setImageSource(target.src);
- setTokenId(id);
let proxy = proxyRef.current;
if (proxy) {
@@ -105,23 +91,29 @@ function ProxyToken({
end: (event) => {
let target = event.target;
const id = target.dataset.id;
- if (id in disabledTokensRef.current) {
- return;
- }
let proxy = proxyRef.current;
if (proxy) {
- const mapImage = document.querySelector(".mapImage");
- if (onProxyDragEnd && mapImage) {
- const mapImageRect = mapImage.getBoundingClientRect();
+ const mapStage = mapStageRef.current;
+ if (onProxyDragEnd && mapStage) {
+ const mapImageRect = mapStage
+ .findOne("#mapImage")
+ .getClientRect();
+
+ const map = document.querySelector(".map");
+ const mapRect = map.getBoundingClientRect();
let x = parseFloat(proxy.getAttribute("data-x")) || 0;
let y = parseFloat(proxy.getAttribute("data-y")) || 0;
+
+ // TODO: This seems to be wrong when map is zoomed
+
// Convert coordiantes to be relative to the map
- x = x - mapImageRect.left;
- y = y - mapImageRect.top;
+ x = x - mapRect.left - mapImageRect.x;
+ y = y - mapRect.top - mapImageRect.y;
+
// Normalize to map width
- x = x / (mapImageRect.right - mapImageRect.left);
- y = y / (mapImageRect.bottom - mapImageRect.top);
+ x = x / mapImageRect.width;
+ y = y / mapImageRect.height;
// Get the token from the supplied tokens if it exists
const token = tokensRef.current[id] || {};
@@ -145,7 +137,7 @@ function ProxyToken({
},
},
});
- }, [onProxyDragEnd, tokenClassName, proxyContainer]);
+ }, [onProxyDragEnd, tokenClassName, proxyContainer, mapStageRef]);
if (!imageSource) {
return null;
@@ -175,12 +167,6 @@ function ProxyToken({
width: "100%",
}}
/>
- {tokens[tokenId] && tokens[tokenId].statuses && (
-
- )}
- {tokens[tokenId] && tokens[tokenId].label && (
-
- )}
,
proxyContainer
@@ -189,7 +175,6 @@ function ProxyToken({
ProxyToken.defaultProps = {
tokens: {},
- disabledTokens: {},
};
export default ProxyToken;
diff --git a/src/components/token/SelectTokensButton.js b/src/components/token/SelectTokensButton.js
new file mode 100644
index 0000000..2792b02
--- /dev/null
+++ b/src/components/token/SelectTokensButton.js
@@ -0,0 +1,38 @@
+import React, { useState } from "react";
+import { IconButton } from "theme-ui";
+
+import SelectTokensIcon from "../../icons/SelectTokensIcon";
+
+import SelectTokensModal from "../../modals/SelectTokensModal";
+
+function SelectTokensButton() {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ function openModal() {
+ setIsModalOpen(true);
+ }
+ function closeModal() {
+ setIsModalOpen(false);
+ }
+
+ function handleDone() {
+ closeModal();
+ }
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+export default SelectTokensButton;
diff --git a/src/components/token/TokenDragOverlay.js b/src/components/token/TokenDragOverlay.js
new file mode 100644
index 0000000..70097bc
--- /dev/null
+++ b/src/components/token/TokenDragOverlay.js
@@ -0,0 +1,133 @@
+import React, { useContext, useEffect, useRef, useState } from "react";
+import { Box, IconButton } from "theme-ui";
+
+import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
+
+import AuthContext from "../../contexts/AuthContext";
+import MapInteractionContext from "../../contexts/MapInteractionContext";
+
+function TokenDragOverlay({
+ onTokenStateRemove,
+ onTokenStateChange,
+ token,
+ tokenState,
+ tokenImage,
+ mapState,
+}) {
+ const { userId } = useContext(AuthContext);
+ const { setPreventMapInteraction, mapWidth, mapHeight } = useContext(
+ MapInteractionContext
+ );
+
+ const [isRemoveHovered, setIsRemoveHovered] = useState(false);
+ const removeTokenRef = useRef();
+
+ // Detect token hover on remove icon manually to support touch devices
+ useEffect(() => {
+ const map = document.querySelector(".map");
+ const mapRect = map.getBoundingClientRect();
+
+ function detectRemoveHover() {
+ const pointerPosition = tokenImage.getStage().getPointerPosition();
+ const screenSpacePointerPosition = {
+ x: pointerPosition.x + mapRect.left,
+ y: pointerPosition.y + mapRect.top,
+ };
+ const removeIconPosition = removeTokenRef.current.getBoundingClientRect();
+
+ if (
+ screenSpacePointerPosition.x > removeIconPosition.left &&
+ screenSpacePointerPosition.y > removeIconPosition.top &&
+ screenSpacePointerPosition.x < removeIconPosition.right &&
+ screenSpacePointerPosition.y < removeIconPosition.bottom
+ ) {
+ if (!isRemoveHovered) {
+ setIsRemoveHovered(true);
+ }
+ } else if (isRemoveHovered) {
+ setIsRemoveHovered(false);
+ }
+ }
+
+ let handler;
+ if (tokenState && tokenImage) {
+ handler = setInterval(detectRemoveHover, 100);
+ }
+
+ return () => {
+ if (handler) {
+ clearInterval(handler);
+ }
+ };
+ }, [tokenState, tokenImage, isRemoveHovered]);
+
+ // Detect drag end of token image and remove it if it is over the remove icon
+ useEffect(() => {
+ function handleTokenDragEnd() {
+ if (isRemoveHovered) {
+ // Handle other tokens when a vehicle gets deleted
+ if (token.isVehicle) {
+ const layer = tokenImage.getLayer();
+ const mountedTokens = tokenImage.find(".token");
+ for (let mountedToken of mountedTokens) {
+ // Save and restore token position after moving layer
+ const position = mountedToken.absolutePosition();
+ mountedToken.moveTo(layer);
+ mountedToken.absolutePosition(position);
+ onTokenStateChange({
+ [mountedToken.id()]: {
+ ...mapState.tokens[mountedToken.id()],
+ x: mountedToken.x() / mapWidth,
+ y: mountedToken.y() / mapHeight,
+ lastEditedBy: userId,
+ },
+ });
+ }
+ }
+ onTokenStateRemove(tokenState);
+ setPreventMapInteraction(false);
+ }
+ }
+ tokenImage.on("dragend", handleTokenDragEnd);
+ return () => {
+ tokenImage.off("dragend", handleTokenDragEnd);
+ };
+ }, [
+ tokenImage,
+ token,
+ tokenState,
+ isRemoveHovered,
+ mapWidth,
+ mapHeight,
+ userId,
+ onTokenStateChange,
+ onTokenStateRemove,
+ setPreventMapInteraction,
+ mapState.tokens,
+ ]);
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default TokenDragOverlay;
diff --git a/src/components/token/TokenLabel.js b/src/components/token/TokenLabel.js
index b5d90fb..2befea8 100644
--- a/src/components/token/TokenLabel.js
+++ b/src/components/token/TokenLabel.js
@@ -1,51 +1,50 @@
-import React from "react";
-import { Image, Box, Text } from "theme-ui";
+import React, { useRef, useEffect, useState } from "react";
+import { Rect, Text, Group } from "react-konva";
-import tokenLabel from "../../images/TokenLabel.png";
+function TokenLabel({ tokenState, width, height }) {
+ const fontSize = height / 6 / tokenState.size;
+ const paddingY = height / 16 / tokenState.size;
+ const paddingX = height / 8 / tokenState.size;
+
+ const [rectWidth, setRectWidth] = useState(0);
+ useEffect(() => {
+ const text = textRef.current;
+ if (text && tokenState.label) {
+ setRectWidth(text.getTextWidth() + paddingX);
+ } else {
+ setRectWidth(0);
+ }
+ }, [tokenState.label, paddingX]);
+
+ const textRef = useRef();
-function TokenLabel({ label }) {
return (
-
-
-
-
+
+
+ {}}
+ />
+
);
}
diff --git a/src/components/token/TokenMenu.js b/src/components/token/TokenMenu.js
index d64063a..74ac4e2 100644
--- a/src/components/token/TokenMenu.js
+++ b/src/components/token/TokenMenu.js
@@ -1,119 +1,78 @@
-import React, { useEffect, useState, useRef } from "react";
-import interact from "interactjs";
-import { Box, Input } from "theme-ui";
+import React, { useEffect, useState } from "react";
+import { Box, Input, Slider, Flex, Text } from "theme-ui";
import MapMenu from "../map/MapMenu";
import colors, { colorOptions } from "../../helpers/colors";
-/**
- * @callback onTokenChange
- * @param {Object} token the token that was changed
- */
+import usePrevious from "../../helpers/usePrevious";
-/**
- *
- * @param {string} tokenClassName The class name to attach the interactjs handler to
- * @param {onProxyDragEnd} onTokenChange Called when the the token data is changed
- * @param {Object} tokens An mapping of tokens to use as a base when calling onTokenChange
- * @param {Object} disabledTokens An optional mapping of tokens that shouldn't allow interaction
- */
-function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
- const [isOpen, setIsOpen] = useState(false);
+const defaultTokenMaxSize = 6;
+function TokenMenu({
+ isOpen,
+ onRequestClose,
+ tokenState,
+ tokenImage,
+ onTokenStateChange,
+}) {
+ const wasOpen = usePrevious(isOpen);
- function handleRequestClose() {
- setIsOpen(false);
- }
-
- // Store the tokens in a ref and access in the interactjs loop
- // This is needed to stop interactjs from creating multiple listeners
- const tokensRef = useRef(tokens);
- const disabledTokensRef = useRef(disabledTokens);
- useEffect(() => {
- tokensRef.current = tokens;
- disabledTokensRef.current = disabledTokens;
- }, [tokens, disabledTokens]);
-
- const [currentToken, setCurrentToken] = useState({});
+ const [tokenMaxSize, setTokenMaxSize] = useState(defaultTokenMaxSize);
const [menuLeft, setMenuLeft] = useState(0);
const [menuTop, setMenuTop] = useState(0);
+ useEffect(() => {
+ if (isOpen && !wasOpen && tokenState) {
+ setTokenMaxSize(Math.max(tokenState.size, defaultTokenMaxSize));
+ // Update menu position
+ if (tokenImage) {
+ const imageRect = tokenImage.getClientRect();
+ const map = document.querySelector(".map");
+ const mapRect = map.getBoundingClientRect();
+
+ // Center X for the menu which is 156px wide
+ setMenuLeft(mapRect.left + imageRect.x + imageRect.width / 2 - 156 / 2);
+ // Y 12px from the bottom
+ setMenuTop(mapRect.top + imageRect.y + imageRect.height + 12);
+ }
+ }
+ }, [isOpen, tokenState, wasOpen, tokenImage]);
function handleLabelChange(event) {
- // Slice to remove Label: text
- const label = event.target.value.slice(7);
- if (label.length <= 1) {
- setCurrentToken((prevToken) => ({
- ...prevToken,
- label: label,
- }));
-
- onTokenChange({ ...currentToken, label: label });
- }
+ const label = event.target.value;
+ onTokenStateChange({ [tokenState.id]: { ...tokenState, label: label } });
}
function handleStatusChange(status) {
- const statuses = currentToken.statuses;
+ const statuses = tokenState.statuses;
let newStatuses = [];
if (statuses.includes(status)) {
newStatuses = statuses.filter((s) => s !== status);
} else {
newStatuses = [...statuses, status];
}
- setCurrentToken((prevToken) => ({
- ...prevToken,
- statuses: newStatuses,
- }));
- onTokenChange({ ...currentToken, statuses: newStatuses });
+ onTokenStateChange({
+ [tokenState.id]: { ...tokenState, statuses: newStatuses },
+ });
}
- useEffect(() => {
- function handleTokenMenuOpen(event) {
- const target = event.target;
- const id = target.getAttribute("data-id");
- if (id in disabledTokensRef.current) {
- return;
- }
- const token = tokensRef.current[id] || {};
- setCurrentToken(token);
+ function handleSizeChange(event) {
+ const newSize = parseInt(event.target.value);
+ onTokenStateChange({ [tokenState.id]: { ...tokenState, size: newSize } });
+ }
- const targetRect = target.getBoundingClientRect();
- setMenuLeft(targetRect.left);
- setMenuTop(targetRect.bottom);
-
- setIsOpen(true);
- }
-
- // Add listener for tap gesture
- const tokenInteract = interact(`.${tokenClassName}`).on(
- "tap",
- handleTokenMenuOpen
- );
-
- function handleMapContextMenu(event) {
- event.preventDefault();
- if (event.target.classList.contains(tokenClassName)) {
- handleTokenMenuOpen(event);
- }
- }
-
- // Handle context menu on the map level as handling
- // on the token level lead to the default menu still
- // being displayed
- const map = document.querySelector(".map");
- map.addEventListener("contextmenu", handleMapContextMenu);
-
- return () => {
- map.removeEventListener("contextmenu", handleMapContextMenu);
- tokenInteract.unset();
- };
- }, [tokenClassName]);
+ function handleRotationChange(event) {
+ const newRotation = parseInt(event.target.value);
+ onTokenStateChange({
+ [tokenState.id]: { ...tokenState, rotation: newRotation },
+ });
+ }
function handleModalContent(node) {
if (node) {
// Focus input
const tokenLabelInput = node.querySelector("#changeTokenLabel");
tokenLabelInput.focus();
- tokenLabelInput.setSelectionRange(7, 8);
+ tokenLabelInput.select();
// Ensure menu is in bounds
const nodeRect = node.getBoundingClientRect();
@@ -134,23 +93,32 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
return (
-
-
+ {
e.preventDefault();
- handleRequestClose();
+ onRequestClose();
}}
+ sx={{ alignItems: "center" }}
>
+
+ Label:
+
-
+
handleStatusChange(color)}
aria-label={`Token label Color ${color}`}
>
- {currentToken.statuses && currentToken.statuses.includes(color) && (
-
- )}
+ {tokenState &&
+ tokenState.statuses &&
+ tokenState.statuses.includes(color) && (
+
+ )}
))}
+
+
+ Size:
+
+
+
+
+
+ Rotation:
+
+
+
);
diff --git a/src/components/token/TokenSettings.js b/src/components/token/TokenSettings.js
new file mode 100644
index 0000000..b01103a
--- /dev/null
+++ b/src/components/token/TokenSettings.js
@@ -0,0 +1,90 @@
+import React from "react";
+import { Flex, Box, Input, IconButton, Label, Checkbox } from "theme-ui";
+
+import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
+
+function TokenSettings({
+ token,
+ onSettingsChange,
+ showMore,
+ onShowMoreChange,
+}) {
+ return (
+
+
+
+
+
+ onSettingsChange("defaultSize", parseInt(e.target.value))
+ }
+ disabled={!token || token.type === "default"}
+ min={1}
+ my={1}
+ />
+
+
+ {showMore && (
+ <>
+
+
+ onSettingsChange("name", e.target.value)}
+ disabled={!token || token.type === "default"}
+ my={1}
+ />
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ onShowMoreChange(!showMore);
+ }}
+ sx={{
+ transform: `rotate(${showMore ? "180deg" : "0"})`,
+ alignSelf: "center",
+ }}
+ aria-label={showMore ? "Show Less" : "Show More"}
+ title={showMore ? "Show Less" : "Show More"}
+ disabled={!token}
+ >
+
+
+
+ );
+}
+
+export default TokenSettings;
diff --git a/src/components/token/TokenStatus.js b/src/components/token/TokenStatus.js
index 5621589..fdea596 100644
--- a/src/components/token/TokenStatus.js
+++ b/src/components/token/TokenStatus.js
@@ -1,46 +1,25 @@
import React from "react";
-import { Box } from "theme-ui";
+import { Circle, Group } from "react-konva";
import colors from "../../helpers/colors";
-function TokenStatus({ statuses }) {
+function TokenStatus({ tokenState, width, height }) {
return (
-
- {statuses.map((status, index) => (
-
+ {tokenState.statuses.map((status, index) => (
+
-
-
+ width={width}
+ height={height}
+ stroke={colors[status]}
+ strokeWidth={width / 20 / tokenState.size}
+ scaleX={1 - index / 10 / tokenState.size}
+ scaleY={1 - index / 10 / tokenState.size}
+ opacity={0.8}
+ fillEnabled={false}
+ />
))}
-
+
);
}
diff --git a/src/components/token/TokenTile.js b/src/components/token/TokenTile.js
new file mode 100644
index 0000000..35c8b33
--- /dev/null
+++ b/src/components/token/TokenTile.js
@@ -0,0 +1,81 @@
+import React from "react";
+import { Flex, Image, Text, Box, IconButton } from "theme-ui";
+
+import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
+
+import useDataSource from "../../helpers/useDataSource";
+import {
+ tokenSources as defaultTokenSources,
+ unknownSource,
+} from "../../tokens";
+
+function TokenTile({ token, isSelected, onTokenSelect, onTokenRemove }) {
+ const tokenSource = useDataSource(token, defaultTokenSources, unknownSource);
+ const isDefault = token.type === "default";
+
+ return (
+ onTokenSelect(token)}
+ sx={{
+ borderColor: "primary",
+ borderStyle: isSelected ? "solid" : "none",
+ borderWidth: "4px",
+ position: "relative",
+ width: "150px",
+ height: "150px",
+ borderRadius: "4px",
+ justifyContent: "center",
+ alignItems: "center",
+ cursor: "pointer",
+ }}
+ m={2}
+ bg="muted"
+ >
+
+
+
+ {token.name}
+
+
+ {isSelected && !isDefault && (
+
+ {
+ onTokenRemove(token.id);
+ }}
+ bg="overlay"
+ sx={{ borderRadius: "50%" }}
+ m={1}
+ >
+
+
+
+ )}
+
+ );
+}
+
+export default TokenTile;
diff --git a/src/components/token/TokenTiles.js b/src/components/token/TokenTiles.js
new file mode 100644
index 0000000..f9ae7e2
--- /dev/null
+++ b/src/components/token/TokenTiles.js
@@ -0,0 +1,67 @@
+import React from "react";
+import { Flex } from "theme-ui";
+import SimpleBar from "simplebar-react";
+
+import AddIcon from "../../icons/AddIcon";
+
+import TokenTile from "./TokenTile";
+
+function TokenTiles({
+ tokens,
+ onTokenAdd,
+ onTokenSelect,
+ selectedToken,
+ onTokenRemove,
+}) {
+ return (
+
+
+
+
+
+ {tokens.map((token) => (
+
+ ))}
+
+
+ );
+}
+
+export default TokenTiles;
diff --git a/src/components/token/Tokens.js b/src/components/token/Tokens.js
index 5fde471..6a43587 100644
--- a/src/components/token/Tokens.js
+++ b/src/components/token/Tokens.js
@@ -1,34 +1,38 @@
-import React, { useState, useContext } from "react";
-import { Box } from "theme-ui";
+import React, { useContext } from "react";
+import { Box, Flex } from "theme-ui";
import shortid from "shortid";
import SimpleBar from "simplebar-react";
import ListToken from "./ListToken";
import ProxyToken from "./ProxyToken";
-import NumberInput from "../NumberInput";
+
+import SelectTokensButton from "./SelectTokensButton";
import { fromEntries } from "../../helpers/shared";
import AuthContext from "../../contexts/AuthContext";
+import TokenDataContext from "../../contexts/TokenDataContext";
const listTokenClassName = "list-token";
-function Tokens({ onCreateMapTokenState, tokens }) {
- const [tokenSize, setTokenSize] = useState(1);
+function Tokens({ onMapTokenStateCreate }) {
const { userId } = useContext(AuthContext);
+ const { ownedTokens, tokens } = useContext(TokenDataContext);
function handleProxyDragEnd(isOnMap, token) {
- if (isOnMap && onCreateMapTokenState) {
+ if (isOnMap && onMapTokenStateCreate) {
// Create a token state from the dragged token
- onCreateMapTokenState({
+ onMapTokenStateCreate({
id: shortid.generate(),
tokenId: token.id,
owner: userId,
- size: tokenSize,
+ size: token.defaultSize,
label: "",
statuses: [],
x: token.x,
y: token.y,
+ lastEditedBy: userId,
+ rotation: 0,
});
}
}
@@ -43,24 +47,27 @@ function Tokens({ onCreateMapTokenState, tokens }) {
overflow: "hidden",
}}
>
-
- {tokens.map((token) => (
-
- ))}
+
+ {ownedTokens
+ .filter((token) => !token.hideInSidebar)
+ .map((token) => (
+
+ ))}
-
-
-
+
+
+
prevLoadingAssets + 1);
+ }
+
+ function assetLoadFinish() {
+ setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets - 1);
+ }
+
+ const isLoading = loadingAssetCount > 0;
+
+ const value = {
+ assetLoadStart,
+ assetLoadFinish,
+ isLoading,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default DiceLoadingContext;
diff --git a/src/contexts/MapDataContext.js b/src/contexts/MapDataContext.js
new file mode 100644
index 0000000..433eb9b
--- /dev/null
+++ b/src/contexts/MapDataContext.js
@@ -0,0 +1,166 @@
+import React, { useEffect, useState, useContext } from "react";
+
+import AuthContext from "./AuthContext";
+import DatabaseContext from "./DatabaseContext";
+
+import { maps as defaultMaps } from "../maps";
+
+const MapDataContext = React.createContext();
+
+const defaultMapState = {
+ tokens: {},
+ // An index into the draw actions array to which only actions before the
+ // index will be performed (used in undo and redo)
+ mapDrawActionIndex: -1,
+ mapDrawActions: [],
+ fogDrawActionIndex: -1,
+ fogDrawActions: [],
+ // Flags to determine what other people can edit
+ editFlags: ["drawing", "tokens"],
+};
+
+export function MapDataProvider({ children }) {
+ const { database } = useContext(DatabaseContext);
+ const { userId } = useContext(AuthContext);
+
+ const [maps, setMaps] = useState([]);
+ const [mapStates, setMapStates] = useState([]);
+ // Load maps from the database and ensure state is properly setup
+ useEffect(() => {
+ if (!userId || !database) {
+ return;
+ }
+ async function getDefaultMaps() {
+ const defaultMapsWithIds = [];
+ for (let i = 0; i < defaultMaps.length; i++) {
+ const defaultMap = defaultMaps[i];
+ const id = `__default-${defaultMap.name}`;
+ defaultMapsWithIds.push({
+ ...defaultMap,
+ id,
+ owner: userId,
+ // Emulate the time increasing to avoid sort errors
+ created: Date.now() + i,
+ lastModified: Date.now() + i,
+ gridType: "grid",
+ });
+ // Add a state for the map if there isn't one already
+ const state = await database.table("states").get(id);
+ if (!state) {
+ await database.table("states").add({ ...defaultMapState, mapId: id });
+ }
+ }
+ return defaultMapsWithIds;
+ }
+
+ async function loadMaps() {
+ let storedMaps = await database.table("maps").toArray();
+ const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
+ const defaultMapsWithIds = await getDefaultMaps();
+ const allMaps = [...sortedMaps, ...defaultMapsWithIds];
+ setMaps(allMaps);
+ const storedStates = await database.table("states").toArray();
+ setMapStates(storedStates);
+ }
+
+ loadMaps();
+ }, [userId, database]);
+
+ async function addMap(map) {
+ await database.table("maps").add(map);
+ const state = { ...defaultMapState, mapId: map.id };
+ await database.table("states").add(state);
+ setMaps((prevMaps) => [map, ...prevMaps]);
+ setMapStates((prevStates) => [state, ...prevStates]);
+ }
+
+ async function removeMap(id) {
+ await database.table("maps").delete(id);
+ await database.table("states").delete(id);
+ setMaps((prevMaps) => {
+ const filtered = prevMaps.filter((map) => map.id !== id);
+ return filtered;
+ });
+ setMapStates((prevMapsStates) => {
+ const filtered = prevMapsStates.filter((state) => state.mapId !== id);
+ return filtered;
+ });
+ }
+
+ async function resetMap(id) {
+ const state = { ...defaultMapState, mapId: id };
+ await database.table("states").put(state);
+ setMapStates((prevMapStates) => {
+ const newStates = [...prevMapStates];
+ const i = newStates.findIndex((state) => state.mapId === id);
+ if (i > -1) {
+ newStates[i] = state;
+ }
+ return newStates;
+ });
+ return state;
+ }
+
+ async function updateMap(id, update) {
+ const change = { ...update, lastModified: Date.now() };
+ await database.table("maps").update(id, change);
+ setMaps((prevMaps) => {
+ const newMaps = [...prevMaps];
+ const i = newMaps.findIndex((map) => map.id === id);
+ if (i > -1) {
+ newMaps[i] = { ...newMaps[i], ...change };
+ }
+ return newMaps;
+ });
+ }
+
+ async function updateMapState(id, update) {
+ await database.table("states").update(id, update);
+ setMapStates((prevMapStates) => {
+ const newStates = [...prevMapStates];
+ const i = newStates.findIndex((state) => state.mapId === id);
+ if (i > -1) {
+ newStates[i] = { ...newStates[i], ...update };
+ }
+ return newStates;
+ });
+ }
+
+ async function putMap(map) {
+ await database.table("maps").put(map);
+ setMaps((prevMaps) => {
+ const newMaps = [...prevMaps];
+ const i = newMaps.findIndex((m) => m.id === map.id);
+ if (i > -1) {
+ newMaps[i] = { ...newMaps[i], ...map };
+ } else {
+ newMaps.unshift(map);
+ }
+ return newMaps;
+ });
+ }
+
+ function getMap(mapId) {
+ return maps.find((map) => map.id === mapId);
+ }
+
+ const ownedMaps = maps.filter((map) => map.owner === userId);
+
+ const value = {
+ maps,
+ ownedMaps,
+ mapStates,
+ addMap,
+ removeMap,
+ resetMap,
+ updateMap,
+ updateMapState,
+ putMap,
+ getMap,
+ };
+ return (
+ {children}
+ );
+}
+
+export default MapDataContext;
diff --git a/src/contexts/MapInteractionContext.js b/src/contexts/MapInteractionContext.js
index 504c31e..6b11577 100644
--- a/src/contexts/MapInteractionContext.js
+++ b/src/contexts/MapInteractionContext.js
@@ -1,8 +1,14 @@
import React from "react";
const MapInteractionContext = React.createContext({
- translateRef: null,
- scaleRef: null,
+ stageScale: 1,
+ stageWidth: 1,
+ stageHeight: 1,
+ stageDragState: "none",
+ setPreventMapInteraction: () => {},
+ mapWidth: 1,
+ mapHeight: 1,
+ mapDragPositionRef: { current: undefined },
});
export const MapInteractionProvider = MapInteractionContext.Provider;
diff --git a/src/contexts/MapLoadingContext.js b/src/contexts/MapLoadingContext.js
new file mode 100644
index 0000000..13ec1d7
--- /dev/null
+++ b/src/contexts/MapLoadingContext.js
@@ -0,0 +1,31 @@
+import React, { useState } from "react";
+
+const MapLoadingContext = React.createContext();
+
+export function MapLoadingProvider({ children }) {
+ const [loadingAssetCount, setLoadingAssetCount] = useState(0);
+
+ function assetLoadStart() {
+ setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets + 1);
+ }
+
+ function assetLoadFinish() {
+ setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets - 1);
+ }
+
+ const isLoading = loadingAssetCount > 0;
+
+ const value = {
+ assetLoadStart,
+ assetLoadFinish,
+ isLoading,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default MapLoadingContext;
diff --git a/src/contexts/MapStageContext.js b/src/contexts/MapStageContext.js
new file mode 100644
index 0000000..e8f61d8
--- /dev/null
+++ b/src/contexts/MapStageContext.js
@@ -0,0 +1,8 @@
+import React from "react";
+
+const MapStageContext = React.createContext({
+ mapStageRef: { current: null },
+});
+export const MapStageProvider = MapStageContext.Provider;
+
+export default MapStageContext;
diff --git a/src/contexts/TokenDataContext.js b/src/contexts/TokenDataContext.js
new file mode 100644
index 0000000..14cd6c0
--- /dev/null
+++ b/src/contexts/TokenDataContext.js
@@ -0,0 +1,112 @@
+import React, { useEffect, useState, useContext } from "react";
+
+import AuthContext from "./AuthContext";
+import DatabaseContext from "./DatabaseContext";
+
+import { tokens as defaultTokens } from "../tokens";
+
+const TokenDataContext = React.createContext();
+
+export function TokenDataProvider({ children }) {
+ const { database } = useContext(DatabaseContext);
+ const { userId } = useContext(AuthContext);
+
+ const [tokens, setTokens] = useState([]);
+
+ useEffect(() => {
+ if (!userId || !database) {
+ return;
+ }
+ function getDefaultTokes() {
+ const defaultTokensWithIds = [];
+ for (let defaultToken of defaultTokens) {
+ defaultTokensWithIds.push({
+ ...defaultToken,
+ id: `__default-${defaultToken.name}`,
+ owner: userId,
+ });
+ }
+ return defaultTokensWithIds;
+ }
+
+ async function loadTokens() {
+ let storedTokens = await database.table("tokens").toArray();
+ const sortedTokens = storedTokens.sort((a, b) => b.created - a.created);
+ const defaultTokensWithIds = getDefaultTokes();
+ const allTokens = [...sortedTokens, ...defaultTokensWithIds];
+ setTokens(allTokens);
+ }
+
+ loadTokens();
+ }, [userId, database]);
+
+ async function addToken(token) {
+ await database.table("tokens").add(token);
+ setTokens((prevTokens) => [token, ...prevTokens]);
+ }
+
+ async function removeToken(id) {
+ await database.table("tokens").delete(id);
+ setTokens((prevTokens) => {
+ const filtered = prevTokens.filter((token) => token.id !== id);
+ return filtered;
+ });
+ }
+
+ async function updateToken(id, update) {
+ const change = { ...update, lastModified: Date.now() };
+ await database.table("tokens").update(id, change);
+ setTokens((prevTokens) => {
+ const newTokens = [...prevTokens];
+ const i = newTokens.findIndex((token) => token.id === id);
+ if (i > -1) {
+ newTokens[i] = { ...newTokens[i], ...change };
+ }
+ return newTokens;
+ });
+ }
+
+ async function putToken(token) {
+ await database.table("tokens").put(token);
+ setTokens((prevTokens) => {
+ const newTokens = [...prevTokens];
+ const i = newTokens.findIndex((t) => t.id === token.id);
+ if (i > -1) {
+ newTokens[i] = { ...newTokens[i], ...token };
+ } else {
+ newTokens.unshift(token);
+ }
+ return newTokens;
+ });
+ }
+
+ function getToken(tokenId) {
+ return tokens.find((token) => token.id === tokenId);
+ }
+
+ const ownedTokens = tokens.filter((token) => token.owner === userId);
+
+ const tokensById = tokens.reduce((obj, token) => {
+ obj[token.id] = token;
+ return obj;
+ }, {});
+
+ const value = {
+ tokens,
+ ownedTokens,
+ addToken,
+ removeToken,
+ updateToken,
+ putToken,
+ getToken,
+ tokensById,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default TokenDataContext;
diff --git a/src/database.js b/src/database.js
index 71851cb..72b0480 100644
--- a/src/database.js
+++ b/src/database.js
@@ -26,6 +26,69 @@ function loadVersions(db) {
map.file = mapBuffers[map.id];
});
});
+ // v1.3.0 - Added new default tokens
+ db.version(3)
+ .stores({})
+ .upgrade((tx) => {
+ return tx
+ .table("states")
+ .toCollection()
+ .modify((state) => {
+ function mapTokenId(id) {
+ switch (id) {
+ case "__default-Axes":
+ return "__default-Barbarian";
+ case "__default-Bird":
+ return "__default-Druid";
+ case "__default-Book":
+ return "__default-Wizard";
+ case "__default-Crown":
+ return "__default-Humanoid";
+ case "__default-Dragon":
+ return "__default-Dragon";
+ case "__default-Eye":
+ return "__default-Warlock";
+ case "__default-Fist":
+ return "__default-Monk";
+ case "__default-Horse":
+ return "__default-Fey";
+ case "__default-Leaf":
+ return "__default-Druid";
+ case "__default-Lion":
+ return "__default-Monstrosity";
+ case "__default-Money":
+ return "__default-Humanoid";
+ case "__default-Moon":
+ return "__default-Cleric";
+ case "__default-Potion":
+ return "__default-Sorcerer";
+ case "__default-Shield":
+ return "__default-Paladin";
+ case "__default-Skull":
+ return "__default-Undead";
+ case "__default-Snake":
+ return "__default-Beast";
+ case "__default-Sun":
+ return "__default-Cleric";
+ case "__default-Swords":
+ return "__default-Fighter";
+ case "__default-Tree":
+ return "__default-Plant";
+ case "__default-Triangle":
+ return "__default-Sorcerer";
+ default:
+ return "__default-Fighter";
+ }
+ }
+ for (let stateId in state.tokens) {
+ state.tokens[stateId].tokenId = mapTokenId(
+ state.tokens[stateId].tokenId
+ );
+ state.tokens[stateId].lastEditedBy = "";
+ state.tokens[stateId].rotation = 0;
+ }
+ });
+ });
}
// Get the dexie database used in DatabaseContext
diff --git a/src/dice/Dice.js b/src/dice/Dice.js
new file mode 100644
index 0000000..e40e352
--- /dev/null
+++ b/src/dice/Dice.js
@@ -0,0 +1,154 @@
+import * as BABYLON from "babylonjs";
+
+import d4Source from "./shared/d4.glb";
+import d6Source from "./shared/d6.glb";
+import d8Source from "./shared/d8.glb";
+import d10Source from "./shared/d10.glb";
+import d12Source from "./shared/d12.glb";
+import d20Source from "./shared/d20.glb";
+import d100Source from "./shared/d100.glb";
+
+import { lerp } from "../helpers/shared";
+import { importTextureAsync } from "../helpers/babylon";
+
+const minDiceRollSpeed = 600;
+const maxDiceRollSpeed = 800;
+
+class Dice {
+ static instanceCount = 0;
+
+ static async loadMeshes(material, scene, sourceOverrides) {
+ let meshes = {};
+ const addToMeshes = async (type, defaultSource) => {
+ let source = sourceOverrides ? sourceOverrides[type] : defaultSource;
+ const mesh = await this.loadMesh(source, material, scene);
+ meshes[type] = mesh;
+ };
+ await addToMeshes("d4", d4Source);
+ await addToMeshes("d6", d6Source);
+ await addToMeshes("d8", d8Source);
+ await addToMeshes("d10", d10Source);
+ await addToMeshes("d12", d12Source);
+ await addToMeshes("d20", d20Source);
+ await addToMeshes("d100", d100Source);
+ return meshes;
+ }
+
+ static async loadMesh(source, material, scene) {
+ let mesh = (
+ await BABYLON.SceneLoader.ImportMeshAsync("", source, "", scene)
+ ).meshes[1];
+ mesh.setParent(null);
+
+ mesh.material = material;
+
+ mesh.receiveShadows = true;
+ mesh.isVisible = false;
+ return mesh;
+ }
+
+ static async loadMaterial(materialName, textures, scene) {
+ let pbr = new BABYLON.PBRMaterial(materialName, scene);
+ pbr.albedoTexture = await importTextureAsync(textures.albedo);
+ pbr.normalTexture = await importTextureAsync(textures.normal);
+ pbr.metallicTexture = await importTextureAsync(textures.metalRoughness);
+ pbr.useRoughnessFromMetallicTextureAlpha = false;
+ pbr.useRoughnessFromMetallicTextureGreen = true;
+ pbr.useMetallnessFromMetallicTextureBlue = true;
+ return pbr;
+ }
+
+ static createInstanceFromMesh(mesh, name, physicalProperties, scene) {
+ let instance = mesh.createInstance(name);
+ instance.position = mesh.position;
+ for (let child of mesh.getChildTransformNodes()) {
+ const locator = child.clone();
+ locator.setAbsolutePosition(child.getAbsolutePosition());
+ locator.name = child.name;
+ instance.addChild(locator);
+ }
+
+ instance.physicsImpostor = new BABYLON.PhysicsImpostor(
+ instance,
+ BABYLON.PhysicsImpostor.ConvexHullImpostor,
+ physicalProperties,
+ scene
+ );
+
+ return instance;
+ }
+
+ static getDicePhysicalProperties(diceType) {
+ switch (diceType) {
+ case "d4":
+ return { mass: 4, friction: 4 };
+ case "d6":
+ return { mass: 6, friction: 4 };
+ case "d8":
+ return { mass: 6.2, friction: 4 };
+ case "d10":
+ case "d100":
+ return { mass: 7, friction: 4 };
+ case "d12":
+ return { mass: 8, friction: 4 };
+ case "20":
+ return { mass: 10, friction: 4 };
+ default:
+ return { mass: 10, friction: 4 };
+ }
+ }
+
+ static roll(instance) {
+ instance.physicsImpostor.setLinearVelocity(BABYLON.Vector3.Zero());
+ instance.physicsImpostor.setAngularVelocity(BABYLON.Vector3.Zero());
+
+ const scene = instance.getScene();
+ const diceTraySingle = scene.getNodeByID("dice_tray_single");
+ const diceTrayDouble = scene.getNodeByID("dice_tray_double");
+ const visibleDiceTray = diceTraySingle.isVisible
+ ? diceTraySingle
+ : diceTrayDouble;
+ const trayBounds = visibleDiceTray.getBoundingInfo().boundingBox;
+
+ const position = new BABYLON.Vector3(
+ trayBounds.center.x + (Math.random() * 2 - 1),
+ 8,
+ trayBounds.center.z + (Math.random() * 2 - 1)
+ );
+ instance.position = position;
+ instance.addRotation(
+ Math.random() * Math.PI * 2,
+ Math.random() * Math.PI * 2,
+ Math.random() * Math.PI * 2
+ );
+
+ const throwTarget = new BABYLON.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)
+ .subtract(throwTarget)
+ .normalizeToNew()
+ .scale(lerp(minDiceRollSpeed, maxDiceRollSpeed, Math.random()));
+
+ instance.physicsImpostor.applyImpulse(
+ impulse,
+ instance.physicsImpostor.getObjectCenter()
+ );
+ }
+
+ static createInstance(mesh, physicalProperties, scene) {
+ this.instanceCount++;
+
+ return this.createInstanceFromMesh(
+ mesh,
+ `dice_instance_${this.instanceCount}`,
+ physicalProperties,
+ scene
+ );
+ }
+}
+
+export default Dice;
diff --git a/src/dice/diceTray/DiceTray.js b/src/dice/diceTray/DiceTray.js
new file mode 100644
index 0000000..0e32e8b
--- /dev/null
+++ b/src/dice/diceTray/DiceTray.js
@@ -0,0 +1,175 @@
+import * as BABYLON from "babylonjs";
+
+import singleMeshSource from "./single.glb";
+import doubleMeshSource from "./double.glb";
+
+import singleAlbedo from "./singleAlbedo.jpg";
+import singleMetalRoughness from "./singleMetalRoughness.jpg";
+import singleNormal from "./singleNormal.jpg";
+
+import doubleAlbedo from "./doubleAlbedo.jpg";
+import doubleMetalRoughness from "./doubleMetalRoughness.jpg";
+import doubleNormal from "./doubleNormal.jpg";
+
+import { importTextureAsync } from "../../helpers/babylon";
+
+class DiceTray {
+ _size;
+ get size() {
+ return this._size;
+ }
+ set size(newSize) {
+ this._size = newSize;
+ const wallOffsetWidth = this.collisionSize / 2 + this.width / 2 - 0.5;
+ const wallOffsetHeight = this.collisionSize / 2 + this.height / 2 - 0.5;
+ this.wallTop.position.z = -wallOffsetHeight;
+ this.wallRight.position.x = -wallOffsetWidth;
+ this.wallBottom.position.z = wallOffsetHeight;
+ this.wallLeft.position.x = wallOffsetWidth;
+ this.singleMesh.isVisible = newSize === "single";
+ this.doubleMesh.isVisible = newSize === "double";
+ }
+ scene;
+ shadowGenerator;
+ get width() {
+ return this.size === "single" ? 10 : 20;
+ }
+ height = 20;
+ collisionSize = 50;
+ wallTop;
+ wallRight;
+ wallBottom;
+ wallLeft;
+ singleMesh;
+ doubleMesh;
+
+ constructor(initialSize, scene, shadowGenerator) {
+ this._size = initialSize;
+ this.scene = scene;
+ this.shadowGenerator = shadowGenerator;
+ }
+
+ async load() {
+ this.loadWalls();
+ await this.loadMeshes();
+ }
+
+ createCollision(name, x, y, z, friction) {
+ let collision = BABYLON.Mesh.CreateBox(
+ name,
+ this.collisionSize,
+ this.scene,
+ true,
+ BABYLON.Mesh.DOUBLESIDE
+ );
+ collision.position.x = x;
+ collision.position.y = y;
+ collision.position.z = z;
+ collision.physicsImpostor = new BABYLON.PhysicsImpostor(
+ collision,
+ BABYLON.PhysicsImpostor.BoxImpostor,
+ { mass: 0, friction: friction },
+ this.scene
+ );
+ collision.isVisible = false;
+
+ return collision;
+ }
+
+ loadWalls() {
+ const wallOffsetWidth = this.collisionSize / 2 + this.width / 2 - 0.5;
+ const wallOffsetHeight = this.collisionSize / 2 + this.height / 2 - 0.5;
+ this.wallTop = this.createCollision("wallTop", 0, 0, -wallOffsetHeight, 10);
+ this.wallRight = this.createCollision(
+ "wallRight",
+ -wallOffsetWidth,
+ 0,
+ 0,
+ 10
+ );
+ this.wallBottom = this.createCollision(
+ "wallBottom",
+ 0,
+ 0,
+ wallOffsetHeight,
+ 10
+ );
+ this.wallLeft = this.createCollision("wallLeft", wallOffsetWidth, 0, 0, 10);
+ const diceTrayGroundOffset = 0.32;
+ this.createCollision(
+ "ground",
+ 0,
+ -this.collisionSize / 2 + diceTrayGroundOffset,
+ 0,
+ 20
+ );
+ const diceTrayRoofOffset = 10;
+ this.createCollision(
+ "roof",
+ 0,
+ this.collisionSize / 2 + diceTrayRoofOffset,
+ 0,
+ 100
+ );
+ }
+
+ async loadMeshes() {
+ this.singleMesh = (
+ await BABYLON.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
+ );
+ singleMaterial.albedoTexture = await importTextureAsync(singleAlbedo);
+ singleMaterial.normalTexture = await importTextureAsync(singleNormal);
+ singleMaterial.metallicTexture = await importTextureAsync(
+ singleMetalRoughness
+ );
+ singleMaterial.useRoughnessFromMetallicTextureAlpha = false;
+ singleMaterial.useRoughnessFromMetallicTextureGreen = true;
+ singleMaterial.useMetallnessFromMetallicTextureBlue = true;
+ this.singleMesh.material = singleMaterial;
+
+ this.singleMesh.receiveShadows = true;
+ this.shadowGenerator.addShadowCaster(this.singleMesh);
+ this.singleMesh.isVisible = this.size === "single";
+
+ this.doubleMesh = (
+ await BABYLON.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
+ );
+ doubleMaterial.albedoTexture = await importTextureAsync(doubleAlbedo);
+ doubleMaterial.normalTexture = await importTextureAsync(doubleNormal);
+ doubleMaterial.metallicTexture = await importTextureAsync(
+ doubleMetalRoughness
+ );
+ doubleMaterial.useRoughnessFromMetallicTextureAlpha = false;
+ doubleMaterial.useRoughnessFromMetallicTextureGreen = true;
+ doubleMaterial.useMetallnessFromMetallicTextureBlue = true;
+ this.doubleMesh.material = doubleMaterial;
+
+ this.doubleMesh.receiveShadows = true;
+ this.shadowGenerator.addShadowCaster(this.doubleMesh);
+ this.doubleMesh.isVisible = this.size === "double";
+ }
+}
+
+export default DiceTray;
diff --git a/src/dice/diceTray/double.glb b/src/dice/diceTray/double.glb
new file mode 100644
index 0000000..0eb752b
Binary files /dev/null and b/src/dice/diceTray/double.glb differ
diff --git a/src/dice/diceTray/doubleAlbedo.jpg b/src/dice/diceTray/doubleAlbedo.jpg
new file mode 100644
index 0000000..c38282b
Binary files /dev/null and b/src/dice/diceTray/doubleAlbedo.jpg differ
diff --git a/src/dice/diceTray/doubleMetalRoughness.jpg b/src/dice/diceTray/doubleMetalRoughness.jpg
new file mode 100644
index 0000000..94eb5d2
Binary files /dev/null and b/src/dice/diceTray/doubleMetalRoughness.jpg differ
diff --git a/src/dice/diceTray/doubleNormal.jpg b/src/dice/diceTray/doubleNormal.jpg
new file mode 100644
index 0000000..3f2a266
Binary files /dev/null and b/src/dice/diceTray/doubleNormal.jpg differ
diff --git a/src/dice/diceTray/single.glb b/src/dice/diceTray/single.glb
new file mode 100644
index 0000000..efb0621
Binary files /dev/null and b/src/dice/diceTray/single.glb differ
diff --git a/src/dice/diceTray/singleAlbedo.jpg b/src/dice/diceTray/singleAlbedo.jpg
new file mode 100644
index 0000000..0e3bffe
Binary files /dev/null and b/src/dice/diceTray/singleAlbedo.jpg differ
diff --git a/src/dice/diceTray/singleMetalRoughness.jpg b/src/dice/diceTray/singleMetalRoughness.jpg
new file mode 100644
index 0000000..20246f2
Binary files /dev/null and b/src/dice/diceTray/singleMetalRoughness.jpg differ
diff --git a/src/dice/diceTray/singleNormal.jpg b/src/dice/diceTray/singleNormal.jpg
new file mode 100644
index 0000000..4d469f9
Binary files /dev/null and b/src/dice/diceTray/singleNormal.jpg differ
diff --git a/src/dice/environment.dds b/src/dice/environment.dds
new file mode 100755
index 0000000..e8c89af
Binary files /dev/null and b/src/dice/environment.dds differ
diff --git a/src/dice/galaxy/GalaxyDice.js b/src/dice/galaxy/GalaxyDice.js
new file mode 100644
index 0000000..ebe8548
--- /dev/null
+++ b/src/dice/galaxy/GalaxyDice.js
@@ -0,0 +1,37 @@
+import Dice from "../Dice";
+
+import albedo from "./albedo.jpg";
+import metalRoughness from "./metalRoughness.jpg";
+import normal from "./normal.jpg";
+
+class GalaxyDice extends Dice {
+ static meshes;
+ static material;
+
+ static async load(scene) {
+ if (!this.material) {
+ this.material = await this.loadMaterial(
+ "galaxy_pbr",
+ { albedo, metalRoughness, normal },
+ scene
+ );
+ }
+ if (!this.meshes) {
+ this.meshes = await this.loadMeshes(this.material, scene);
+ }
+ }
+
+ static createInstance(diceType, scene) {
+ if (!this.material || !this.meshes) {
+ throw Error("Dice not loaded, call load before creating an instance");
+ }
+
+ return Dice.createInstance(
+ this.meshes[diceType],
+ this.getDicePhysicalProperties(diceType),
+ scene
+ );
+ }
+}
+
+export default GalaxyDice;
diff --git a/src/dice/galaxy/albedo.jpg b/src/dice/galaxy/albedo.jpg
new file mode 100755
index 0000000..4f5cd33
Binary files /dev/null and b/src/dice/galaxy/albedo.jpg differ
diff --git a/src/dice/galaxy/metalRoughness.jpg b/src/dice/galaxy/metalRoughness.jpg
new file mode 100755
index 0000000..2883f3e
Binary files /dev/null and b/src/dice/galaxy/metalRoughness.jpg differ
diff --git a/src/dice/galaxy/normal.jpg b/src/dice/galaxy/normal.jpg
new file mode 100755
index 0000000..ca1c7f2
Binary files /dev/null and b/src/dice/galaxy/normal.jpg differ
diff --git a/src/dice/galaxy/preview.png b/src/dice/galaxy/preview.png
new file mode 100644
index 0000000..d03da88
Binary files /dev/null and b/src/dice/galaxy/preview.png differ
diff --git a/src/dice/gemstone/GemstoneDice.js b/src/dice/gemstone/GemstoneDice.js
new file mode 100644
index 0000000..347c489
--- /dev/null
+++ b/src/dice/gemstone/GemstoneDice.js
@@ -0,0 +1,64 @@
+import * as BABYLON from "babylonjs";
+
+import Dice from "../Dice";
+
+import albedo from "./albedo.jpg";
+import metalRoughness from "./metalRoughness.jpg";
+import normal from "./normal.jpg";
+
+import { importTextureAsync } from "../../helpers/babylon";
+
+class GemstoneDice extends Dice {
+ static meshes;
+ static material;
+
+ static getDicePhysicalProperties(diceType) {
+ let properties = super.getDicePhysicalProperties(diceType);
+ return { mass: properties.mass * 1.5, friction: properties.friction };
+ }
+
+ static async loadMaterial(materialName, textures, scene) {
+ let pbr = new BABYLON.PBRMaterial(materialName, scene);
+ pbr.albedoTexture = await importTextureAsync(textures.albedo);
+ pbr.normalTexture = await importTextureAsync(textures.normal);
+ pbr.metallicTexture = await importTextureAsync(textures.metalRoughness);
+ pbr.useRoughnessFromMetallicTextureAlpha = false;
+ pbr.useRoughnessFromMetallicTextureGreen = true;
+ pbr.useMetallnessFromMetallicTextureBlue = true;
+
+ pbr.subSurface.isTranslucencyEnabled = true;
+ pbr.subSurface.translucencyIntensity = 1.0;
+ pbr.subSurface.minimumThickness = 5;
+ pbr.subSurface.maximumThickness = 10;
+ pbr.subSurface.tintColor = new BABYLON.Color3(190 / 255, 0, 220 / 255);
+
+ return pbr;
+ }
+
+ static async load(scene) {
+ if (!this.material) {
+ this.material = await this.loadMaterial(
+ "gemstone_pbr",
+ { albedo, metalRoughness, normal },
+ scene
+ );
+ }
+ if (!this.meshes) {
+ this.meshes = await this.loadMeshes(this.material, scene);
+ }
+ }
+
+ static createInstance(diceType, scene) {
+ if (!this.material || !this.meshes) {
+ throw Error("Dice not loaded, call load before creating an instance");
+ }
+
+ return Dice.createInstance(
+ this.meshes[diceType],
+ this.getDicePhysicalProperties(diceType),
+ scene
+ );
+ }
+}
+
+export default GemstoneDice;
diff --git a/src/dice/gemstone/albedo.jpg b/src/dice/gemstone/albedo.jpg
new file mode 100755
index 0000000..afcc39d
Binary files /dev/null and b/src/dice/gemstone/albedo.jpg differ
diff --git a/src/dice/gemstone/metalRoughness.jpg b/src/dice/gemstone/metalRoughness.jpg
new file mode 100755
index 0000000..ba996da
Binary files /dev/null and b/src/dice/gemstone/metalRoughness.jpg differ
diff --git a/src/dice/gemstone/normal.jpg b/src/dice/gemstone/normal.jpg
new file mode 100755
index 0000000..a764626
Binary files /dev/null and b/src/dice/gemstone/normal.jpg differ
diff --git a/src/dice/gemstone/preview.png b/src/dice/gemstone/preview.png
new file mode 100644
index 0000000..b5f0502
Binary files /dev/null and b/src/dice/gemstone/preview.png differ
diff --git a/src/dice/glass/GlassDice.js b/src/dice/glass/GlassDice.js
new file mode 100644
index 0000000..50de8f3
--- /dev/null
+++ b/src/dice/glass/GlassDice.js
@@ -0,0 +1,66 @@
+import * as BABYLON from "babylonjs";
+
+import Dice from "../Dice";
+
+import albedo from "./albedo.jpg";
+import mask from "./mask.png";
+import normal from "./normal.jpg";
+
+import { importTextureAsync } from "../../helpers/babylon";
+
+class GlassDice extends Dice {
+ static meshes;
+ static material;
+
+ static getDicePhysicalProperties(diceType) {
+ let properties = super.getDicePhysicalProperties(diceType);
+ return { mass: properties.mass * 1.5, friction: properties.friction };
+ }
+
+ static async loadMaterial(materialName, textures, scene) {
+ let pbr = new BABYLON.PBRMaterial(materialName, scene);
+ pbr.albedoTexture = await importTextureAsync(textures.albedo);
+ pbr.normalTexture = await importTextureAsync(textures.normal);
+ pbr.roughness = 0.25;
+ pbr.metallic = 0;
+ pbr.subSurface.isRefractionEnabled = true;
+ pbr.subSurface.indexOfRefraction = 2.0;
+ pbr.subSurface.refractionIntensity = 1.2;
+ pbr.subSurface.isTranslucencyEnabled = true;
+ 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.thicknessTexture = await importTextureAsync(textures.mask);
+ pbr.subSurface.useMaskFromThicknessTexture = true;
+
+ return pbr;
+ }
+
+ static async load(scene) {
+ if (!this.material) {
+ this.material = await this.loadMaterial(
+ "glass_pbr",
+ { albedo, mask, normal },
+ scene
+ );
+ }
+ if (!this.meshes) {
+ this.meshes = await this.loadMeshes(this.material, scene);
+ }
+ }
+
+ static createInstance(diceType, scene) {
+ if (!this.material || !this.meshes) {
+ throw Error("Dice not loaded, call load before creating an instance");
+ }
+
+ return Dice.createInstance(
+ this.meshes[diceType],
+ this.getDicePhysicalProperties(diceType),
+ scene
+ );
+ }
+}
+
+export default GlassDice;
diff --git a/src/dice/glass/albedo.jpg b/src/dice/glass/albedo.jpg
new file mode 100755
index 0000000..34dee44
Binary files /dev/null and b/src/dice/glass/albedo.jpg differ
diff --git a/src/dice/glass/mask.png b/src/dice/glass/mask.png
new file mode 100644
index 0000000..cbd2286
Binary files /dev/null and b/src/dice/glass/mask.png differ
diff --git a/src/dice/glass/normal.jpg b/src/dice/glass/normal.jpg
new file mode 100755
index 0000000..c7bdb61
Binary files /dev/null and b/src/dice/glass/normal.jpg differ
diff --git a/src/dice/glass/preview.png b/src/dice/glass/preview.png
new file mode 100644
index 0000000..f612ec2
Binary files /dev/null and b/src/dice/glass/preview.png differ
diff --git a/src/dice/index.js b/src/dice/index.js
new file mode 100644
index 0000000..62e22b9
--- /dev/null
+++ b/src/dice/index.js
@@ -0,0 +1,48 @@
+import Case from "case";
+
+import GalaxyDice from "./galaxy/GalaxyDice";
+import IronDice from "./iron/IronDice";
+import NebulaDice from "./nebula/NebulaDice";
+import SunriseDice from "./sunrise/SunriseDice";
+import SunsetDice from "./sunset/SunsetDice";
+import WalnutDice from "./walnut/WalnutDice";
+import GlassDice from "./glass/GlassDice";
+import GemstoneDice from "./gemstone/GemstoneDice";
+
+import GalaxyPreview from "./galaxy/preview.png";
+import IronPreview from "./iron/preview.png";
+import NebulaPreview from "./nebula/preview.png";
+import SunrisePreview from "./sunrise/preview.png";
+import SunsetPreview from "./sunset/preview.png";
+import WalnutPreview from "./walnut/preview.png";
+import GlassPreview from "./glass/preview.png";
+import GemstonePreview from "./gemstone/preview.png";
+
+export const diceClasses = {
+ galaxy: GalaxyDice,
+ nebula: NebulaDice,
+ sunrise: SunriseDice,
+ sunset: SunsetDice,
+ iron: IronDice,
+ walnut: WalnutDice,
+ glass: GlassDice,
+ gemstone: GemstoneDice,
+};
+
+export const dicePreviews = {
+ galaxy: GalaxyPreview,
+ nebula: NebulaPreview,
+ sunrise: SunrisePreview,
+ sunset: SunsetPreview,
+ iron: IronPreview,
+ walnut: WalnutPreview,
+ glass: GlassPreview,
+ gemstone: GemstonePreview,
+};
+
+export const dice = Object.keys(diceClasses).map((key) => ({
+ key,
+ name: Case.capital(key),
+ class: diceClasses[key],
+ preview: dicePreviews[key],
+}));
diff --git a/src/dice/iron/IronDice.js b/src/dice/iron/IronDice.js
new file mode 100644
index 0000000..917ab6c
--- /dev/null
+++ b/src/dice/iron/IronDice.js
@@ -0,0 +1,42 @@
+import Dice from "../Dice";
+
+import albedo from "./albedo.jpg";
+import metalRoughness from "./metalRoughness.jpg";
+import normal from "./normal.jpg";
+
+class IronDice extends Dice {
+ static meshes;
+ static material;
+
+ static getDicePhysicalProperties(diceType) {
+ let properties = super.getDicePhysicalProperties(diceType);
+ return { mass: properties.mass * 2, friction: properties.friction };
+ }
+
+ static async load(scene) {
+ if (!this.material) {
+ this.material = await this.loadMaterial(
+ "iron_pbr",
+ { albedo, metalRoughness, normal },
+ scene
+ );
+ }
+ if (!this.meshes) {
+ this.meshes = await this.loadMeshes(this.material, scene);
+ }
+ }
+
+ static createInstance(diceType, scene) {
+ if (!this.material || !this.meshes) {
+ throw Error("Dice not loaded, call load before creating an instance");
+ }
+
+ return Dice.createInstance(
+ this.meshes[diceType],
+ this.getDicePhysicalProperties(diceType),
+ scene
+ );
+ }
+}
+
+export default IronDice;
diff --git a/src/dice/iron/albedo.jpg b/src/dice/iron/albedo.jpg
new file mode 100755
index 0000000..c3da36e
Binary files /dev/null and b/src/dice/iron/albedo.jpg differ
diff --git a/src/dice/iron/metalRoughness.jpg b/src/dice/iron/metalRoughness.jpg
new file mode 100755
index 0000000..b68015a
Binary files /dev/null and b/src/dice/iron/metalRoughness.jpg differ
diff --git a/src/dice/iron/normal.jpg b/src/dice/iron/normal.jpg
new file mode 100755
index 0000000..1770d3f
Binary files /dev/null and b/src/dice/iron/normal.jpg differ
diff --git a/src/dice/iron/preview.png b/src/dice/iron/preview.png
new file mode 100644
index 0000000..ddc5885
Binary files /dev/null and b/src/dice/iron/preview.png differ
diff --git a/src/dice/nebula/NebulaDice.js b/src/dice/nebula/NebulaDice.js
new file mode 100644
index 0000000..fe687f1
--- /dev/null
+++ b/src/dice/nebula/NebulaDice.js
@@ -0,0 +1,37 @@
+import Dice from "../Dice";
+
+import albedo from "./albedo.jpg";
+import metalRoughness from "./metalRoughness.jpg";
+import normal from "./normal.jpg";
+
+class NebulaDice extends Dice {
+ static meshes;
+ static material;
+
+ static async load(scene) {
+ if (!this.material) {
+ this.material = await this.loadMaterial(
+ "neubula_pbr",
+ { albedo, metalRoughness, normal },
+ scene
+ );
+ }
+ if (!this.meshes) {
+ this.meshes = await this.loadMeshes(this.material, scene);
+ }
+ }
+
+ static createInstance(diceType, scene) {
+ if (!this.material || !this.meshes) {
+ throw Error("Dice not loaded, call load before creating an instance");
+ }
+
+ return Dice.createInstance(
+ this.meshes[diceType],
+ this.getDicePhysicalProperties(diceType),
+ scene
+ );
+ }
+}
+
+export default NebulaDice;
diff --git a/src/dice/nebula/albedo.jpg b/src/dice/nebula/albedo.jpg
new file mode 100755
index 0000000..b767726
Binary files /dev/null and b/src/dice/nebula/albedo.jpg differ
diff --git a/src/dice/nebula/metalRoughness.jpg b/src/dice/nebula/metalRoughness.jpg
new file mode 100755
index 0000000..0843c12
Binary files /dev/null and b/src/dice/nebula/metalRoughness.jpg differ
diff --git a/src/dice/nebula/normal.jpg b/src/dice/nebula/normal.jpg
new file mode 100755
index 0000000..c86a993
Binary files /dev/null and b/src/dice/nebula/normal.jpg differ
diff --git a/src/dice/nebula/preview.png b/src/dice/nebula/preview.png
new file mode 100644
index 0000000..992a1a4
Binary files /dev/null and b/src/dice/nebula/preview.png differ
diff --git a/src/dice/shared/d10.glb b/src/dice/shared/d10.glb
new file mode 100644
index 0000000..ec0da4a
Binary files /dev/null and b/src/dice/shared/d10.glb differ
diff --git a/src/dice/shared/d100.glb b/src/dice/shared/d100.glb
new file mode 100644
index 0000000..45de7e2
Binary files /dev/null and b/src/dice/shared/d100.glb differ
diff --git a/src/dice/shared/d12.glb b/src/dice/shared/d12.glb
new file mode 100644
index 0000000..ef2284f
Binary files /dev/null and b/src/dice/shared/d12.glb differ
diff --git a/src/dice/shared/d20.glb b/src/dice/shared/d20.glb
new file mode 100644
index 0000000..7894171
Binary files /dev/null and b/src/dice/shared/d20.glb differ
diff --git a/src/dice/shared/d4.glb b/src/dice/shared/d4.glb
new file mode 100644
index 0000000..352d96a
Binary files /dev/null and b/src/dice/shared/d4.glb differ
diff --git a/src/dice/shared/d6.glb b/src/dice/shared/d6.glb
new file mode 100644
index 0000000..3c3410b
Binary files /dev/null and b/src/dice/shared/d6.glb differ
diff --git a/src/dice/shared/d8.glb b/src/dice/shared/d8.glb
new file mode 100644
index 0000000..60e335d
Binary files /dev/null and b/src/dice/shared/d8.glb differ
diff --git a/src/dice/sunrise/SunriseDice.js b/src/dice/sunrise/SunriseDice.js
new file mode 100644
index 0000000..d92f6f0
--- /dev/null
+++ b/src/dice/sunrise/SunriseDice.js
@@ -0,0 +1,37 @@
+import Dice from "../Dice";
+
+import albedo from "./albedo.jpg";
+import metalRoughness from "./metalRoughness.jpg";
+import normal from "./normal.jpg";
+
+class SunriseDice extends Dice {
+ static meshes;
+ static material;
+
+ static async load(scene) {
+ if (!this.material) {
+ this.material = await this.loadMaterial(
+ "sunrise_pbr",
+ { albedo, metalRoughness, normal },
+ scene
+ );
+ }
+ if (!this.meshes) {
+ this.meshes = await this.loadMeshes(this.material, scene);
+ }
+ }
+
+ static createInstance(diceType, scene) {
+ if (!this.material || !this.meshes) {
+ throw Error("Dice not loaded, call load before creating an instance");
+ }
+
+ return Dice.createInstance(
+ this.meshes[diceType],
+ this.getDicePhysicalProperties(diceType),
+ scene
+ );
+ }
+}
+
+export default SunriseDice;
diff --git a/src/dice/sunrise/albedo.jpg b/src/dice/sunrise/albedo.jpg
new file mode 100755
index 0000000..3010bb5
Binary files /dev/null and b/src/dice/sunrise/albedo.jpg differ
diff --git a/src/dice/sunrise/metalRoughness.jpg b/src/dice/sunrise/metalRoughness.jpg
new file mode 100755
index 0000000..face52a
Binary files /dev/null and b/src/dice/sunrise/metalRoughness.jpg differ
diff --git a/src/dice/sunrise/normal.jpg b/src/dice/sunrise/normal.jpg
new file mode 100755
index 0000000..c6cd269
Binary files /dev/null and b/src/dice/sunrise/normal.jpg differ
diff --git a/src/dice/sunrise/preview.png b/src/dice/sunrise/preview.png
new file mode 100644
index 0000000..afe95bb
Binary files /dev/null and b/src/dice/sunrise/preview.png differ
diff --git a/src/dice/sunset/SunsetDice.js b/src/dice/sunset/SunsetDice.js
new file mode 100644
index 0000000..c0e6884
--- /dev/null
+++ b/src/dice/sunset/SunsetDice.js
@@ -0,0 +1,37 @@
+import Dice from "../Dice";
+
+import albedo from "./albedo.jpg";
+import metalRoughness from "./metalRoughness.jpg";
+import normal from "./normal.jpg";
+
+class SunsetDice extends Dice {
+ static meshes;
+ static material;
+
+ static async load(scene) {
+ if (!this.material) {
+ this.material = await this.loadMaterial(
+ "sunset_pbr",
+ { albedo, metalRoughness, normal },
+ scene
+ );
+ }
+ if (!this.meshes) {
+ this.meshes = await this.loadMeshes(this.material, scene);
+ }
+ }
+
+ static createInstance(diceType, scene) {
+ if (!this.material || !this.meshes) {
+ throw Error("Dice not loaded, call load before creating an instance");
+ }
+
+ return Dice.createInstance(
+ this.meshes[diceType],
+ this.getDicePhysicalProperties(diceType),
+ scene
+ );
+ }
+}
+
+export default SunsetDice;
diff --git a/src/dice/sunset/albedo.jpg b/src/dice/sunset/albedo.jpg
new file mode 100755
index 0000000..d44c2c7
Binary files /dev/null and b/src/dice/sunset/albedo.jpg differ
diff --git a/src/dice/sunset/metalRoughness.jpg b/src/dice/sunset/metalRoughness.jpg
new file mode 100755
index 0000000..c206d0d
Binary files /dev/null and b/src/dice/sunset/metalRoughness.jpg differ
diff --git a/src/dice/sunset/normal.jpg b/src/dice/sunset/normal.jpg
new file mode 100755
index 0000000..b4b91e0
Binary files /dev/null and b/src/dice/sunset/normal.jpg differ
diff --git a/src/dice/sunset/preview.png b/src/dice/sunset/preview.png
new file mode 100644
index 0000000..64eb54c
Binary files /dev/null and b/src/dice/sunset/preview.png differ
diff --git a/src/dice/walnut/WalnutDice.js b/src/dice/walnut/WalnutDice.js
new file mode 100644
index 0000000..b5e5755
--- /dev/null
+++ b/src/dice/walnut/WalnutDice.js
@@ -0,0 +1,64 @@
+import Dice from "../Dice";
+
+import albedo from "./albedo.jpg";
+import metalRoughness from "./metalRoughness.jpg";
+import normal from "./normal.jpg";
+
+import d4Source from "./d4.glb";
+import d6Source from "./d6.glb";
+import d8Source from "./d8.glb";
+import d10Source from "./d10.glb";
+import d12Source from "./d12.glb";
+import d20Source from "./d20.glb";
+import d100Source from "./d100.glb";
+
+const sourceOverrides = {
+ d4: d4Source,
+ d6: d6Source,
+ d8: d8Source,
+ d10: d10Source,
+ d12: d12Source,
+ d20: d20Source,
+ d100: d100Source,
+};
+
+class WalnutDice extends Dice {
+ static meshes;
+ static material;
+
+ static getDicePhysicalProperties(diceType) {
+ let properties = super.getDicePhysicalProperties(diceType);
+ return { mass: properties.mass * 1.4, friction: properties.friction };
+ }
+
+ static async load(scene) {
+ if (!this.material) {
+ this.material = await this.loadMaterial(
+ "walnut_pbr",
+ { albedo, metalRoughness, normal },
+ scene
+ );
+ }
+ if (!this.meshes) {
+ this.meshes = await this.loadMeshes(
+ this.material,
+ scene,
+ sourceOverrides
+ );
+ }
+ }
+
+ static createInstance(diceType, scene) {
+ if (!this.material || !this.meshes) {
+ throw Error("Dice not loaded, call load before creating an instance");
+ }
+
+ return Dice.createInstance(
+ this.meshes[diceType],
+ this.getDicePhysicalProperties(diceType),
+ scene
+ );
+ }
+}
+
+export default WalnutDice;
diff --git a/src/dice/walnut/albedo.jpg b/src/dice/walnut/albedo.jpg
new file mode 100755
index 0000000..798d3cd
Binary files /dev/null and b/src/dice/walnut/albedo.jpg differ
diff --git a/src/dice/walnut/d10.glb b/src/dice/walnut/d10.glb
new file mode 100644
index 0000000..fb99084
Binary files /dev/null and b/src/dice/walnut/d10.glb differ
diff --git a/src/dice/walnut/d100.glb b/src/dice/walnut/d100.glb
new file mode 100644
index 0000000..2a48b50
Binary files /dev/null and b/src/dice/walnut/d100.glb differ
diff --git a/src/dice/walnut/d12.glb b/src/dice/walnut/d12.glb
new file mode 100644
index 0000000..e4a4846
Binary files /dev/null and b/src/dice/walnut/d12.glb differ
diff --git a/src/dice/walnut/d20.glb b/src/dice/walnut/d20.glb
new file mode 100644
index 0000000..8971663
Binary files /dev/null and b/src/dice/walnut/d20.glb differ
diff --git a/src/dice/walnut/d4.glb b/src/dice/walnut/d4.glb
new file mode 100644
index 0000000..d2e9c6d
Binary files /dev/null and b/src/dice/walnut/d4.glb differ
diff --git a/src/dice/walnut/d6.glb b/src/dice/walnut/d6.glb
new file mode 100644
index 0000000..af0209c
Binary files /dev/null and b/src/dice/walnut/d6.glb differ
diff --git a/src/dice/walnut/d8.glb b/src/dice/walnut/d8.glb
new file mode 100644
index 0000000..af4910c
Binary files /dev/null and b/src/dice/walnut/d8.glb differ
diff --git a/src/dice/walnut/metalRoughness.jpg b/src/dice/walnut/metalRoughness.jpg
new file mode 100755
index 0000000..6d29a56
Binary files /dev/null and b/src/dice/walnut/metalRoughness.jpg differ
diff --git a/src/dice/walnut/normal.jpg b/src/dice/walnut/normal.jpg
new file mode 100755
index 0000000..c67888e
Binary files /dev/null and b/src/dice/walnut/normal.jpg differ
diff --git a/src/dice/walnut/preview.png b/src/dice/walnut/preview.png
new file mode 100644
index 0000000..2961f2a
Binary files /dev/null and b/src/dice/walnut/preview.png differ
diff --git a/src/docs/releaseNotes/v1.3.0.md b/src/docs/releaseNotes/v1.3.0.md
new file mode 100644
index 0000000..80919c2
--- /dev/null
+++ b/src/docs/releaseNotes/v1.3.0.md
@@ -0,0 +1,41 @@
+# v1.3.0
+
+## Major Changes
+
+### Dice Rolling
+
+Added a physically simulated dice tray and dice with a bunch of features.
+
+- Single click dice rolling, simply click the icon for the type of dice you want to roll and it will roll it.
+- Reroll all the dice in the tray by clicking the reroll icon or reroll an individual dice by picking it up and throwing it.
+- 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.
+
+### Custom and New Default Tokens
+
+Along with the ability to add your own custom tokens there are new default tokens.
+The new tokens offer a wider variety of moster types as well as cover all official classes in D&D 5E. Replacing the token size input is a new add token button, clicking this will allow you to add and manage tokens. When adding a custom token there are a few options available.
+
+- Default Size, select the default number of grid cells that the token will take up (for non square tokens this is the number of horizontal tiles).
+- Is Mount / Vehicle, marking a token as a mount / vehicle does two things: first this token will always be shown below other tokens, second when moving this token any other token on top of the mount / vehicle will automatically be moved with it.
+- Hide in Sidebar, marking this as true will hide the token in the main token bar on the side of the screen.
+
+### Overhauled Map and Interaction System
+
+Map, tokens, drawing and fog have all been unified into one system which leads to a lot of positives.
+
+- Tokens can now be hidden by the fog of war allowing for more options when setting up a game in advance.
+- Less bugs when interacting between map systems.
+- Drawings and fog can now extend past the map.
+- Tokens now have exact hit registration for dragging which is great for tokens with a lot of transparent parts.
+
+## Minor Changes
+
+- New size and rotation options for tokens once they've been placed on the map.
+- Increased character limit for token labels.
+- Token movement is now animated for other party members.
+- A new visual display and method for deleting tokens from the map.
+
+[Reddit]()
diff --git a/src/helpers/babylon.js b/src/helpers/babylon.js
new file mode 100644
index 0000000..82d735b
--- /dev/null
+++ b/src/helpers/babylon.js
@@ -0,0 +1,20 @@
+import * as BABYLON from "babylonjs";
+
+// 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(
+ url,
+ null,
+ undefined,
+ false,
+ undefined,
+ () => {
+ resolve(texture);
+ },
+ () => {
+ reject("Unable to load texture");
+ }
+ );
+ });
+}
diff --git a/src/helpers/drawing.js b/src/helpers/drawing.js
index 89a192b..0ff64d0 100644
--- a/src/helpers/drawing.js
+++ b/src/helpers/drawing.js
@@ -2,7 +2,6 @@ import simplify from "simplify-js";
import * as Vector2 from "./vector2";
import { toDegrees } from "./shared";
-import colors from "./colors";
const snappingThreshold = 1 / 5;
export function getBrushPositionForTool(
@@ -140,207 +139,13 @@ export function getUpdatedShapeData(type, data, brushPosition, gridSize) {
}
}
-const defaultStrokeSize = 1 / 10;
-export function getStrokeSize(multiplier, gridSize, canvasWidth, canvasHeight) {
+const defaultStrokeWidth = 1 / 10;
+export function getStrokeWidth(multiplier, gridSize, mapWidth, mapHeight) {
const gridPixelSize = Vector2.multiply(gridSize, {
- x: canvasWidth,
- y: canvasHeight,
+ x: mapWidth,
+ y: mapHeight,
});
- return Vector2.min(gridPixelSize) * defaultStrokeSize * multiplier;
-}
-
-export function shapeHasFill(shape) {
- return (
- shape.type === "fog" ||
- shape.type === "shape" ||
- (shape.type === "path" && shape.pathType === "fill")
- );
-}
-
-export function pointsToQuadraticBezier(points) {
- const quadraticPoints = [];
-
- // Draw a smooth curve between the points where each control point
- // is the current point in the array and the next point is the center of
- // the current point and the next point
- for (let i = 1; i < points.length - 2; i++) {
- const start = points[i - 1];
- const controlPoint = points[i];
- const next = points[i + 1];
- const end = Vector2.divide(Vector2.add(controlPoint, next), 2);
-
- quadraticPoints.push({ start, controlPoint, end });
- }
- // Curve through the last two points
- quadraticPoints.push({
- start: points[points.length - 2],
- controlPoint: points[points.length - 1],
- end: points[points.length - 1],
- });
-
- return quadraticPoints;
-}
-
-export function pointsToPathSmooth(points, close, canvasWidth, canvasHeight) {
- const path = new Path2D();
- if (points.length < 2) {
- return path;
- }
- path.moveTo(points[0].x * canvasWidth, points[0].y * canvasHeight);
-
- const quadraticPoints = pointsToQuadraticBezier(points);
- for (let quadPoint of quadraticPoints) {
- const pointScaled = Vector2.multiply(quadPoint.end, {
- x: canvasWidth,
- y: canvasHeight,
- });
- const controlScaled = Vector2.multiply(quadPoint.controlPoint, {
- x: canvasWidth,
- y: canvasHeight,
- });
- path.quadraticCurveTo(
- controlScaled.x,
- controlScaled.y,
- pointScaled.x,
- pointScaled.y
- );
- }
-
- if (close) {
- path.closePath();
- }
- return path;
-}
-
-export function pointsToPathSharp(points, close, canvasWidth, canvasHeight) {
- const path = new Path2D();
- path.moveTo(points[0].x * canvasWidth, points[0].y * canvasHeight);
- for (let point of points.slice(1)) {
- path.lineTo(point.x * canvasWidth, point.y * canvasHeight);
- }
- if (close) {
- path.closePath();
- }
-
- return path;
-}
-
-export function circleToPath(x, y, radius, canvasWidth, canvasHeight) {
- const path = new Path2D();
- const minSide = canvasWidth < canvasHeight ? canvasWidth : canvasHeight;
- path.arc(
- x * canvasWidth,
- y * canvasHeight,
- radius * minSide,
- 0,
- 2 * Math.PI,
- true
- );
- return path;
-}
-
-export function rectangleToPath(
- x,
- y,
- width,
- height,
- canvasWidth,
- canvasHeight
-) {
- const path = new Path2D();
- path.rect(
- x * canvasWidth,
- y * canvasHeight,
- width * canvasWidth,
- height * canvasHeight
- );
- return path;
-}
-
-export function shapeToPath(shape, canvasWidth, canvasHeight) {
- const data = shape.data;
- if (shape.type === "path") {
- return pointsToPathSmooth(
- data.points,
- shape.pathType === "fill",
- canvasWidth,
- canvasHeight
- );
- } else if (shape.type === "shape") {
- if (shape.shapeType === "circle") {
- return circleToPath(
- data.x,
- data.y,
- data.radius,
- canvasWidth,
- canvasHeight
- );
- } else if (shape.shapeType === "rectangle") {
- return rectangleToPath(
- data.x,
- data.y,
- data.width,
- data.height,
- canvasWidth,
- canvasHeight
- );
- } else if (shape.shapeType === "triangle") {
- return pointsToPathSharp(data.points, true, canvasWidth, canvasHeight);
- }
- } else if (shape.type === "fog") {
- return pointsToPathSharp(
- shape.data.points,
- true,
- canvasWidth,
- canvasHeight
- );
- }
-}
-
-export function isShapeHovered(
- shape,
- context,
- hoverPosition,
- canvasWidth,
- canvasHeight
-) {
- const path = shapeToPath(shape, canvasWidth, canvasHeight);
- if (shapeHasFill(shape)) {
- return context.isPointInPath(
- path,
- hoverPosition.x * canvasWidth,
- hoverPosition.y * canvasHeight
- );
- } else {
- return context.isPointInStroke(
- path,
- hoverPosition.x * canvasWidth,
- hoverPosition.y * canvasHeight
- );
- }
-}
-
-export function drawShape(shape, context, gridSize, canvasWidth, canvasHeight) {
- const path = shapeToPath(shape, canvasWidth, canvasHeight);
- const color = colors[shape.color] || shape.color;
- const fill = shapeHasFill(shape);
-
- context.globalAlpha = shape.blend ? 0.5 : 1.0;
- context.fillStyle = color;
- context.strokeStyle = color;
- if (shape.strokeWidth > 0) {
- context.lineCap = "round";
- context.lineWidth = getStrokeSize(
- shape.strokeWidth,
- gridSize,
- canvasWidth,
- canvasHeight
- );
- context.stroke(path);
- }
- if (fill) {
- context.fill(path);
- }
+ return Vector2.min(gridPixelSize) * defaultStrokeWidth * multiplier;
}
const defaultSimplifySize = 1 / 100;
@@ -350,12 +155,3 @@ export function simplifyPoints(points, gridSize, scale) {
(Vector2.min(gridSize) * defaultSimplifySize) / scale
);
}
-
-export function getRelativePointerPosition(event, container) {
- if (container) {
- const containerRect = container.getBoundingClientRect();
- const x = (event.clientX - containerRect.x) / containerRect.width;
- const y = (event.clientY - containerRect.y) / containerRect.height;
- return { x, y };
- }
-}
diff --git a/src/helpers/shared.js b/src/helpers/shared.js
index 182272a..7552d7b 100644
--- a/src/helpers/shared.js
+++ b/src/helpers/shared.js
@@ -35,3 +35,7 @@ export function toRadians(angle) {
export function toDegrees(angle) {
return angle * (180 / Math.PI);
}
+
+export function lerp(a, b, alpha) {
+ return a * (1 - alpha) + b * alpha;
+}
diff --git a/src/helpers/useDataSource.js b/src/helpers/useDataSource.js
index 4af57a1..6dc39b8 100644
--- a/src/helpers/useDataSource.js
+++ b/src/helpers/useDataSource.js
@@ -2,14 +2,14 @@ import { useEffect, useState } from "react";
// Helper function to load either file or default data
// into a URL and ensure that it is revoked if needed
-function useDataSource(data, defaultSources) {
+function useDataSource(data, defaultSources, unknownSource) {
const [dataSource, setDataSource] = useState(null);
useEffect(() => {
if (!data) {
- setDataSource(null);
+ setDataSource(unknownSource);
return;
}
- let url = null;
+ let url = unknownSource;
if (data.type === "file") {
url = URL.createObjectURL(new Blob([data.file]));
} else if (data.type === "default") {
@@ -22,7 +22,7 @@ function useDataSource(data, defaultSources) {
URL.revokeObjectURL(url);
}
};
- }, [data, defaultSources]);
+ }, [data, defaultSources, unknownSource]);
return dataSource;
}
diff --git a/src/helpers/useMapBrush.js b/src/helpers/useMapBrush.js
new file mode 100644
index 0000000..3c96f7d
--- /dev/null
+++ b/src/helpers/useMapBrush.js
@@ -0,0 +1,71 @@
+import { useContext, useRef, useEffect } from "react";
+
+import MapInteractionContext from "../contexts/MapInteractionContext";
+
+import { compare } from "./vector2";
+
+import usePrevious from "./usePrevious";
+
+/**
+ * @callback onBrushUpdate
+ * @param {string} drawState "first" | "drawing" | "last"
+ * @param {Object} brushPosition the normalized x and y coordinates of the brush on the map
+ */
+
+/**
+ * Helper to get the maps drag position as it changes
+ * @param {boolean} shouldUpdate
+ * @param {onBrushUpdate} onBrushUpdate
+ */
+function useMapBrush(shouldUpdate, onBrushUpdate) {
+ const { stageDragState, mapDragPositionRef } = useContext(
+ MapInteractionContext
+ );
+
+ const requestRef = useRef();
+ const previousDragState = usePrevious(stageDragState);
+ const previousBrushPositionRef = useRef(mapDragPositionRef.current);
+
+ useEffect(() => {
+ function updateBrush(forceUpdate) {
+ const drawState =
+ stageDragState === "dragging" ? "drawing" : stageDragState;
+ const brushPosition = mapDragPositionRef.current;
+ const previousBrushPostition = previousBrushPositionRef.current;
+ // Only update brush when it has moved
+ if (
+ !compare(brushPosition, previousBrushPostition, 0.0001) ||
+ forceUpdate
+ ) {
+ onBrushUpdate(drawState, brushPosition);
+ previousBrushPositionRef.current = brushPosition;
+ }
+ }
+
+ function animate() {
+ if (!shouldUpdate) {
+ return;
+ }
+ requestRef.current = requestAnimationFrame(animate);
+ updateBrush(false);
+ }
+
+ requestRef.current = requestAnimationFrame(animate);
+
+ if (stageDragState !== previousDragState && shouldUpdate) {
+ updateBrush(true);
+ }
+
+ return () => {
+ cancelAnimationFrame(requestRef.current);
+ };
+ }, [
+ shouldUpdate,
+ onBrushUpdate,
+ stageDragState,
+ mapDragPositionRef,
+ previousDragState,
+ ]);
+}
+
+export default useMapBrush;
diff --git a/src/helpers/usePreventOverscroll.js b/src/helpers/usePreventOverscroll.js
new file mode 100644
index 0000000..73cd602
--- /dev/null
+++ b/src/helpers/usePreventOverscroll.js
@@ -0,0 +1,25 @@
+import { useEffect } from "react";
+
+function usePreventOverscroll(elementRef) {
+ useEffect(() => {
+ // Stop overscroll on chrome and safari
+ // also stop pinch to zoom on chrome
+ function preventOverscroll(event) {
+ event.preventDefault();
+ }
+ const element = elementRef.current;
+ if (element) {
+ element.addEventListener("wheel", preventOverscroll, {
+ passive: false,
+ });
+ }
+
+ return () => {
+ if (element) {
+ element.removeEventListener("wheel", preventOverscroll);
+ }
+ };
+ }, [elementRef]);
+}
+
+export default usePreventOverscroll;
diff --git a/src/helpers/useSession.js b/src/helpers/useSession.js
index 1de6308..e728a0b 100644
--- a/src/helpers/useSession.js
+++ b/src/helpers/useSession.js
@@ -136,7 +136,7 @@ function useSession(
function addPeer(id, initiator, sync) {
const connection = new Peer({
initiator,
- trickle: false,
+ trickle: true,
config: { iceServers },
});
diff --git a/src/icons/ClearDiceIcon.js b/src/icons/ClearDiceIcon.js
new file mode 100644
index 0000000..030f248
--- /dev/null
+++ b/src/icons/ClearDiceIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function ClearDiceIcon() {
+ return (
+
+ );
+}
+
+export default ClearDiceIcon;
diff --git a/src/icons/D100Icon.js b/src/icons/D100Icon.js
new file mode 100644
index 0000000..70aa34c
--- /dev/null
+++ b/src/icons/D100Icon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function D100Icon() {
+ return (
+
+ );
+}
+
+export default D100Icon;
diff --git a/src/icons/D10Icon.js b/src/icons/D10Icon.js
new file mode 100644
index 0000000..c045d0b
--- /dev/null
+++ b/src/icons/D10Icon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function D10Icon() {
+ return (
+
+ );
+}
+
+export default D10Icon;
diff --git a/src/icons/D12Icon.js b/src/icons/D12Icon.js
new file mode 100644
index 0000000..fa34680
--- /dev/null
+++ b/src/icons/D12Icon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function D12Icon() {
+ return (
+
+ );
+}
+
+export default D12Icon;
diff --git a/src/icons/D20Icon.js b/src/icons/D20Icon.js
new file mode 100644
index 0000000..5975f0e
--- /dev/null
+++ b/src/icons/D20Icon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function D20Icon() {
+ return (
+
+ );
+}
+
+export default D20Icon;
diff --git a/src/icons/D4Icon.js b/src/icons/D4Icon.js
new file mode 100644
index 0000000..c9377cd
--- /dev/null
+++ b/src/icons/D4Icon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function D4Icon() {
+ return (
+
+ );
+}
+
+export default D4Icon;
diff --git a/src/icons/D6Icon.js b/src/icons/D6Icon.js
new file mode 100644
index 0000000..8135fa2
--- /dev/null
+++ b/src/icons/D6Icon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function D6Icon() {
+ return (
+
+ );
+}
+
+export default D6Icon;
diff --git a/src/icons/D8Icon.js b/src/icons/D8Icon.js
new file mode 100644
index 0000000..ad7bef1
--- /dev/null
+++ b/src/icons/D8Icon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function D8Icon() {
+ return (
+
+ );
+}
+
+export default D8Icon;
diff --git a/src/icons/ExpandMoreDiceIcon.js b/src/icons/ExpandMoreDiceIcon.js
new file mode 100644
index 0000000..53f0a14
--- /dev/null
+++ b/src/icons/ExpandMoreDiceIcon.js
@@ -0,0 +1,22 @@
+import React from "react";
+
+function ExpandMoreDiceIcon({ isExpanded }) {
+ return (
+
+ );
+}
+
+export default ExpandMoreDiceIcon;
diff --git a/src/icons/ExpandMoreDiceTrayIcon.js b/src/icons/ExpandMoreDiceTrayIcon.js
new file mode 100644
index 0000000..2b860d9
--- /dev/null
+++ b/src/icons/ExpandMoreDiceTrayIcon.js
@@ -0,0 +1,19 @@
+import React from "react";
+
+function ExpandMoreDotIcon() {
+ return (
+
+ );
+}
+
+export default ExpandMoreDotIcon;
diff --git a/src/icons/RemoveTokenIcon.js b/src/icons/RemoveTokenIcon.js
new file mode 100644
index 0000000..2f587d2
--- /dev/null
+++ b/src/icons/RemoveTokenIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function RemoveTokenIcon() {
+ return (
+
+ );
+}
+
+export default RemoveTokenIcon;
diff --git a/src/icons/RerollDiceIcon.js b/src/icons/RerollDiceIcon.js
new file mode 100644
index 0000000..0f3a5b1
--- /dev/null
+++ b/src/icons/RerollDiceIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function RerollDiceIcon() {
+ return (
+
+ );
+}
+
+export default RerollDiceIcon;
diff --git a/src/icons/SelectDiceIcon.js b/src/icons/SelectDiceIcon.js
new file mode 100644
index 0000000..c9433ef
--- /dev/null
+++ b/src/icons/SelectDiceIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function SelectMapIcon() {
+ return (
+
+ );
+}
+
+export default SelectMapIcon;
diff --git a/src/icons/SelectTokensIcon.js b/src/icons/SelectTokensIcon.js
new file mode 100644
index 0000000..0637422
--- /dev/null
+++ b/src/icons/SelectTokensIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function SelectMapIcon() {
+ return (
+
+ );
+}
+
+export default SelectMapIcon;
diff --git a/src/maps/Unknown Grid 22x22.jpg b/src/maps/Unknown Grid 22x22.jpg
new file mode 100644
index 0000000..d4e9933
Binary files /dev/null and b/src/maps/Unknown Grid 22x22.jpg differ
diff --git a/src/maps/index.js b/src/maps/index.js
index 9fe93d7..8f1bbbf 100644
--- a/src/maps/index.js
+++ b/src/maps/index.js
@@ -1,3 +1,5 @@
+import Case from "case";
+
import blankImage from "./Blank Grid 22x22.jpg";
import grassImage from "./Grass Grid 22x22.jpg";
import sandImage from "./Sand Grid 22x22.jpg";
@@ -5,6 +7,8 @@ import stoneImage from "./Stone Grid 22x22.jpg";
import waterImage from "./Water Grid 22x22.jpg";
import woodImage from "./Wood Grid 22x22.jpg";
+import unknownImage from "./Unknown Grid 22x22.jpg";
+
export const mapSources = {
blank: blankImage,
grass: grassImage,
@@ -16,10 +20,12 @@ export const mapSources = {
export const maps = Object.keys(mapSources).map((key) => ({
key,
- name: key.charAt(0).toUpperCase() + key.slice(1),
+ name: Case.capital(key),
gridX: 22,
gridY: 22,
width: 1024,
height: 1024,
type: "default",
}));
+
+export const unknownSource = unknownImage;
diff --git a/src/modals/SelectDiceModal.js b/src/modals/SelectDiceModal.js
new file mode 100644
index 0000000..7a3e967
--- /dev/null
+++ b/src/modals/SelectDiceModal.js
@@ -0,0 +1,35 @@
+import React, { useState } from "react";
+import { Flex, Label, Button } from "theme-ui";
+
+import Modal from "../components/Modal";
+import DiceTiles from "../components/dice/DiceTiles";
+
+import { dice } from "../dice";
+
+function SelectDiceModal({ isOpen, onRequestClose, onDone, defaultDice }) {
+ const [selectedDice, setSelectedDice] = useState(defaultDice);
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default SelectDiceModal;
diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js
index cb0cbcb..91de48a 100644
--- a/src/modals/SelectMapModal.js
+++ b/src/modals/SelectMapModal.js
@@ -1,32 +1,18 @@
-import React, { useRef, useState, useEffect, useContext } from "react";
-import { Box, Button, Flex, Label, Text } from "theme-ui";
+import React, { useRef, useState, useContext } from "react";
+import { Button, Flex, Label } from "theme-ui";
import shortid from "shortid";
import Modal from "../components/Modal";
import MapTiles from "../components/map/MapTiles";
import MapSettings from "../components/map/MapSettings";
+import ImageDrop from "../components/ImageDrop";
-import AuthContext from "../contexts/AuthContext";
-import DatabaseContext from "../contexts/DatabaseContext";
-
-import usePrevious from "../helpers/usePrevious";
import blobToBuffer from "../helpers/blobToBuffer";
-import { maps as defaultMaps } from "../maps";
+import MapDataContext from "../contexts/MapDataContext";
+import AuthContext from "../contexts/AuthContext";
const defaultMapSize = 22;
-const defaultMapState = {
- tokens: {},
- // An index into the draw actions array to which only actions before the
- // index will be performed (used in undo and redo)
- mapDrawActionIndex: -1,
- mapDrawActions: [],
- fogDrawActionIndex: -1,
- fogDrawActions: [],
- // Flags to determine what other people can edit
- editFlags: ["drawing", "tokens"],
-};
-
const defaultMapProps = {
// Grid type
// TODO: add support for hex horizontal and hex vertical
@@ -42,68 +28,26 @@ function SelectMapModal({
// The map currently being view in the map screen
currentMap,
}) {
- const { database } = useContext(DatabaseContext);
const { userId } = useContext(AuthContext);
-
- const wasOpen = usePrevious(isOpen);
+ const {
+ ownedMaps,
+ mapStates,
+ addMap,
+ removeMap,
+ resetMap,
+ updateMap,
+ updateMapState,
+ } = useContext(MapDataContext);
const [imageLoading, setImageLoading] = useState(false);
// The map selected in the modal
- const [selectedMap, setSelectedMap] = useState(null);
- const [selectedMapState, setSelectedMapState] = useState(null);
- const [maps, setMaps] = useState([]);
- // Load maps from the database and ensure state is properly setup
- useEffect(() => {
- if (!userId || !database) {
- return;
- }
- async function getDefaultMaps() {
- const defaultMapsWithIds = [];
- for (let i = 0; i < defaultMaps.length; i++) {
- const defaultMap = defaultMaps[i];
- const id = `__default-${defaultMap.name}`;
- defaultMapsWithIds.push({
- ...defaultMap,
- id,
- owner: userId,
- // Emulate the time increasing to avoid sort errors
- created: Date.now() + i,
- lastModified: Date.now() + i,
- ...defaultMapProps,
- });
- // Add a state for the map if there isn't one already
- const state = await database.table("states").get(id);
- if (!state) {
- await database.table("states").add({ ...defaultMapState, mapId: id });
- }
- }
- return defaultMapsWithIds;
- }
+ const [selectedMapId, setSelectedMapId] = useState(null);
- async function loadMaps() {
- let storedMaps = await database
- .table("maps")
- .where({ owner: userId })
- .toArray();
- const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
- const defaultMapsWithIds = await getDefaultMaps();
- const allMaps = [...sortedMaps, ...defaultMapsWithIds];
- setMaps(allMaps);
-
- // reload map state as is may have changed while the modal was closed
- if (selectedMap) {
- const state = await database.table("states").get(selectedMap.id);
- if (state) {
- setSelectedMapState(state);
- }
- }
- }
-
- if (!wasOpen && isOpen) {
- loadMaps();
- }
- }, [userId, database, isOpen, wasOpen, selectedMap]);
+ const selectedMap = ownedMaps.find((map) => map.id === selectedMapId);
+ const selectedMapState = mapStates.find(
+ (state) => state.mapId === selectedMapId
+ );
const fileInputRef = useRef();
@@ -180,108 +124,55 @@ function SelectMapModal({
}
async function handleMapAdd(map) {
- await database.table("maps").add(map);
- const state = { ...defaultMapState, mapId: map.id };
- await database.table("states").add(state);
- setMaps((prevMaps) => [map, ...prevMaps]);
- setSelectedMap(map);
- setSelectedMapState(state);
+ await addMap(map);
+ setSelectedMapId(map.id);
}
async function handleMapRemove(id) {
- await database.table("maps").delete(id);
- await database.table("states").delete(id);
- setMaps((prevMaps) => {
- const filtered = prevMaps.filter((map) => map.id !== id);
- setSelectedMap(filtered[0]);
- database.table("states").get(filtered[0].id).then(setSelectedMapState);
- return filtered;
- });
+ await removeMap(id);
+ setSelectedMapId(null);
// Removed the map from the map screen if needed
- if (currentMap && currentMap.id === selectedMap.id) {
+ if (currentMap && currentMap.id === selectedMapId) {
onMapChange(null, null);
}
}
- async function handleMapSelect(map) {
- const state = await database.table("states").get(map.id);
- setSelectedMapState(state);
- setSelectedMap(map);
+ function handleMapSelect(map) {
+ setSelectedMapId(map.id);
}
async function handleMapReset(id) {
- const state = { ...defaultMapState, mapId: id };
- await database.table("states").put(state);
- setSelectedMapState(state);
+ const newState = await resetMap(id);
// Reset the state of the current map if needed
- if (currentMap && currentMap.id === selectedMap.id) {
- onMapStateChange(state);
+ if (currentMap && currentMap.id === selectedMapId) {
+ onMapStateChange(newState);
}
}
- async function handleSubmit(e) {
- e.preventDefault();
- if (selectedMap) {
+ async function handleDone() {
+ if (selectedMapId) {
onMapChange(selectedMap, selectedMapState);
onDone();
}
onDone();
}
- /**
- * Drag and Drop
- */
- const [dragging, setDragging] = useState(false);
- function handleImageDragEnter(event) {
- event.preventDefault();
- event.stopPropagation();
- setDragging(true);
- }
-
- function handleImageDragLeave(event) {
- event.preventDefault();
- event.stopPropagation();
- setDragging(false);
- }
-
- function handleImageDrop(event) {
- event.preventDefault();
- event.stopPropagation();
- const file = event.dataTransfer.files[0];
- if (file && file.type.startsWith("image")) {
- handleImageUpload(file);
- }
- setDragging(false);
- }
-
/**
* Map settings
*/
const [showMoreSettings, setShowMoreSettings] = useState(false);
async function handleMapSettingsChange(key, value) {
- const change = { [key]: value, lastModified: Date.now() };
- database.table("maps").update(selectedMap.id, change);
- const newMap = { ...selectedMap, ...change };
- setMaps((prevMaps) => {
- const newMaps = [...prevMaps];
- const i = newMaps.findIndex((map) => map.id === selectedMap.id);
- if (i > -1) {
- newMaps[i] = newMap;
- }
- return newMaps;
- });
- setSelectedMap(newMap);
+ await updateMap(selectedMapId, { [key]: value });
}
async function handleMapStateSettingsChange(key, value) {
- database.table("states").update(selectedMap.id, { [key]: value });
- setSelectedMapState((prevState) => ({ ...prevState, [key]: value }));
+ await updateMapState(selectedMapId, { [key]: value });
}
return (
-
+
handleImageUpload(event.target.files[0])}
type="file"
@@ -298,14 +189,14 @@ function SelectMapModal({
Select or import a map
-
+
);
}
diff --git a/src/modals/SelectTokensModal.js b/src/modals/SelectTokensModal.js
new file mode 100644
index 0000000..1b337f1
--- /dev/null
+++ b/src/modals/SelectTokensModal.js
@@ -0,0 +1,141 @@
+import React, { useRef, useContext, useState } from "react";
+import { Flex, Label, Button } from "theme-ui";
+import shortid from "shortid";
+
+import Modal from "../components/Modal";
+import ImageDrop from "../components/ImageDrop";
+import TokenTiles from "../components/token/TokenTiles";
+import TokenSettings from "../components/token/TokenSettings";
+
+import blobToBuffer from "../helpers/blobToBuffer";
+
+import TokenDataContext from "../contexts/TokenDataContext";
+import AuthContext from "../contexts/AuthContext";
+
+function SelectTokensModal({ isOpen, onRequestClose }) {
+ const { userId } = useContext(AuthContext);
+ const { ownedTokens, addToken, removeToken, updateToken } = useContext(
+ TokenDataContext
+ );
+ const fileInputRef = useRef();
+
+ const [imageLoading, setImageLoading] = useState(false);
+
+ const [selectedTokenId, setSelectedTokenId] = useState(null);
+ const selectedToken = ownedTokens.find(
+ (token) => token.id === selectedTokenId
+ );
+
+ function openImageDialog() {
+ if (fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ }
+
+ function handleTokenAdd(token) {
+ addToken(token);
+ }
+
+ function handleImageUpload(file) {
+ let name = "Unknown Map";
+ if (file.name) {
+ // Remove file extension
+ name = file.name.replace(/\.[^/.]+$/, "");
+ // Removed grid size expression
+ name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
+ // Clean string
+ name = name.replace(/ +/g, " ");
+ name = name.trim();
+ }
+ let image = new Image();
+ setImageLoading(true);
+ blobToBuffer(file).then((buffer) => {
+ // Copy file to avoid permissions issues
+ const blob = new Blob([buffer]);
+ // Create and load the image temporarily to get its dimensions
+ const url = URL.createObjectURL(blob);
+ image.onload = function () {
+ handleTokenAdd({
+ file: buffer,
+ name,
+ type: "file",
+ id: shortid.generate(),
+ created: Date.now(),
+ lastModified: Date.now(),
+ owner: userId,
+ defaultSize: 1,
+ isVehicle: false,
+ hideInSidebar: false,
+ });
+ setImageLoading(false);
+ };
+ image.src = url;
+
+ // Set file input to null to allow adding the same image 2 times in a row
+ fileInputRef.current.value = null;
+ });
+ }
+
+ function handleTokenSelect(token) {
+ setSelectedTokenId(token.id);
+ }
+
+ async function handleTokenRemove(id) {
+ await removeToken(id);
+ setSelectedTokenId(null);
+ }
+
+ /**
+ * Token settings
+ */
+ const [showMoreSettings, setShowMoreSettings] = useState(false);
+
+ async function handleTokenSettingsChange(key, value) {
+ await updateToken(selectedTokenId, { [key]: value });
+ }
+
+ return (
+
+
+ handleImageUpload(event.target.files[0])}
+ type="file"
+ accept="image/*"
+ style={{ display: "none" }}
+ ref={fileInputRef}
+ />
+
+
+
+
+
+ Done
+
+
+
+
+ );
+}
+
+export default SelectTokensModal;
diff --git a/src/modals/SettingsModal.js b/src/modals/SettingsModal.js
index 43fbe55..61a75ee 100644
--- a/src/modals/SettingsModal.js
+++ b/src/modals/SettingsModal.js
@@ -18,7 +18,26 @@ function SettingsModal({ isOpen, onRequestClose }) {
async function handleClearCache() {
await database.table("maps").where("owner").notEqual(userId).delete();
- // TODO: With custom tokens look up all tokens that aren't being used in a state
+ // Find all other peoples tokens who aren't benig used in a map state and delete them
+ const tokens = await database
+ .table("tokens")
+ .where("owner")
+ .notEqual(userId)
+ .toArray();
+ const states = await database.table("states").toArray();
+ for (let token of tokens) {
+ let inUse = false;
+ for (let state of states) {
+ for (let tokenState of Object.values(state.tokens)) {
+ if (token.id === tokenState.tokenId) {
+ inUse = true;
+ }
+ }
+ }
+ if (!inUse) {
+ database.table("tokens").delete(token.id);
+ }
+ }
window.location.reload();
}
diff --git a/src/routes/Game.js b/src/routes/Game.js
index f5d2c96..4e0e4f8 100644
--- a/src/routes/Game.js
+++ b/src/routes/Game.js
@@ -1,4 +1,10 @@
-import React, { useState, useEffect, useCallback, useContext } from "react";
+import React, {
+ useState,
+ useEffect,
+ useCallback,
+ useContext,
+ useRef,
+} from "react";
import { Flex, Box, Text } from "theme-ui";
import { useParams } from "react-router-dom";
@@ -17,15 +23,17 @@ import AuthModal from "../modals/AuthModal";
import AuthContext from "../contexts/AuthContext";
import DatabaseContext from "../contexts/DatabaseContext";
-
-import { tokens as defaultTokens } from "../tokens";
+import TokenDataContext from "../contexts/TokenDataContext";
+import MapDataContext from "../contexts/MapDataContext";
+import MapLoadingContext from "../contexts/MapLoadingContext";
+import { MapStageProvider } from "../contexts/MapStageContext";
function Game() {
- const { database } = useContext(DatabaseContext);
const { id: gameId } = useParams();
const { authenticationStatus, userId, nickname, setNickname } = useContext(
AuthContext
);
+ const { assetLoadStart, assetLoadFinish } = useContext(MapLoadingContext);
const { peers, socket } = useSession(
gameId,
@@ -37,64 +45,70 @@ function Game() {
handlePeerError
);
+ const { putToken, getToken } = useContext(TokenDataContext);
+ const { putMap, getMap } = useContext(MapDataContext);
+
/**
* Map state
*/
- const [map, setMap] = useState(null);
- const [mapState, setMapState] = useState(null);
- const [mapLoading, setMapLoading] = useState(false);
+ const [currentMap, setCurrentMap] = useState(null);
+ const [currentMapState, setCurrentMapState] = useState(null);
const canEditMapDrawing =
- map !== null &&
- mapState !== null &&
- (mapState.editFlags.includes("drawing") || map.owner === userId);
+ currentMap !== null &&
+ currentMapState !== null &&
+ (currentMapState.editFlags.includes("drawing") ||
+ currentMap.owner === userId);
const canEditFogDrawing =
- map !== null &&
- mapState !== null &&
- (mapState.editFlags.includes("fog") || map.owner === userId);
+ currentMap !== null &&
+ currentMapState !== null &&
+ (currentMapState.editFlags.includes("fog") || currentMap.owner === userId);
const disabledMapTokens = {};
// If we have a map and state and have the token permission disabled
// and are not the map owner
if (
- mapState !== null &&
- map !== null &&
- !mapState.editFlags.includes("tokens") &&
- map.owner !== userId
+ currentMapState !== null &&
+ currentMap !== null &&
+ !currentMapState.editFlags.includes("tokens") &&
+ currentMap.owner !== userId
) {
- for (let token of Object.values(mapState.tokens)) {
+ for (let token of Object.values(currentMapState.tokens)) {
if (token.owner !== userId) {
disabledMapTokens[token.id] = true;
}
}
}
+ const { database } = useContext(DatabaseContext);
// Sync the map state to the database after 500ms of inactivity
- const debouncedMapState = useDebounce(mapState, 500);
+ const debouncedMapState = useDebounce(currentMapState, 500);
useEffect(() => {
if (
debouncedMapState &&
debouncedMapState.mapId &&
- map &&
- map.owner === userId &&
+ currentMap &&
+ currentMap.owner === userId &&
database
) {
+ // Update the database directly to avoid re-renders
database
.table("states")
.update(debouncedMapState.mapId, debouncedMapState);
}
- }, [map, debouncedMapState, userId, database]);
+ }, [currentMap, debouncedMapState, userId, database]);
function handleMapChange(newMap, newMapState) {
- setMapState(newMapState);
- setMap(newMap);
+ setCurrentMapState(newMapState);
+ setCurrentMap(newMap);
for (let peer of Object.values(peers)) {
// Clear the map so the new map state isn't shown on an old map
peer.connection.send({ id: "map", data: null });
peer.connection.send({ id: "mapState", data: newMapState });
sendMapDataToPeer(peer, newMap);
+ sendTokensToPeer(peer, newMapState);
}
}
@@ -110,42 +124,14 @@ function Game() {
}
function handleMapStateChange(newMapState) {
- setMapState(newMapState);
+ setCurrentMapState(newMapState);
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "mapState", data: newMapState });
}
}
- async function handleMapTokenStateChange(token) {
- if (mapState === null) {
- return;
- }
- setMapState((prevMapState) => ({
- ...prevMapState,
- tokens: {
- ...prevMapState.tokens,
- [token.id]: token,
- },
- }));
- for (let peer of Object.values(peers)) {
- const data = { [token.id]: token };
- peer.connection.send({ id: "tokenStateEdit", data });
- }
- }
-
- function handleMapTokenStateRemove(token) {
- setMapState((prevMapState) => {
- const { [token.id]: old, ...rest } = prevMapState.tokens;
- return { ...prevMapState, tokens: rest };
- });
- for (let peer of Object.values(peers)) {
- const data = { [token.id]: token };
- peer.connection.send({ id: "tokenStateRemove", data });
- }
- }
-
function addMapDrawActions(actions, indexKey, actionsKey) {
- setMapState((prevMapState) => {
+ setCurrentMapState((prevMapState) => {
const newActions = [
...prevMapState[actionsKey].slice(0, prevMapState[indexKey] + 1),
...actions,
@@ -161,11 +147,11 @@ function Game() {
function updateDrawActionIndex(change, indexKey, actionsKey, peerId) {
const newIndex = Math.min(
- Math.max(mapState[indexKey] + change, -1),
- mapState[actionsKey].length - 1
+ Math.max(currentMapState[indexKey] + change, -1),
+ currentMapState[actionsKey].length - 1
);
- setMapState((prevMapState) => ({
+ setCurrentMapState((prevMapState) => ({
...prevMapState,
[indexKey]: newIndex,
}));
@@ -230,6 +216,67 @@ function Game() {
}
}
+ /**
+ * Token state
+ */
+
+ // Get all tokens from a token state and send it to a peer
+ function sendTokensToPeer(peer, state) {
+ let sentTokens = {};
+ for (let tokenState of Object.values(state.tokens)) {
+ const token = getToken(tokenState.tokenId);
+ if (
+ token &&
+ token.type === "file" &&
+ !(tokenState.tokenId in sentTokens)
+ ) {
+ sentTokens[tokenState.tokenId] = true;
+ // Omit file from token peer will request file if needed
+ const { file, ...rest } = token;
+ peer.connection.send({ id: "token", data: rest });
+ }
+ }
+ }
+
+ async function handleMapTokenStateCreate(tokenState) {
+ // If file type token send the token to the other peers
+ const token = getToken(tokenState.tokenId);
+ if (token && token.type === "file") {
+ const { file, ...rest } = token;
+ for (let peer of Object.values(peers)) {
+ peer.connection.send({ id: "token", data: rest });
+ }
+ }
+ handleMapTokenStateChange({ [tokenState.id]: tokenState });
+ }
+
+ function handleMapTokenStateChange(change) {
+ if (currentMapState === null) {
+ return;
+ }
+ setCurrentMapState((prevMapState) => ({
+ ...prevMapState,
+ tokens: {
+ ...prevMapState.tokens,
+ ...change,
+ },
+ }));
+ for (let peer of Object.values(peers)) {
+ peer.connection.send({ id: "tokenStateEdit", data: change });
+ }
+ }
+
+ function handleMapTokenStateRemove(tokenState) {
+ setCurrentMapState((prevMapState) => {
+ const { [tokenState.id]: old, ...rest } = prevMapState.tokens;
+ return { ...prevMapState, tokens: rest };
+ });
+ for (let peer of Object.values(peers)) {
+ const data = { [tokenState.id]: tokenState };
+ peer.connection.send({ id: "tokenStateRemove", data });
+ }
+ }
+
/**
* Party state
*/
@@ -259,63 +306,84 @@ function Game() {
function handlePeerData({ data, peer }) {
if (data.id === "sync") {
- if (mapState) {
- peer.connection.send({ id: "mapState", data: mapState });
+ if (currentMapState) {
+ peer.connection.send({ id: "mapState", data: currentMapState });
+ sendTokensToPeer(peer, currentMapState);
}
- if (map) {
- sendMapDataToPeer(peer, map);
+ if (currentMap) {
+ sendMapDataToPeer(peer, currentMap);
}
}
if (data.id === "map") {
const newMap = data.data;
// If is a file map check cache and request the full file if outdated
if (newMap && newMap.type === "file") {
- database
- .table("maps")
- .get(newMap.id)
- .then((cachedMap) => {
- if (cachedMap && cachedMap.lastModified === newMap.lastModified) {
- setMap(cachedMap);
- } else {
- setMapLoading(true);
- peer.connection.send({ id: "mapRequest" });
- }
- });
+ const cachedMap = getMap(newMap.id);
+ if (cachedMap && cachedMap.lastModified === newMap.lastModified) {
+ setCurrentMap(cachedMap);
+ } else {
+ assetLoadStart();
+ peer.connection.send({ id: "mapRequest", data: newMap.id });
+ }
} else {
- setMap(newMap);
+ setCurrentMap(newMap);
}
}
// Send full map data including file
if (data.id === "mapRequest") {
+ const map = getMap(data.data);
peer.connection.send({ id: "mapResponse", data: map });
}
// A new map response with a file attached
if (data.id === "mapResponse") {
- setMapLoading(false);
+ assetLoadFinish();
if (data.data && data.data.type === "file") {
const newMap = { ...data.data, file: data.data.file };
- // Store in db
- database
- .table("maps")
- .put(newMap)
- .then(() => {
- setMap(newMap);
- });
+ putMap(newMap).then(() => {
+ setCurrentMap(newMap);
+ });
} else {
- setMap(data.data);
+ setCurrentMap(data.data);
}
}
if (data.id === "mapState") {
- setMapState(data.data);
+ setCurrentMapState(data.data);
+ }
+ if (data.id === "token") {
+ const newToken = data.data;
+ if (newToken && newToken.type === "file") {
+ const cachedToken = getToken(newToken.id);
+ if (
+ !cachedToken ||
+ cachedToken.lastModified !== newToken.lastModified
+ ) {
+ assetLoadStart();
+ peer.connection.send({
+ id: "tokenRequest",
+ data: newToken.id,
+ });
+ }
+ }
+ }
+ if (data.id === "tokenRequest") {
+ const token = getToken(data.data);
+ peer.connection.send({ id: "tokenResponse", data: token });
+ }
+ if (data.id === "tokenResponse") {
+ assetLoadFinish();
+ const newToken = data.data;
+ if (newToken && newToken.type === "file") {
+ putToken(newToken);
+ }
}
if (data.id === "tokenStateEdit") {
- setMapState((prevMapState) => ({
+ setCurrentMapState((prevMapState) => ({
...prevMapState,
tokens: { ...prevMapState.tokens, ...data.data },
}));
}
if (data.id === "tokenStateRemove") {
- setMapState((prevMapState) => ({
+ setCurrentMapState((prevMapState) => ({
...prevMapState,
tokens: omit(prevMapState.tokens, Object.keys(data.data)),
}));
@@ -330,7 +398,7 @@ function Game() {
addMapDrawActions(data.data, "mapDrawActionIndex", "mapDrawActions");
}
if (data.id === "mapDrawIndex") {
- setMapState((prevMapState) => ({
+ setCurrentMapState((prevMapState) => ({
...prevMapState,
mapDrawActionIndex: data.data,
}));
@@ -339,7 +407,7 @@ function Game() {
addMapDrawActions(data.data, "fogDrawActionIndex", "fogDrawActions");
}
if (data.id === "mapFogIndex") {
- setMapState((prevMapState) => ({
+ setCurrentMapState((prevMapState) => ({
...prevMapState,
fogDrawActionIndex: data.data,
}));
@@ -438,30 +506,19 @@ function Game() {
}
}, [stream, peers, handleStreamEnd]);
- /**
- * Token data
- */
- const [tokens, setTokens] = useState([]);
- useEffect(() => {
- if (!userId) {
- return;
- }
- const defaultTokensWithIds = [];
- for (let defaultToken of defaultTokens) {
- defaultTokensWithIds.push({
- ...defaultToken,
- id: `__default-${defaultToken.name}`,
- owner: userId,
- });
- }
- setTokens(defaultTokensWithIds);
- }, [userId]);
+ // A ref to the Konva stage
+ // the ref will be assigned in the MapInteraction component
+ const mapStageRef = useRef();
return (
- <>
+
-
+
setPeerError(null)}>
@@ -508,7 +560,7 @@ function Game() {
{authenticationStatus === "unknown" && }
- >
+
);
}
diff --git a/src/routes/Home.js b/src/routes/Home.js
index 5a7af5f..b1265cc 100644
--- a/src/routes/Home.js
+++ b/src/routes/Home.js
@@ -51,7 +51,7 @@ function Home() {
Join Game
- Beta v1.2.1
+ Beta v1.3.0
Release Notes
+
+
+
diff --git a/src/tokens/Aberration.png b/src/tokens/Aberration.png
new file mode 100644
index 0000000..ecda159
Binary files /dev/null and b/src/tokens/Aberration.png differ
diff --git a/src/tokens/Artificer.png b/src/tokens/Artificer.png
new file mode 100644
index 0000000..f4361d0
Binary files /dev/null and b/src/tokens/Artificer.png differ
diff --git a/src/tokens/Axes.png b/src/tokens/Axes.png
deleted file mode 100644
index 7bd7f64..0000000
Binary files a/src/tokens/Axes.png and /dev/null differ
diff --git a/src/tokens/Barbarian.png b/src/tokens/Barbarian.png
new file mode 100644
index 0000000..c26cfef
Binary files /dev/null and b/src/tokens/Barbarian.png differ
diff --git a/src/tokens/Bard.png b/src/tokens/Bard.png
new file mode 100644
index 0000000..39da152
Binary files /dev/null and b/src/tokens/Bard.png differ
diff --git a/src/tokens/Beast.png b/src/tokens/Beast.png
new file mode 100644
index 0000000..b4b901d
Binary files /dev/null and b/src/tokens/Beast.png differ
diff --git a/src/tokens/Bird.png b/src/tokens/Bird.png
deleted file mode 100644
index 9473c7b..0000000
Binary files a/src/tokens/Bird.png and /dev/null differ
diff --git a/src/tokens/Blood Hunter.png b/src/tokens/Blood Hunter.png
new file mode 100644
index 0000000..fa708a5
Binary files /dev/null and b/src/tokens/Blood Hunter.png differ
diff --git a/src/tokens/Book.png b/src/tokens/Book.png
deleted file mode 100644
index 944a2c5..0000000
Binary files a/src/tokens/Book.png and /dev/null differ
diff --git a/src/tokens/Celestial.png b/src/tokens/Celestial.png
new file mode 100644
index 0000000..eae7a0a
Binary files /dev/null and b/src/tokens/Celestial.png differ
diff --git a/src/tokens/Cleric.png b/src/tokens/Cleric.png
new file mode 100644
index 0000000..ef668bd
Binary files /dev/null and b/src/tokens/Cleric.png differ
diff --git a/src/tokens/Construct.png b/src/tokens/Construct.png
new file mode 100644
index 0000000..006a5ac
Binary files /dev/null and b/src/tokens/Construct.png differ
diff --git a/src/tokens/Crown.png b/src/tokens/Crown.png
deleted file mode 100644
index 9158249..0000000
Binary files a/src/tokens/Crown.png and /dev/null differ
diff --git a/src/tokens/Dragon.png b/src/tokens/Dragon.png
index 2ec5e83..5863bfc 100644
Binary files a/src/tokens/Dragon.png and b/src/tokens/Dragon.png differ
diff --git a/src/tokens/Druid.png b/src/tokens/Druid.png
new file mode 100644
index 0000000..db41ad4
Binary files /dev/null and b/src/tokens/Druid.png differ
diff --git a/src/tokens/Elemental.png b/src/tokens/Elemental.png
new file mode 100644
index 0000000..05d79be
Binary files /dev/null and b/src/tokens/Elemental.png differ
diff --git a/src/tokens/Eye.png b/src/tokens/Eye.png
deleted file mode 100644
index 45219b0..0000000
Binary files a/src/tokens/Eye.png and /dev/null differ
diff --git a/src/tokens/Fey.png b/src/tokens/Fey.png
new file mode 100644
index 0000000..e0da46d
Binary files /dev/null and b/src/tokens/Fey.png differ
diff --git a/src/tokens/Fiend.png b/src/tokens/Fiend.png
new file mode 100644
index 0000000..78be9a3
Binary files /dev/null and b/src/tokens/Fiend.png differ
diff --git a/src/tokens/Fighter.png b/src/tokens/Fighter.png
new file mode 100644
index 0000000..b647532
Binary files /dev/null and b/src/tokens/Fighter.png differ
diff --git a/src/tokens/Fist.png b/src/tokens/Fist.png
deleted file mode 100644
index 0a2abc9..0000000
Binary files a/src/tokens/Fist.png and /dev/null differ
diff --git a/src/tokens/Giant.png b/src/tokens/Giant.png
new file mode 100644
index 0000000..f36eb36
Binary files /dev/null and b/src/tokens/Giant.png differ
diff --git a/src/tokens/Goblinoid.png b/src/tokens/Goblinoid.png
new file mode 100644
index 0000000..54a3fbb
Binary files /dev/null and b/src/tokens/Goblinoid.png differ
diff --git a/src/tokens/Horse.png b/src/tokens/Horse.png
deleted file mode 100644
index b43e98f..0000000
Binary files a/src/tokens/Horse.png and /dev/null differ
diff --git a/src/tokens/Humanoid.png b/src/tokens/Humanoid.png
new file mode 100644
index 0000000..112f556
Binary files /dev/null and b/src/tokens/Humanoid.png differ
diff --git a/src/tokens/Leaf.png b/src/tokens/Leaf.png
deleted file mode 100644
index 63b4ae0..0000000
Binary files a/src/tokens/Leaf.png and /dev/null differ
diff --git a/src/tokens/Lion.png b/src/tokens/Lion.png
deleted file mode 100644
index de3f2c1..0000000
Binary files a/src/tokens/Lion.png and /dev/null differ
diff --git a/src/tokens/Money.png b/src/tokens/Money.png
deleted file mode 100644
index c7fc359..0000000
Binary files a/src/tokens/Money.png and /dev/null differ
diff --git a/src/tokens/Monk.png b/src/tokens/Monk.png
new file mode 100644
index 0000000..c04998e
Binary files /dev/null and b/src/tokens/Monk.png differ
diff --git a/src/tokens/Monstrosity.png b/src/tokens/Monstrosity.png
new file mode 100644
index 0000000..c05d13b
Binary files /dev/null and b/src/tokens/Monstrosity.png differ
diff --git a/src/tokens/Moon.png b/src/tokens/Moon.png
deleted file mode 100644
index 91ee3a8..0000000
Binary files a/src/tokens/Moon.png and /dev/null differ
diff --git a/src/tokens/Ooze.png b/src/tokens/Ooze.png
new file mode 100644
index 0000000..10551a1
Binary files /dev/null and b/src/tokens/Ooze.png differ
diff --git a/src/tokens/Paladin.png b/src/tokens/Paladin.png
new file mode 100644
index 0000000..4d05148
Binary files /dev/null and b/src/tokens/Paladin.png differ
diff --git a/src/tokens/Plant.png b/src/tokens/Plant.png
new file mode 100644
index 0000000..d163956
Binary files /dev/null and b/src/tokens/Plant.png differ
diff --git a/src/tokens/Potion.png b/src/tokens/Potion.png
deleted file mode 100644
index 056dd28..0000000
Binary files a/src/tokens/Potion.png and /dev/null differ
diff --git a/src/tokens/Ranger.png b/src/tokens/Ranger.png
new file mode 100644
index 0000000..4ccd1c7
Binary files /dev/null and b/src/tokens/Ranger.png differ
diff --git a/src/tokens/Rouge.png b/src/tokens/Rouge.png
new file mode 100644
index 0000000..25ccf4f
Binary files /dev/null and b/src/tokens/Rouge.png differ
diff --git a/src/tokens/Shapechanger.png b/src/tokens/Shapechanger.png
new file mode 100644
index 0000000..3ac8666
Binary files /dev/null and b/src/tokens/Shapechanger.png differ
diff --git a/src/tokens/Shield.png b/src/tokens/Shield.png
deleted file mode 100644
index e4eba5a..0000000
Binary files a/src/tokens/Shield.png and /dev/null differ
diff --git a/src/tokens/Skull.png b/src/tokens/Skull.png
deleted file mode 100644
index 3939468..0000000
Binary files a/src/tokens/Skull.png and /dev/null differ
diff --git a/src/tokens/Snake.png b/src/tokens/Snake.png
deleted file mode 100644
index 4a12bc8..0000000
Binary files a/src/tokens/Snake.png and /dev/null differ
diff --git a/src/tokens/Sorcerer.png b/src/tokens/Sorcerer.png
new file mode 100644
index 0000000..f53b5e3
Binary files /dev/null and b/src/tokens/Sorcerer.png differ
diff --git a/src/tokens/Sun.png b/src/tokens/Sun.png
deleted file mode 100644
index 093c458..0000000
Binary files a/src/tokens/Sun.png and /dev/null differ
diff --git a/src/tokens/Swords.png b/src/tokens/Swords.png
deleted file mode 100644
index 0483427..0000000
Binary files a/src/tokens/Swords.png and /dev/null differ
diff --git a/src/tokens/Titan.png b/src/tokens/Titan.png
new file mode 100644
index 0000000..8b00c9e
Binary files /dev/null and b/src/tokens/Titan.png differ
diff --git a/src/tokens/Tree.png b/src/tokens/Tree.png
deleted file mode 100644
index 20373a1..0000000
Binary files a/src/tokens/Tree.png and /dev/null differ
diff --git a/src/tokens/Triangle.png b/src/tokens/Triangle.png
deleted file mode 100644
index 753f889..0000000
Binary files a/src/tokens/Triangle.png and /dev/null differ
diff --git a/src/tokens/Undead.png b/src/tokens/Undead.png
new file mode 100644
index 0000000..37c494a
Binary files /dev/null and b/src/tokens/Undead.png differ
diff --git a/src/tokens/Unknown.png b/src/tokens/Unknown.png
new file mode 100644
index 0000000..8987eed
Binary files /dev/null and b/src/tokens/Unknown.png differ
diff --git a/src/tokens/Warlock.png b/src/tokens/Warlock.png
new file mode 100644
index 0000000..f522b64
Binary files /dev/null and b/src/tokens/Warlock.png differ
diff --git a/src/tokens/Wizard.png b/src/tokens/Wizard.png
new file mode 100644
index 0000000..93f5970
Binary files /dev/null and b/src/tokens/Wizard.png differ
diff --git a/src/tokens/index.js b/src/tokens/index.js
index 1132688..acfd30b 100644
--- a/src/tokens/index.js
+++ b/src/tokens/index.js
@@ -1,49 +1,92 @@
-import axes from "./Axes.png";
-import bird from "./Bird.png";
-import book from "./Book.png";
-import crown from "./Crown.png";
+import Case from "case";
+
+import aberration from "./Aberration.png";
+import artificer from "./Artificer.png";
+import barbarian from "./Barbarian.png";
+import bard from "./Bard.png";
+import beast from "./Beast.png";
+import bloodHunter from "./Blood Hunter.png";
+import celestial from "./Celestial.png";
+import cleric from "./Cleric.png";
+import construct from "./Construct.png";
import dragon from "./Dragon.png";
-import eye from "./Eye.png";
-import fist from "./Fist.png";
-import horse from "./Horse.png";
-import leaf from "./Leaf.png";
-import lion from "./Lion.png";
-import money from "./Money.png";
-import moon from "./Moon.png";
-import potion from "./Potion.png";
-import shield from "./Shield.png";
-import skull from "./Skull.png";
-import snake from "./Snake.png";
-import sun from "./Sun.png";
-import swords from "./Swords.png";
-import tree from "./Tree.png";
-import triangle from "./Triangle.png";
+import druid from "./Druid.png";
+import elemental from "./Elemental.png";
+import fey from "./Fey.png";
+import fiend from "./Fiend.png";
+import fighter from "./Fighter.png";
+import giant from "./Giant.png";
+import goblinoid from "./Goblinoid.png";
+import humanoid from "./Humanoid.png";
+import monk from "./Monk.png";
+import monstrosity from "./Monstrosity.png";
+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 shapechanger from "./Shapechanger.png";
+import sorcerer from "./Sorcerer.png";
+import titan from "./Titan.png";
+import undead from "./Undead.png";
+import warlock from "./Warlock.png";
+import wizard from "./Wizard.png";
+import unknown from "./Unknown.png";
export const tokenSources = {
- axes,
- bird,
- book,
- crown,
+ barbarian,
+ bard,
+ cleric,
+ druid,
+ fighter,
+ monk,
+ paladin,
+ ranger,
+ rouge,
+ sorcerer,
+ warlock,
+ wizard,
+ artificer,
+ bloodHunter,
+ aberration,
+ beast,
+ celestial,
+ construct,
dragon,
- eye,
- fist,
- horse,
- leaf,
- lion,
- money,
- moon,
- potion,
- shield,
- skull,
- snake,
- sun,
- swords,
- tree,
- triangle,
+ elemental,
+ fey,
+ fiend,
+ giant,
+ goblinoid,
+ humanoid,
+ monstrosity,
+ ooze,
+ plant,
+ shapechanger,
+ titan,
+ undead,
};
+function getDefaultTokenSize(key) {
+ switch (key) {
+ case "dragon":
+ case "elemental":
+ case "giant":
+ case "ooze":
+ case "titan":
+ return 2;
+ default:
+ return 1;
+ }
+}
+
export const tokens = Object.keys(tokenSources).map((key) => ({
key,
- name: key.charAt(0).toUpperCase() + key.slice(1),
+ name: Case.capital(key),
type: "default",
+ defaultSize: getDefaultTokenSize(key),
+ isVehicle: false,
+ hideInSidebar: false,
}));
+
+export const unknownSource = unknown;
diff --git a/yarn.lock b/yarn.lock
index 9cb4b13..df494c8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -863,6 +863,13 @@
dependencies:
regenerator-runtime "^0.13.4"
+"@babel/runtime@^7.3.1":
+ version "7.9.6"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.6.tgz#a9102eb5cadedf3f31d08a9ecf294af7827ea29f"
+ integrity sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==
+ dependencies:
+ regenerator-runtime "^0.13.4"
+
"@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.4", "@babel/runtime@^7.8.4":
version "7.8.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.7.tgz#8fefce9802db54881ba59f90bb28719b4996324d"
@@ -2178,6 +2185,10 @@ alphanum-sort@^1.0.0:
resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=
+ammo.js@kripken/ammo.js#aab297a4164779c3a9d8dc8d9da26958de3cb778:
+ version "0.0.2"
+ resolved "https://codeload.github.com/kripken/ammo.js/tar.gz/aab297a4164779c3a9d8dc8d9da26958de3cb778"
+
ansi-colors@^3.0.0:
version "3.2.4"
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf"
@@ -2642,6 +2653,24 @@ babylon@^6.18.0:
resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
+babylonjs-gltf2interface@4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/babylonjs-gltf2interface/-/babylonjs-gltf2interface-4.1.0.tgz#95ec994e352ac5cb74869e238218a1b4df18e2f4"
+ integrity sha512-H2obg+4t8bcmLyzGiOQqmUaTQqTu+6mJUlsMWZvmRBf0k2fQVeTdAkH7aDy6HVIz/THvpIx4ntG1Lsyquvmc5Q==
+
+babylonjs-loaders@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/babylonjs-loaders/-/babylonjs-loaders-4.1.0.tgz#b056423b98c1e3a3962491ec72dce5e0b8852295"
+ integrity sha512-gNC+XEVI5cLJLVRTlkFHVfSY4EZS0VzWzEmNb8M49ZMFNuqOuHsVnQZg4Vms9e4LgvNtws4Z0SWrRanZnkIX5g==
+ dependencies:
+ babylonjs "4.1.0"
+ babylonjs-gltf2interface "4.1.0"
+
+babylonjs@4.1.0, babylonjs@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/babylonjs/-/babylonjs-4.1.0.tgz#a2d1d6765795e9d44f002831554d63d6275394bd"
+ integrity sha512-MnaH1BQIL+PYgqGaAvGVdP8yd7nM1j6sbQi/K/6+RlkHPxIETW2NbjqxiW7Sywgy7r3PwqWT/gxG4Bz95Z6/yA==
+
backo2@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
@@ -3089,6 +3118,11 @@ case-sensitive-paths-webpack-plugin@2.3.0:
resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.3.0.tgz#23ac613cc9a856e4f88ff8bb73bbb5e989825cf7"
integrity sha512-/4YgnZS8y1UXXmC02xD5rRrBEu6T5ub+mQHLNRj0fzTRbgdBYhsNo2V5EqwgqrExjxsjtF/OpAKAMkKsxbD5XQ==
+case@^1.6.3:
+ version "1.6.3"
+ resolved "https://registry.yarnpkg.com/case/-/case-1.6.3.tgz#0a4386e3e9825351ca2e6216c60467ff5f1ea1c9"
+ integrity sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ==
+
caseless@~0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
@@ -6868,6 +6902,11 @@ kleur@^3.0.3:
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
+konva@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/konva/-/konva-6.0.0.tgz#9b3d13a4622f353c4ce736fbf1fa4b6483240649"
+ integrity sha512-YTwmtz3KzbzdC0KDRHWLzuk0KXB4NUdaQqytrxacXE1C39V6wCk7Nnu0wgq+GdGbG6m8A1qiEU9TSJ19qdIzDw==
+
last-call-webpack-plugin@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555"
@@ -6988,6 +7027,11 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
+lodash-es@^4.17.15:
+ version "4.17.15"
+ resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
+ integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==
+
lodash._reinterpolate@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
@@ -8966,7 +9010,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.3"
-prop-types@^15.5.10, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
+prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@@ -9088,6 +9132,11 @@ queue-microtask@^1.1.0:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.1.2.tgz#139bf8186db0c545017ec66c2664ac646d5c571e"
integrity sha512-F9wwNePtXrzZenAB3ax0Y8TSKGvuB7Qw16J30hspEUTbfUM+H827XyN3rlpwhVmtm5wuZtbKIHjOnwDn7MUxWQ==
+raf-schd@^4.0.2:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0"
+ integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ==
+
raf@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
@@ -9209,6 +9258,14 @@ react-is@^16.8.1, react-is@^16.8.4:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c"
integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==
+react-konva@^16.13.0-3:
+ version "16.13.0-3"
+ resolved "https://registry.yarnpkg.com/react-konva/-/react-konva-16.13.0-3.tgz#9ef1e813c8b2dd61b54b26151ccbdeed52b89a80"
+ integrity sha512-U9az1RidQD4c64oZoHiiv6GU6h2ggHO30nZDqfQWuBTH+Bl2wij6Z0NgbUyVyN1IpKIgXRiEKMS9idlxhAzTXQ==
+ dependencies:
+ react-reconciler "^0.25.1"
+ scheduler "^0.19.1"
+
react-lifecycles-compat@^3.0.0:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
@@ -9238,6 +9295,27 @@ react-modal@^3.11.2:
react-lifecycles-compat "^3.0.0"
warning "^4.0.3"
+react-reconciler@^0.25.1:
+ version "0.25.1"
+ resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.25.1.tgz#f9814d59d115e1210762287ce987801529363aaa"
+ integrity sha512-R5UwsIvRcSs3w8n9k3tBoTtUHdVhu9u84EG7E5M0Jk9F5i6DA1pQzPfUZd6opYWGy56MJOtV3VADzy6DRwYDjw==
+ dependencies:
+ loose-envify "^1.1.0"
+ object-assign "^4.1.1"
+ prop-types "^15.6.2"
+ scheduler "^0.19.1"
+
+react-resize-detector@^4.2.3:
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-4.2.3.tgz#7df258668a30bdfd88e655bbdb27db7fd7b23127"
+ integrity sha512-4AeS6lxdz2KOgDZaOVt1duoDHrbYwSrUX32KeM9j6t9ISyRphoJbTRCMS1aPFxZHFqcCGLT1gMl3lEcSWZNW0A==
+ dependencies:
+ lodash "^4.17.15"
+ lodash-es "^4.17.15"
+ prop-types "^15.7.2"
+ raf-schd "^4.0.2"
+ resize-observer-polyfill "^1.5.1"
+
react-router-dom@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18"
@@ -9334,6 +9412,19 @@ react-scripts@3.4.0:
optionalDependencies:
fsevents "2.1.2"
+react-spring@^8.0.27:
+ version "8.0.27"
+ resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-8.0.27.tgz#97d4dee677f41e0b2adcb696f3839680a3aa356a"
+ integrity sha512-nDpWBe3ZVezukNRandTeLSPcwwTMjNVu1IDq9qA/AMiUqHuRN4BeSWvKr3eIxxg1vtiYiOLy4FqdfCP5IoP77g==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ prop-types "^15.5.8"
+
+react-use-gesture@^7.0.15:
+ version "7.0.15"
+ resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-7.0.15.tgz#93c651e916a31cfb12d079e7fa1543d5b0511e07"
+ integrity sha512-vHQkaa7oUbSDTAcFk9huQXa7E8KPrZH91erPuOMoqZT513qvtbb/SzTQ33lHc71/kOoJkMbzOkc4uoA4sT7Ogg==
+
react@^16.13.0:
version "16.13.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.13.0.tgz#d046eabcdf64e457bbeed1e792e235e1b9934cf7"
@@ -9891,6 +9982,14 @@ scheduler@^0.19.0:
loose-envify "^1.1.0"
object-assign "^4.1.1"
+scheduler@^0.19.1:
+ version "0.19.1"
+ resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196"
+ integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==
+ dependencies:
+ loose-envify "^1.1.0"
+ object-assign "^4.1.1"
+
schema-utils@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770"
@@ -11171,6 +11270,11 @@ url@^0.11.0:
punycode "1.3.2"
querystring "0.2.0"
+use-image@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/use-image/-/use-image-1.0.5.tgz#51fa23fe705c3ad0d4ae3eca6cf636551c591693"
+ integrity sha512-tv1tHn1GRcbrifNzCPAN81Z1Fayfd3GXkUDFx0/dUkqqPmADNDRoCyT9MqrUX9GPcofsQl6SREPr9Zavm3dRTQ==
+
use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"