diff --git a/package.json b/package.json index 341f414..247f710 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "owlbear-rodeo", - "version": "1.2.1", + "version": "1.3.0", "private": true, "dependencies": { "@msgpack/msgpack": "^1.12.1", @@ -8,24 +8,34 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", + "ammo.js": "kripken/ammo.js#aab297a4164779c3a9d8dc8d9da26958de3cb778", + "babylonjs": "^4.1.0", + "babylonjs-loaders": "^4.1.0", + "case": "^1.6.3", "dexie": "^2.0.4", "fake-indexeddb": "^3.0.0", "interactjs": "^1.9.7", + "konva": "^6.0.0", "normalize-wheel": "^1.0.1", "raw.macro": "^0.3.0", "react": "^16.13.0", "react-dom": "^16.13.0", + "react-konva": "^16.13.0-3", "react-markdown": "^4.3.1", "react-modal": "^3.11.2", + "react-resize-detector": "^4.2.3", "react-router-dom": "^5.1.2", "react-router-hash-link": "^1.2.2", "react-scripts": "3.4.0", + "react-spring": "^8.0.27", + "react-use-gesture": "^7.0.15", "shortid": "^2.2.15", "simple-peer": "^9.6.2", "simplebar-react": "^2.1.0", "simplify-js": "^1.2.4", "socket.io-client": "^2.3.0", "theme-ui": "^0.3.1", + "use-image": "^1.0.5", "webrtc-adapter": "^7.5.1" }, "scripts": { diff --git a/src/App.js b/src/App.js index c666293..3cac500 100644 --- a/src/App.js +++ b/src/App.js @@ -11,6 +11,9 @@ import ReleaseNotes from "./routes/ReleaseNotes"; import { AuthProvider } from "./contexts/AuthContext"; import { DatabaseProvider } from "./contexts/DatabaseContext"; +import { MapDataProvider } from "./contexts/MapDataContext"; +import { TokenDataProvider } from "./contexts/TokenDataContext"; +import { MapLoadingProvider } from "./contexts/MapLoadingContext"; function App() { return ( @@ -29,7 +32,13 @@ function App() { - + + + + + + + diff --git a/src/components/map/controls/Divider.js b/src/components/Divider.js similarity index 83% rename from src/components/map/controls/Divider.js rename to src/components/Divider.js index 485e2d6..853aa0c 100644 --- a/src/components/map/controls/Divider.js +++ b/src/components/Divider.js @@ -1,12 +1,12 @@ import React from "react"; import { Divider } from "theme-ui"; -function StyledDivider({ vertical }) { +function StyledDivider({ vertical, color }) { return ( + {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({ @@ -115,7 +109,7 @@ function MapSettings({ }} aria-label={showMore ? "Show Less" : "Show More"} title={showMore ? "Show Less" : "Show More"} - disabled={map === null} + disabled={!map} > diff --git a/src/components/map/MapTile.js b/src/components/map/MapTile.js index 38c4989..640b8aa 100644 --- a/src/components/map/MapTile.js +++ b/src/components/map/MapTile.js @@ -6,7 +6,7 @@ import ResetMapIcon from "../../icons/ResetMapIcon"; import ExpandMoreDotIcon from "../../icons/ExpandMoreDotIcon"; import useDataSource from "../../helpers/useDataSource"; -import { mapSources as defaultMapSources } from "../../maps"; +import { mapSources as defaultMapSources, unknownSource } from "../../maps"; function MapTile({ map, @@ -15,9 +15,9 @@ function MapTile({ onMapSelect, onMapRemove, onMapReset, - onSubmit, + onDone, }) { - const mapSource = useDataSource(map, defaultMapSources); + const mapSource = useDataSource(map, defaultMapSources, unknownSource); const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false); const isDefault = map.type === "default"; const hasMapState = @@ -108,7 +108,7 @@ function MapTile({ }} onDoubleClick={(e) => { if (!isMapTileMenuOpen) { - onSubmit(e); + onDone(e); } }} > diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js index 4ebc275..bae2a30 100644 --- a/src/components/map/MapTiles.js +++ b/src/components/map/MapTiles.js @@ -17,7 +17,7 @@ function MapTiles({ onMapAdd, onMapRemove, onMapReset, - onSubmit, + onDone, }) { const { databaseStatus } = useContext(DatabaseContext); return ( @@ -69,7 +69,7 @@ function MapTiles({ onMapSelect={onMapSelect} onMapRemove={onMapRemove} onMapReset={onMapReset} - onSubmit={onSubmit} + onDone={onDone} /> ))} 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 ( - - - - - - {label} - - - - + + + {}} + /> + ); } 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 ( + + + {isExpanded ? ( + + ) : ( + + )} + + ); +} + +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 - - {dragging && ( - { - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = "copy"; - }} - onDrop={handleImageDrop} - > - Drop map to upload - - )} - + ); } 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} + /> + + + + + + + + + ); +} + +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