12
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "owlbear-rodeo",
|
"name": "owlbear-rodeo",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@msgpack/msgpack": "^1.12.1",
|
"@msgpack/msgpack": "^1.12.1",
|
||||||
@ -8,24 +8,34 @@
|
|||||||
"@testing-library/jest-dom": "^4.2.4",
|
"@testing-library/jest-dom": "^4.2.4",
|
||||||
"@testing-library/react": "^9.3.2",
|
"@testing-library/react": "^9.3.2",
|
||||||
"@testing-library/user-event": "^7.1.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",
|
"dexie": "^2.0.4",
|
||||||
"fake-indexeddb": "^3.0.0",
|
"fake-indexeddb": "^3.0.0",
|
||||||
"interactjs": "^1.9.7",
|
"interactjs": "^1.9.7",
|
||||||
|
"konva": "^6.0.0",
|
||||||
"normalize-wheel": "^1.0.1",
|
"normalize-wheel": "^1.0.1",
|
||||||
"raw.macro": "^0.3.0",
|
"raw.macro": "^0.3.0",
|
||||||
"react": "^16.13.0",
|
"react": "^16.13.0",
|
||||||
"react-dom": "^16.13.0",
|
"react-dom": "^16.13.0",
|
||||||
|
"react-konva": "^16.13.0-3",
|
||||||
"react-markdown": "^4.3.1",
|
"react-markdown": "^4.3.1",
|
||||||
"react-modal": "^3.11.2",
|
"react-modal": "^3.11.2",
|
||||||
|
"react-resize-detector": "^4.2.3",
|
||||||
"react-router-dom": "^5.1.2",
|
"react-router-dom": "^5.1.2",
|
||||||
"react-router-hash-link": "^1.2.2",
|
"react-router-hash-link": "^1.2.2",
|
||||||
"react-scripts": "3.4.0",
|
"react-scripts": "3.4.0",
|
||||||
|
"react-spring": "^8.0.27",
|
||||||
|
"react-use-gesture": "^7.0.15",
|
||||||
"shortid": "^2.2.15",
|
"shortid": "^2.2.15",
|
||||||
"simple-peer": "^9.6.2",
|
"simple-peer": "^9.6.2",
|
||||||
"simplebar-react": "^2.1.0",
|
"simplebar-react": "^2.1.0",
|
||||||
"simplify-js": "^1.2.4",
|
"simplify-js": "^1.2.4",
|
||||||
"socket.io-client": "^2.3.0",
|
"socket.io-client": "^2.3.0",
|
||||||
"theme-ui": "^0.3.1",
|
"theme-ui": "^0.3.1",
|
||||||
|
"use-image": "^1.0.5",
|
||||||
"webrtc-adapter": "^7.5.1"
|
"webrtc-adapter": "^7.5.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
11
src/App.js
@ -11,6 +11,9 @@ import ReleaseNotes from "./routes/ReleaseNotes";
|
|||||||
|
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import { DatabaseProvider } from "./contexts/DatabaseContext";
|
import { DatabaseProvider } from "./contexts/DatabaseContext";
|
||||||
|
import { MapDataProvider } from "./contexts/MapDataContext";
|
||||||
|
import { TokenDataProvider } from "./contexts/TokenDataContext";
|
||||||
|
import { MapLoadingProvider } from "./contexts/MapLoadingContext";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -29,7 +32,13 @@ function App() {
|
|||||||
<FAQ />
|
<FAQ />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/game/:id">
|
<Route path="/game/:id">
|
||||||
<Game />
|
<MapLoadingProvider>
|
||||||
|
<MapDataProvider>
|
||||||
|
<TokenDataProvider>
|
||||||
|
<Game />
|
||||||
|
</TokenDataProvider>
|
||||||
|
</MapDataProvider>
|
||||||
|
</MapLoadingProvider>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
<Home />
|
<Home />
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Divider } from "theme-ui";
|
import { Divider } from "theme-ui";
|
||||||
|
|
||||||
function StyledDivider({ vertical }) {
|
function StyledDivider({ vertical, color }) {
|
||||||
return (
|
return (
|
||||||
<Divider
|
<Divider
|
||||||
my={vertical ? 0 : 2}
|
my={vertical ? 0 : 2}
|
||||||
mx={vertical ? 2 : 0}
|
mx={vertical ? 2 : 0}
|
||||||
bg="text"
|
bg={color}
|
||||||
sx={{
|
sx={{
|
||||||
height: vertical ? "24px" : "2px",
|
height: vertical ? "24px" : "2px",
|
||||||
width: vertical ? "2px" : "24px",
|
width: vertical ? "2px" : "24px",
|
||||||
@ -19,6 +19,7 @@ function StyledDivider({ vertical }) {
|
|||||||
|
|
||||||
StyledDivider.defaultProps = {
|
StyledDivider.defaultProps = {
|
||||||
vertical: false,
|
vertical: false,
|
||||||
|
color: "text",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default StyledDivider;
|
export default StyledDivider;
|
61
src/components/ImageDrop.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Box, Flex, Text } from "theme-ui";
|
||||||
|
|
||||||
|
function ImageDrop({ onDrop, dropText, children }) {
|
||||||
|
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")) {
|
||||||
|
onDrop(file);
|
||||||
|
}
|
||||||
|
setDragging(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box onDragEnter={handleImageDragEnter}>
|
||||||
|
{children}
|
||||||
|
{dragging && (
|
||||||
|
<Flex
|
||||||
|
bg="overlay"
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "copy",
|
||||||
|
}}
|
||||||
|
onDragLeave={handleImageDragLeave}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
}}
|
||||||
|
onDrop={handleImageDrop}
|
||||||
|
>
|
||||||
|
<Text sx={{ pointerEvents: "none" }}>
|
||||||
|
{dropText || "Drop image to upload"}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageDrop;
|
21
src/components/dice/DiceButton.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { IconButton } from "theme-ui";
|
||||||
|
|
||||||
|
import Count from "./DiceButtonCount";
|
||||||
|
|
||||||
|
function DiceButton({ title, children, count, onClick }) {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
onClick={onClick}
|
||||||
|
color="hsl(210, 50%, 96%)"
|
||||||
|
sx={{ position: "relative" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{count && <Count>{count}</Count>}
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiceButton;
|
29
src/components/dice/DiceButtonCount.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Text } from "theme-ui";
|
||||||
|
|
||||||
|
function DiceButtonCount({ children }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
bottom: "100%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
height: "14px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
as="p"
|
||||||
|
color="text"
|
||||||
|
sx={{ fontSize: "10px", fontWeight: "bold" }}
|
||||||
|
>
|
||||||
|
{children}×
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiceButtonCount;
|
134
src/components/dice/DiceButtons.js
Normal file
@ -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 (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0 15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectDiceButton
|
||||||
|
onDiceChange={handleDiceChange}
|
||||||
|
currentDice={currentDice}
|
||||||
|
/>
|
||||||
|
<Divider vertical color="hsl(210, 50%, 96%)" />
|
||||||
|
<DiceButton
|
||||||
|
title="Add D20"
|
||||||
|
count={diceCounts.d20}
|
||||||
|
onClick={() => onDiceAdd(currentDice.class, "d20")}
|
||||||
|
>
|
||||||
|
<D20Icon />
|
||||||
|
</DiceButton>
|
||||||
|
<DiceButton
|
||||||
|
title="Add D12"
|
||||||
|
count={diceCounts.d12}
|
||||||
|
onClick={() => onDiceAdd(currentDice.class, "d12")}
|
||||||
|
>
|
||||||
|
<D12Icon />
|
||||||
|
</DiceButton>
|
||||||
|
<DiceButton
|
||||||
|
title="Add D10"
|
||||||
|
count={diceCounts.d10}
|
||||||
|
onClick={() => onDiceAdd(currentDice.class, "d10")}
|
||||||
|
>
|
||||||
|
<D10Icon />
|
||||||
|
</DiceButton>
|
||||||
|
<DiceButton
|
||||||
|
title="Add D8"
|
||||||
|
count={diceCounts.d8}
|
||||||
|
onClick={() => onDiceAdd(currentDice.class, "d8")}
|
||||||
|
>
|
||||||
|
<D8Icon />
|
||||||
|
</DiceButton>
|
||||||
|
<DiceButton
|
||||||
|
title="Add D6"
|
||||||
|
count={diceCounts.d6}
|
||||||
|
onClick={() => onDiceAdd(currentDice.class, "d6")}
|
||||||
|
>
|
||||||
|
<D6Icon />
|
||||||
|
</DiceButton>
|
||||||
|
<DiceButton
|
||||||
|
title="Add D4"
|
||||||
|
count={diceCounts.d4}
|
||||||
|
onClick={() => onDiceAdd(currentDice.class, "d4")}
|
||||||
|
>
|
||||||
|
<D4Icon />
|
||||||
|
</DiceButton>
|
||||||
|
<DiceButton
|
||||||
|
title="Add D100"
|
||||||
|
count={diceCounts.d100}
|
||||||
|
onClick={() => onDiceAdd(currentDice.class, "d100")}
|
||||||
|
>
|
||||||
|
<D100Icon />
|
||||||
|
</DiceButton>
|
||||||
|
<Divider vertical color="hsl(210, 50%, 96%)" />
|
||||||
|
<IconButton
|
||||||
|
aria-label={
|
||||||
|
diceTraySize === "single" ? "Expand Dice Tray" : "Shrink Dice Tray"
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
diceTraySize === "single" ? "Expand Dice Tray" : "Shrink Dice Tray"
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
transform: diceTraySize === "single" ? "rotate(0)" : "rotate(180deg)",
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
onDiceTraySizeChange(diceTraySize === "single" ? "double" : "single")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExpandMoreDiceTrayIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiceButtons;
|
130
src/components/dice/DiceControls.js
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "16px",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
display: "flex",
|
||||||
|
color: "white",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DiceResults
|
||||||
|
diceRolls={diceRolls}
|
||||||
|
onDiceClear={() => {
|
||||||
|
onDiceClear();
|
||||||
|
setDiceRolls([]);
|
||||||
|
}}
|
||||||
|
onDiceReroll={onDiceReroll}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "24px",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DiceButtons
|
||||||
|
diceRolls={diceRolls}
|
||||||
|
onDiceAdd={(style, type) => {
|
||||||
|
onDiceAdd(style, type);
|
||||||
|
setDiceRolls((prevRolls) => [
|
||||||
|
...prevRolls,
|
||||||
|
{ type, roll: "unknown" },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
onDiceLoad={onDiceLoad}
|
||||||
|
onDiceTraySizeChange={onDiceTraySizeChange}
|
||||||
|
diceTraySize={diceTraySize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiceControls;
|
155
src/components/dice/DiceInteraction.js
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
position: "absolute",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
|
||||||
|
<canvas
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ outline: "none" }}
|
||||||
|
/>
|
||||||
|
</ReactResizeDetector>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DiceInteraction.defaultProps = {
|
||||||
|
onPointerDown() {},
|
||||||
|
onPointerUp() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiceInteraction;
|
97
src/components/dice/DiceResults.js
Normal file
@ -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) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<Text variant="body2" as="p" color="hsl(210, 50%, 96%)">
|
||||||
|
{dice.roll}
|
||||||
|
</Text>
|
||||||
|
<Text variant="body2" as="p" color="grey">
|
||||||
|
{index === diceRolls.length - 1 ? "" : "+"}
|
||||||
|
</Text>
|
||||||
|
</React.Fragment>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
ml="24px"
|
||||||
|
title="Clear Dice"
|
||||||
|
aria-label="Clear Dice"
|
||||||
|
onClick={onDiceClear}
|
||||||
|
sx={{ pointerEvents: "all" }}
|
||||||
|
>
|
||||||
|
<ClearDiceIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="heading"
|
||||||
|
as="h1"
|
||||||
|
sx={{ fontSize: 5, userSelect: "none" }}
|
||||||
|
mb={diceRolls.length === 1 ? "24px" : 0}
|
||||||
|
>
|
||||||
|
{diceRolls.reduce((accumulator, dice) => accumulator + dice.roll, 0)}
|
||||||
|
</Text>
|
||||||
|
{rolls.length > maxDiceRollsShown ? (
|
||||||
|
<Button
|
||||||
|
aria-label={"Show Dice Details"}
|
||||||
|
title={"Show Dice Details"}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
variant="secondary"
|
||||||
|
sx={{ display: "flex", height: "24px", pointerEvents: "all" }}
|
||||||
|
>
|
||||||
|
{isExpanded ? rolls : rolls.slice(0, maxDiceRollsShown)}
|
||||||
|
{!isExpanded && (
|
||||||
|
<Text variant="body2" as="p" color="hsl(210, 50%, 96%)">
|
||||||
|
...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Flex>{rolls}</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
<IconButton
|
||||||
|
mr="24px"
|
||||||
|
title="Reroll Dice"
|
||||||
|
aria-label="Reroll Dice"
|
||||||
|
onClick={onDiceReroll}
|
||||||
|
sx={{ pointerEvents: "all" }}
|
||||||
|
>
|
||||||
|
<RerollDiceIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiceResults;
|
55
src/components/dice/DiceTile.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Flex, Image, Text } from "theme-ui";
|
||||||
|
|
||||||
|
function DiceTile({ dice, isSelected, onDiceSelect, onDone }) {
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
onClick={() => 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)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||||
|
src={dice.preview}
|
||||||
|
/>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background:
|
||||||
|
"linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.65) 100%);",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
p={2}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="heading"
|
||||||
|
color="hsl(210, 50%, 96%)"
|
||||||
|
sx={{ textAlign: "center" }}
|
||||||
|
>
|
||||||
|
{dice.name}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiceTile;
|
33
src/components/dice/DiceTiles.js
Normal file
@ -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 (
|
||||||
|
<SimpleBar style={{ maxHeight: "300px", width: "500px" }}>
|
||||||
|
<Flex
|
||||||
|
py={2}
|
||||||
|
bg="muted"
|
||||||
|
sx={{
|
||||||
|
flexWrap: "wrap",
|
||||||
|
width: "500px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dice.map((dice) => (
|
||||||
|
<DiceTile
|
||||||
|
key={dice.key}
|
||||||
|
dice={dice}
|
||||||
|
isSelected={selectedDice && dice.key === selectedDice.key}
|
||||||
|
onDiceSelect={onDiceSelect}
|
||||||
|
onDone={onDone}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</SimpleBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiceTiles;
|
262
src/components/dice/DiceTrayOverlay.js
Normal file
@ -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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: diceTraySize === "single" ? "500px" : "1000px",
|
||||||
|
maxWidth:
|
||||||
|
diceTraySize === "single"
|
||||||
|
? "calc(50vh - 48px)"
|
||||||
|
: "calc(100vh - 64px)",
|
||||||
|
paddingBottom: diceTraySize === "single" ? "200%" : "100%",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: isOpen ? "block" : "none",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
pointerEvents: "all",
|
||||||
|
}}
|
||||||
|
bg="background"
|
||||||
|
>
|
||||||
|
<DiceInteraction
|
||||||
|
onSceneMount={handleSceneMount}
|
||||||
|
onPointerDown={() => {
|
||||||
|
sceneInteractionRef.current = true;
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
sceneInteractionRef.current = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DiceControls
|
||||||
|
diceRefs={diceRefs}
|
||||||
|
sceneVisibleRef={sceneVisibleRef}
|
||||||
|
onDiceAdd={handleDiceAdd}
|
||||||
|
onDiceClear={handleDiceClear}
|
||||||
|
onDiceReroll={handleDiceReroll}
|
||||||
|
onDiceLoad={handleDiceLoad}
|
||||||
|
diceTraySize={diceTraySize}
|
||||||
|
onDiceTraySizeChange={setDiceTraySize}
|
||||||
|
/>
|
||||||
|
{isLoading && <LoadingOverlay />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiceTrayOverlay;
|
42
src/components/dice/SelectDiceButton.js
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Select Dice Style"
|
||||||
|
title="Select Dice Style"
|
||||||
|
color="hsl(210, 50%, 96%)"
|
||||||
|
onClick={openModal}
|
||||||
|
>
|
||||||
|
<SelectDiceIcon />
|
||||||
|
</IconButton>
|
||||||
|
<SelectDiceModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onRequestClose={closeModal}
|
||||||
|
defaultDice={currentDice}
|
||||||
|
onDone={handleDone}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectDiceButton;
|
@ -1,26 +1,24 @@
|
|||||||
import React, { useRef, useEffect, useState } from "react";
|
import React, { useState, useContext, useEffect } from "react";
|
||||||
import { Box, Image } from "theme-ui";
|
|
||||||
|
|
||||||
import ProxyToken from "../token/ProxyToken";
|
import MapControls from "./MapControls";
|
||||||
import TokenMenu from "../token/TokenMenu";
|
import MapInteraction from "./MapInteraction";
|
||||||
import MapToken from "./MapToken";
|
import MapToken from "./MapToken";
|
||||||
import MapDrawing from "./MapDrawing";
|
import MapDrawing from "./MapDrawing";
|
||||||
import MapFog from "./MapFog";
|
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 { 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({
|
function Map({
|
||||||
map,
|
map,
|
||||||
mapState,
|
mapState,
|
||||||
tokens,
|
|
||||||
onMapTokenStateChange,
|
onMapTokenStateChange,
|
||||||
onMapTokenStateRemove,
|
onMapTokenStateRemove,
|
||||||
onMapChange,
|
onMapChange,
|
||||||
@ -34,23 +32,17 @@ function Map({
|
|||||||
allowMapDrawing,
|
allowMapDrawing,
|
||||||
allowFogDrawing,
|
allowFogDrawing,
|
||||||
disabledTokens,
|
disabledTokens,
|
||||||
loading,
|
|
||||||
}) {
|
}) {
|
||||||
const mapSource = useDataSource(map, defaultMapSources);
|
const { tokensById } = useContext(TokenDataContext);
|
||||||
|
const { isLoading } = useContext(MapLoadingContext);
|
||||||
|
|
||||||
function handleProxyDragEnd(isOnMap, tokenState) {
|
const gridX = map && map.gridX;
|
||||||
if (isOnMap && onMapTokenStateChange) {
|
const gridY = map && map.gridY;
|
||||||
onMapTokenStateChange(tokenState);
|
const gridSizeNormalized = {
|
||||||
}
|
x: gridX ? 1 / gridX : 0,
|
||||||
|
y: gridY ? 1 / gridY : 0,
|
||||||
if (!isOnMap && onMapTokenStateRemove) {
|
};
|
||||||
onMapTokenStateRemove(tokenState);
|
const tokenSizePercent = gridSizeNormalized.x;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map drawing
|
|
||||||
*/
|
|
||||||
|
|
||||||
const [selectedToolId, setSelectedToolId] = useState("pan");
|
const [selectedToolId, setSelectedToolId] = useState("pan");
|
||||||
const [toolSettings, setToolSettings] = useState({
|
const [toolSettings, setToolSettings] = useState({
|
||||||
@ -100,6 +92,7 @@ function Map({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [mapShapes, setMapShapes] = useState([]);
|
const [mapShapes, setMapShapes] = useState([]);
|
||||||
|
|
||||||
function handleMapShapeAdd(shape) {
|
function handleMapShapeAdd(shape) {
|
||||||
onMapDraw({ type: "add", shapes: [shape] });
|
onMapDraw({ type: "add", shapes: [shape] });
|
||||||
}
|
}
|
||||||
@ -109,6 +102,7 @@ function Map({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [fogShapes, setFogShapes] = useState([]);
|
const [fogShapes, setFogShapes] = useState([]);
|
||||||
|
|
||||||
function handleFogShapeAdd(shape) {
|
function handleFogShapeAdd(shape) {
|
||||||
onFogDraw({ type: "add", shapes: [shape] });
|
onFogDraw({ type: "add", shapes: [shape] });
|
||||||
}
|
}
|
||||||
@ -190,97 +184,12 @@ function Map({
|
|||||||
disabledSettings.fog.push("redo");
|
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 = (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
ref={mapRef}
|
|
||||||
className="mapImage"
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
userSelect: "none",
|
|
||||||
touchAction: "none",
|
|
||||||
}}
|
|
||||||
src={mapSource}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mapTokens = (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mapState &&
|
|
||||||
Object.values(mapState.tokens).map((tokenState) => (
|
|
||||||
<MapToken
|
|
||||||
key={tokenState.id}
|
|
||||||
token={tokens.find((token) => token.id === tokenState.tokenId)}
|
|
||||||
tokenState={tokenState}
|
|
||||||
tokenSizePercent={tokenSizePercent}
|
|
||||||
className={`${mapTokenProxyClassName} ${mapTokenMenuClassName}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mapDrawing = (
|
|
||||||
<MapDrawing
|
|
||||||
width={map ? map.width : 0}
|
|
||||||
height={map ? map.height : 0}
|
|
||||||
selectedTool={selectedToolId !== "fog" ? selectedToolId : "none"}
|
|
||||||
toolSettings={toolSettings[selectedToolId]}
|
|
||||||
shapes={mapShapes}
|
|
||||||
onShapeAdd={handleMapShapeAdd}
|
|
||||||
onShapeRemove={handleMapShapeRemove}
|
|
||||||
gridSize={gridSizeNormalized}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mapFog = (
|
|
||||||
<MapFog
|
|
||||||
width={map ? map.width : 0}
|
|
||||||
height={map ? map.height : 0}
|
|
||||||
isEditing={selectedToolId === "fog"}
|
|
||||||
toolSettings={toolSettings["fog"]}
|
|
||||||
shapes={fogShapes}
|
|
||||||
onShapeAdd={handleFogShapeAdd}
|
|
||||||
onShapeRemove={handleFogShapeRemove}
|
|
||||||
onShapeEdit={handleFogShapeEdit}
|
|
||||||
gridSize={gridSizeNormalized}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mapControls = (
|
const mapControls = (
|
||||||
<MapControls
|
<MapControls
|
||||||
onMapChange={onMapChange}
|
onMapChange={onMapChange}
|
||||||
onMapStateChange={onMapStateChange}
|
onMapStateChange={onMapStateChange}
|
||||||
currentMap={map}
|
currentMap={map}
|
||||||
|
currentMapState={mapState}
|
||||||
onSelectedToolChange={setSelectedToolId}
|
onSelectedToolChange={setSelectedToolId}
|
||||||
selectedToolId={selectedToolId}
|
selectedToolId={selectedToolId}
|
||||||
toolSettings={toolSettings}
|
toolSettings={toolSettings}
|
||||||
@ -290,33 +199,119 @@ function Map({
|
|||||||
disabledSettings={disabledSettings}
|
disabledSettings={disabledSettings}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<MapToken
|
||||||
|
key={tokenState.id}
|
||||||
|
token={tokensById[tokenState.tokenId]}
|
||||||
|
tokenState={tokenState}
|
||||||
|
tokenSizePercent={tokenSizePercent}
|
||||||
|
onTokenStateChange={onMapTokenStateChange}
|
||||||
|
onTokenMenuOpen={handleTokenMenuOpen}
|
||||||
|
onTokenDragStart={(e) =>
|
||||||
|
setDraggingTokenOptions({ tokenState, tokenImage: e.target })
|
||||||
|
}
|
||||||
|
onTokenDragEnd={() => setDraggingTokenOptions(null)}
|
||||||
|
draggable={
|
||||||
|
(selectedToolId === "pan" || selectedToolId === "erase") &&
|
||||||
|
!(tokenState.id in disabledTokens)
|
||||||
|
}
|
||||||
|
mapState={mapState}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
const tokenMenu = (
|
||||||
|
<TokenMenu
|
||||||
|
isOpen={isTokenMenuOpen}
|
||||||
|
onRequestClose={() => setIsTokenMenuOpen(false)}
|
||||||
|
onTokenStateChange={onMapTokenStateChange}
|
||||||
|
tokenState={mapState && mapState.tokens[tokenMenuOptions.tokenStateId]}
|
||||||
|
tokenImage={tokenMenuOptions.tokenImage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenDragOverlay = draggingTokenOptions && (
|
||||||
|
<TokenDragOverlay
|
||||||
|
onTokenStateRemove={(state) => {
|
||||||
|
onMapTokenStateRemove(state);
|
||||||
|
setDraggingTokenOptions(null);
|
||||||
|
}}
|
||||||
|
onTokenStateChange={onMapTokenStateChange}
|
||||||
|
tokenState={draggingTokenOptions && draggingTokenOptions.tokenState}
|
||||||
|
tokenImage={draggingTokenOptions && draggingTokenOptions.tokenImage}
|
||||||
|
token={tokensById[draggingTokenOptions.tokenState.tokenId]}
|
||||||
|
mapState={mapState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapDrawing = (
|
||||||
|
<MapDrawing
|
||||||
|
shapes={mapShapes}
|
||||||
|
onShapeAdd={handleMapShapeAdd}
|
||||||
|
onShapeRemove={handleMapShapeRemove}
|
||||||
|
selectedToolId={selectedToolId}
|
||||||
|
selectedToolSettings={toolSettings[selectedToolId]}
|
||||||
|
gridSize={gridSizeNormalized}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapFog = (
|
||||||
|
<MapFog
|
||||||
|
shapes={fogShapes}
|
||||||
|
onShapeAdd={handleFogShapeAdd}
|
||||||
|
onShapeRemove={handleFogShapeRemove}
|
||||||
|
onShapeEdit={handleFogShapeEdit}
|
||||||
|
selectedToolId={selectedToolId}
|
||||||
|
selectedToolSettings={toolSettings[selectedToolId]}
|
||||||
|
gridSize={gridSizeNormalized}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<MapInteraction
|
||||||
<MapInteraction
|
map={map}
|
||||||
map={map}
|
controls={
|
||||||
aspectRatio={aspectRatio}
|
<>
|
||||||
isEnabled={selectedToolId === "pan"}
|
{mapControls}
|
||||||
controls={mapControls}
|
{tokenMenu}
|
||||||
loading={loading}
|
{tokenDragOverlay}
|
||||||
>
|
<MapDice />
|
||||||
{map && mapImage}
|
{isLoading && <LoadingOverlay />}
|
||||||
{map && mapDrawing}
|
</>
|
||||||
{map && mapFog}
|
}
|
||||||
{map && mapTokens}
|
selectedToolId={selectedToolId}
|
||||||
</MapInteraction>
|
>
|
||||||
<ProxyToken
|
{mapDrawing}
|
||||||
tokenClassName={mapTokenProxyClassName}
|
{mapTokens}
|
||||||
onProxyDragEnd={handleProxyDragEnd}
|
{mapFog}
|
||||||
tokens={mapState && mapState.tokens}
|
</MapInteraction>
|
||||||
disabledTokens={disabledTokens}
|
|
||||||
/>
|
|
||||||
<TokenMenu
|
|
||||||
tokenClassName={mapTokenMenuClassName}
|
|
||||||
onTokenChange={onMapTokenStateChange}
|
|
||||||
tokens={mapState && mapState.tokens}
|
|
||||||
disabledTokens={disabledTokens}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 { IconButton, Flex, Box } from "theme-ui";
|
||||||
|
|
||||||
import RadioIconButton from "./controls/RadioIconButton";
|
import RadioIconButton from "./controls/RadioIconButton";
|
||||||
import Divider from "./controls/Divider";
|
import Divider from "../Divider";
|
||||||
|
|
||||||
import SelectMapButton from "./SelectMapButton";
|
import SelectMapButton from "./SelectMapButton";
|
||||||
|
|
||||||
@ -22,6 +22,7 @@ function MapContols({
|
|||||||
onMapChange,
|
onMapChange,
|
||||||
onMapStateChange,
|
onMapStateChange,
|
||||||
currentMap,
|
currentMap,
|
||||||
|
currentMapState,
|
||||||
selectedToolId,
|
selectedToolId,
|
||||||
onSelectedToolChange,
|
onSelectedToolChange,
|
||||||
toolSettings,
|
toolSettings,
|
||||||
@ -73,6 +74,7 @@ function MapContols({
|
|||||||
onMapChange={onMapChange}
|
onMapChange={onMapChange}
|
||||||
onMapStateChange={onMapStateChange}
|
onMapStateChange={onMapStateChange}
|
||||||
currentMap={currentMap}
|
currentMap={currentMap}
|
||||||
|
currentMapState={currentMapState}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -144,9 +146,6 @@ function MapContols({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const controlsRef = useRef();
|
|
||||||
const settingsRef = useRef();
|
|
||||||
|
|
||||||
function getToolSettings() {
|
function getToolSettings() {
|
||||||
const Settings = toolsById[selectedToolId].SettingsComponent;
|
const Settings = toolsById[selectedToolId].SettingsComponent;
|
||||||
if (Settings) {
|
if (Settings) {
|
||||||
@ -161,7 +160,6 @@ function MapContols({
|
|||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
}}
|
}}
|
||||||
p={1}
|
p={1}
|
||||||
ref={settingsRef}
|
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
settings={toolSettings[selectedToolId]}
|
settings={toolSettings[selectedToolId]}
|
||||||
@ -178,35 +176,6 @@ function MapContols({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop map drawing from happening when selecting controls
|
|
||||||
// Not using react events as they seem to trigger after dom events
|
|
||||||
useEffect(() => {
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex
|
<Flex
|
||||||
@ -218,7 +187,6 @@ function MapContols({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
mx={1}
|
mx={1}
|
||||||
ref={controlsRef}
|
|
||||||
>
|
>
|
||||||
{controls}
|
{controls}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
46
src/components/map/MapDice.js
Normal file
@ -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 (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
ml={1}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
aria-label={isExpanded ? "Hide Dice Tray" : "Show Dice Tray"}
|
||||||
|
title={isExpanded ? "Hide Dice Tray" : "Show Dice Tray"}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
backgroundColor: "overlay",
|
||||||
|
borderRadius: "50%",
|
||||||
|
pointerEvents: "all",
|
||||||
|
}}
|
||||||
|
m={2}
|
||||||
|
>
|
||||||
|
<ExpandMoreDiceIcon isExpanded={isExpanded} />
|
||||||
|
</IconButton>
|
||||||
|
<DiceLoadingProvider>
|
||||||
|
<DiceTrayOverlay isOpen={isExpanded} />
|
||||||
|
</DiceLoadingProvider>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapDice;
|
@ -1,260 +1,255 @@
|
|||||||
import React, { useRef, useEffect, useState, useContext } from "react";
|
import React, { useContext, useState, useCallback } from "react";
|
||||||
import shortid from "shortid";
|
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 { compare as comparePoints } from "../../helpers/vector2";
|
||||||
import {
|
import {
|
||||||
getBrushPositionForTool,
|
getBrushPositionForTool,
|
||||||
getDefaultShapeData,
|
getDefaultShapeData,
|
||||||
getUpdatedShapeData,
|
getUpdatedShapeData,
|
||||||
isShapeHovered,
|
|
||||||
drawShape,
|
|
||||||
simplifyPoints,
|
simplifyPoints,
|
||||||
getRelativePointerPosition,
|
getStrokeWidth,
|
||||||
} from "../../helpers/drawing";
|
} from "../../helpers/drawing";
|
||||||
|
|
||||||
import MapInteractionContext from "../../contexts/MapInteractionContext";
|
import colors from "../../helpers/colors";
|
||||||
|
import useMapBrush from "../../helpers/useMapBrush";
|
||||||
|
|
||||||
function MapDrawing({
|
function MapDrawing({
|
||||||
width,
|
|
||||||
height,
|
|
||||||
selectedTool,
|
|
||||||
toolSettings,
|
|
||||||
shapes,
|
shapes,
|
||||||
onShapeAdd,
|
onShapeAdd,
|
||||||
onShapeRemove,
|
onShapeRemove,
|
||||||
|
selectedToolId,
|
||||||
|
selectedToolSettings,
|
||||||
gridSize,
|
gridSize,
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef();
|
const { stageScale, mapWidth, mapHeight } = useContext(MapInteractionContext);
|
||||||
const containerRef = useRef();
|
|
||||||
|
|
||||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
|
||||||
const [drawingShape, setDrawingShape] = useState(null);
|
const [drawingShape, setDrawingShape] = useState(null);
|
||||||
const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
|
|
||||||
|
|
||||||
const shouldHover = selectedTool === "erase";
|
const shouldHover = selectedToolId === "erase";
|
||||||
const isEditing =
|
const isEditing =
|
||||||
selectedTool === "brush" ||
|
selectedToolId === "brush" ||
|
||||||
selectedTool === "shape" ||
|
selectedToolId === "shape" ||
|
||||||
selectedTool === "erase";
|
selectedToolId === "erase";
|
||||||
|
|
||||||
const { scaleRef } = useContext(MapInteractionContext);
|
const handleShapeDraw = useCallback(
|
||||||
|
(brushState, mapBrushPosition) => {
|
||||||
// Reset pointer position when tool changes
|
function startShape() {
|
||||||
useEffect(() => {
|
const brushPosition = getBrushPositionForTool(
|
||||||
setPointerPosition({ x: -1, y: -1 });
|
mapBrushPosition,
|
||||||
}, [selectedTool]);
|
selectedToolId,
|
||||||
|
selectedToolSettings,
|
||||||
function handleStart(event) {
|
gridSize,
|
||||||
if (!isEditing) {
|
shapes
|
||||||
return;
|
);
|
||||||
}
|
const commonShapeData = {
|
||||||
if (event.touches && event.touches.length !== 1) {
|
color: selectedToolSettings && selectedToolSettings.color,
|
||||||
setIsPointerDown(false);
|
blend: selectedToolSettings && selectedToolSettings.useBlending,
|
||||||
setDrawingShape(null);
|
id: shortid.generate(),
|
||||||
return;
|
};
|
||||||
}
|
if (selectedToolId === "brush") {
|
||||||
const pointer = event.touches ? event.touches[0] : event;
|
setDrawingShape({
|
||||||
const position = getRelativePointerPosition(pointer, containerRef.current);
|
type: "path",
|
||||||
setPointerPosition(position);
|
pathType: selectedToolSettings.type,
|
||||||
setIsPointerDown(true);
|
data: { points: [brushPosition] },
|
||||||
const brushPosition = getBrushPositionForTool(
|
strokeWidth: selectedToolSettings.type === "stroke" ? 1 : 0,
|
||||||
position,
|
...commonShapeData,
|
||||||
selectedTool,
|
});
|
||||||
toolSettings,
|
} else if (selectedToolId === "shape") {
|
||||||
gridSize,
|
setDrawingShape({
|
||||||
shapes
|
type: "shape",
|
||||||
);
|
shapeType: selectedToolSettings.type,
|
||||||
const commonShapeData = {
|
data: getDefaultShapeData(selectedToolSettings.type, brushPosition),
|
||||||
color: toolSettings && toolSettings.color,
|
strokeWidth: 0,
|
||||||
blend: toolSettings && toolSettings.useBlending,
|
...commonShapeData,
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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 };
|
function endShape() {
|
||||||
drawShape(shape, context, gridSize, width, height);
|
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,
|
function handleShapeMouseOver(event, shape) {
|
||||||
height,
|
if (shouldHover) {
|
||||||
pointerPosition,
|
const path = event.target;
|
||||||
isPointerDown,
|
const hoverColor = "#BB99FF";
|
||||||
selectedTool,
|
path.fill(hoverColor);
|
||||||
drawingShape,
|
if (shape.type === "path") {
|
||||||
gridSize,
|
path.stroke(hoverColor);
|
||||||
shouldHover,
|
}
|
||||||
]);
|
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 (
|
||||||
|
<Line
|
||||||
|
points={shape.data.points.reduce(
|
||||||
|
(acc, point) => [...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 (
|
||||||
|
<Rect
|
||||||
|
x={shape.data.x * mapWidth}
|
||||||
|
y={shape.data.y * mapHeight}
|
||||||
|
width={shape.data.width * mapWidth}
|
||||||
|
height={shape.data.height * mapHeight}
|
||||||
|
{...defaultProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (shape.shapeType === "circle") {
|
||||||
|
const minSide = mapWidth < mapHeight ? mapWidth : mapHeight;
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
x={shape.data.x * mapWidth}
|
||||||
|
y={shape.data.y * mapHeight}
|
||||||
|
radius={shape.data.radius * minSide}
|
||||||
|
{...defaultProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (shape.shapeType === "triangle") {
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
points={shape.data.points.reduce(
|
||||||
|
(acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
|
||||||
|
[]
|
||||||
|
)}
|
||||||
|
closed={true}
|
||||||
|
{...defaultProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Group>
|
||||||
style={{
|
{shapes.map(renderShape)}
|
||||||
position: "absolute",
|
{drawingShape && renderShape(drawingShape)}
|
||||||
top: 0,
|
</Group>
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,275 +1,213 @@
|
|||||||
import React, { useRef, useEffect, useState, useContext } from "react";
|
import React, { useContext, useState, useCallback } from "react";
|
||||||
import shortid from "shortid";
|
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 { compare as comparePoints } from "../../helpers/vector2";
|
||||||
import {
|
import {
|
||||||
getBrushPositionForTool,
|
getBrushPositionForTool,
|
||||||
isShapeHovered,
|
|
||||||
drawShape,
|
|
||||||
simplifyPoints,
|
simplifyPoints,
|
||||||
getRelativePointerPosition,
|
getStrokeWidth,
|
||||||
} from "../../helpers/drawing";
|
} from "../../helpers/drawing";
|
||||||
|
|
||||||
import MapInteractionContext from "../../contexts/MapInteractionContext";
|
import colors from "../../helpers/colors";
|
||||||
|
import useMapBrush from "../../helpers/useMapBrush";
|
||||||
import diagonalPattern from "../../images/DiagonalPattern.png";
|
|
||||||
|
|
||||||
function MapFog({
|
function MapFog({
|
||||||
width,
|
|
||||||
height,
|
|
||||||
isEditing,
|
|
||||||
toolSettings,
|
|
||||||
shapes,
|
shapes,
|
||||||
onShapeAdd,
|
onShapeAdd,
|
||||||
onShapeRemove,
|
onShapeRemove,
|
||||||
onShapeEdit,
|
onShapeEdit,
|
||||||
|
selectedToolId,
|
||||||
|
selectedToolSettings,
|
||||||
gridSize,
|
gridSize,
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef();
|
const { stageScale, mapWidth, mapHeight } = useContext(MapInteractionContext);
|
||||||
const containerRef = useRef();
|
|
||||||
|
|
||||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
|
||||||
const [drawingShape, setDrawingShape] = useState(null);
|
const [drawingShape, setDrawingShape] = useState(null);
|
||||||
const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
|
|
||||||
|
|
||||||
|
const isEditing = selectedToolId === "fog";
|
||||||
const shouldHover =
|
const shouldHover =
|
||||||
isEditing &&
|
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
|
const handleShapeDraw = useCallback(
|
||||||
useEffect(() => {
|
(brushState, mapBrushPosition) => {
|
||||||
setPointerPosition({ x: -1, y: -1 });
|
function startShape() {
|
||||||
}, [isEditing, toolSettings]);
|
const brushPosition = getBrushPositionForTool(
|
||||||
|
mapBrushPosition,
|
||||||
function handleStart(event) {
|
selectedToolId,
|
||||||
if (!isEditing) {
|
selectedToolSettings,
|
||||||
return;
|
gridSize,
|
||||||
}
|
shapes
|
||||||
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"
|
|
||||||
);
|
);
|
||||||
for (let shape of shapes) {
|
if (selectedToolSettings.type === "add") {
|
||||||
if (shouldHover) {
|
setDrawingShape({
|
||||||
if (
|
type: "fog",
|
||||||
isShapeHovered(shape, context, pointerPosition, width, height)
|
data: { points: [brushPosition] },
|
||||||
) {
|
strokeWidth: 0.5,
|
||||||
hoveredShape = shape;
|
color: "black",
|
||||||
}
|
blend: false,
|
||||||
}
|
id: shortid.generate(),
|
||||||
drawShape(
|
visible: true,
|
||||||
{
|
});
|
||||||
...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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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,
|
if (selectedToolSettings.type === "remove") {
|
||||||
width,
|
onShapeRemove(shape.id);
|
||||||
height,
|
} else if (selectedToolSettings.type === "toggle") {
|
||||||
pointerPosition,
|
onShapeEdit({ ...shape, visible: !shape.visible });
|
||||||
isEditing,
|
}
|
||||||
drawingShape,
|
}
|
||||||
gridSize,
|
|
||||||
shouldHover,
|
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 (
|
||||||
|
<Line
|
||||||
|
key={shape.id}
|
||||||
|
onMouseOver={(e) => 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 (
|
return (
|
||||||
<div
|
<Group>
|
||||||
style={{
|
{shapes.map(renderShape)}
|
||||||
position: "absolute",
|
{drawingShape && renderShape(drawingShape)}
|
||||||
top: 0,
|
</Group>
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,166 +1,248 @@
|
|||||||
import React, { useRef, useEffect } from "react";
|
import React, { useRef, useEffect, useState, useContext } from "react";
|
||||||
import { Box } from "theme-ui";
|
import { Box } from "theme-ui";
|
||||||
import interact from "interactjs";
|
import { useGesture } from "react-use-gesture";
|
||||||
import normalizeWheel from "normalize-wheel";
|
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 { MapInteractionProvider } from "../../contexts/MapInteractionContext";
|
||||||
|
import MapStageContext from "../../contexts/MapStageContext";
|
||||||
|
import AuthContext from "../../contexts/AuthContext";
|
||||||
|
|
||||||
import LoadingOverlay from "../LoadingOverlay";
|
const wheelZoomSpeed = -0.001;
|
||||||
|
const touchZoomSpeed = 0.005;
|
||||||
const zoomSpeed = -0.001;
|
|
||||||
const minZoom = 0.1;
|
const minZoom = 0.1;
|
||||||
const maxZoom = 5;
|
const maxZoom = 5;
|
||||||
|
|
||||||
function MapInteraction({
|
function MapInteraction({ map, children, controls, selectedToolId }) {
|
||||||
map,
|
const mapSource = useDataSource(map, defaultMapSources);
|
||||||
aspectRatio,
|
const [mapSourceImage] = useImage(mapSource);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
function handleMove(event, isGesture) {
|
const layer = mapLayerRef.current;
|
||||||
const scale = mapScaleRef.current;
|
if (map && layer) {
|
||||||
const translate = mapTranslateRef.current;
|
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;
|
setStageScale(1);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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]);
|
}, [map]);
|
||||||
|
|
||||||
// Bind the wheel event of the map via a ref
|
// Convert a client space XY to be normalized to the map image
|
||||||
// in order to support non-passive event listening
|
function getMapDragPosition(xy) {
|
||||||
// to allow the track pad zoom to be interrupted
|
const [x, y] = xy;
|
||||||
// see https://github.com/facebook/react/issues/14856
|
const container = containerRef.current;
|
||||||
useEffect(() => {
|
const mapImage = mapImageRef.current;
|
||||||
const mapContainer = mapContainerRef.current;
|
if (container && mapImage) {
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const mapRect = mapImage.getClientRect();
|
||||||
|
|
||||||
function handleZoom(event) {
|
const offsetX = x - containerRect.left - mapRect.x;
|
||||||
// Stop overscroll on chrome and safari
|
const offsetY = y - containerRect.top - mapRect.y;
|
||||||
// also stop pinch to zoom on chrome
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// Try and normalize the wheel event to prevent OS differences for zoom speed
|
const normalizedX = offsetX / mapRect.width;
|
||||||
const normalized = normalizeWheel(event);
|
const normalizedY = offsetY / mapRect.height;
|
||||||
|
|
||||||
const scale = mapScaleRef.current;
|
return { x: normalizedX, y: normalizedY };
|
||||||
const translate = mapTranslateRef.current;
|
|
||||||
|
|
||||||
const deltaY = normalized.pixelY * zoomSpeed;
|
|
||||||
const newScale = Math.max(Math.min(scale + deltaY, maxZoom), minZoom);
|
|
||||||
|
|
||||||
setTranslateAndScale(translate, newScale);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mapContainer) {
|
const pinchPreviousDistanceRef = useRef();
|
||||||
mapContainer.addEventListener("wheel", handleZoom, {
|
const pinchPreviousOriginRef = useRef();
|
||||||
passive: false,
|
const isInteractingCanvas = useRef(false);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
const bind = useGesture({
|
||||||
if (mapContainer) {
|
onWheelStart: ({ event }) => {
|
||||||
mapContainer.removeEventListener("wheel", handleZoom);
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className="map"
|
|
||||||
sx={{
|
sx={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
overflow: "hidden",
|
cursor: getCursorForTool(selectedToolId),
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
|
||||||
userSelect: "none",
|
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
bg="background"
|
ref={containerRef}
|
||||||
ref={mapContainerRef}
|
{...bind()}
|
||||||
|
className="map"
|
||||||
>
|
>
|
||||||
<Box
|
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
|
||||||
sx={{
|
<Stage
|
||||||
position: "relative",
|
width={stageWidth}
|
||||||
top: "50%",
|
height={stageHeight}
|
||||||
left: "50%",
|
scale={{ x: stageScale, y: stageScale }}
|
||||||
transform: "translate(-50%, -50%)",
|
x={stageWidth / 2}
|
||||||
}}
|
y={stageHeight / 2}
|
||||||
>
|
offset={{ x: stageWidth / 2, y: stageHeight / 2 }}
|
||||||
<Box ref={mapMoveContainerRef}>
|
ref={mapStageRef}
|
||||||
<Box
|
>
|
||||||
sx={{
|
<Layer ref={mapLayerRef}>
|
||||||
width: "100%",
|
<Image
|
||||||
height: 0,
|
image={mapSourceImage}
|
||||||
paddingBottom: `${(1 / aspectRatio) * 100}%`,
|
width={mapWidth}
|
||||||
}}
|
height={mapHeight}
|
||||||
/>
|
id="mapImage"
|
||||||
<MapInteractionProvider
|
ref={mapImageRef}
|
||||||
value={{
|
/>
|
||||||
translateRef: mapTranslateRef,
|
{/* Forward auth context to konva elements */}
|
||||||
scaleRef: mapScaleRef,
|
<AuthContext.Provider value={auth}>
|
||||||
}}
|
<MapInteractionProvider value={mapInteraction}>
|
||||||
>
|
{children}
|
||||||
{children}
|
</MapInteractionProvider>
|
||||||
</MapInteractionProvider>
|
</AuthContext.Provider>
|
||||||
</Box>
|
</Layer>
|
||||||
</Box>
|
</Stage>
|
||||||
{controls}
|
</ReactResizeDetector>
|
||||||
{loading && <LoadingOverlay />}
|
<MapInteractionProvider value={mapInteraction}>
|
||||||
|
{controls}
|
||||||
|
</MapInteractionProvider>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import Modal from "react-modal";
|
import Modal from "react-modal";
|
||||||
|
|
||||||
import { useThemeUI } from "theme-ui";
|
import { useThemeUI } from "theme-ui";
|
||||||
|
|
||||||
function MapMenu({
|
function MapMenu({
|
||||||
@ -45,6 +44,7 @@ function MapMenu({
|
|||||||
{ once: true }
|
{ once: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (modalContentNode) {
|
if (modalContentNode) {
|
||||||
document.body.removeEventListener("pointerdown", handlePointerDown);
|
document.body.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
@ -34,7 +34,7 @@ function MapSettings({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onSettingsChange("gridX", parseInt(e.target.value))
|
onSettingsChange("gridX", parseInt(e.target.value))
|
||||||
}
|
}
|
||||||
disabled={map === null || map.type === "default"}
|
disabled={!map || map.type === "default"}
|
||||||
min={1}
|
min={1}
|
||||||
my={1}
|
my={1}
|
||||||
/>
|
/>
|
||||||
@ -48,7 +48,7 @@ function MapSettings({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onSettingsChange("gridY", parseInt(e.target.value))
|
onSettingsChange("gridY", parseInt(e.target.value))
|
||||||
}
|
}
|
||||||
disabled={map === null || map.type === "default"}
|
disabled={!map || map.type === "default"}
|
||||||
min={1}
|
min={1}
|
||||||
my={1}
|
my={1}
|
||||||
/>
|
/>
|
||||||
@ -61,19 +61,15 @@ function MapSettings({
|
|||||||
<Flex my={1}>
|
<Flex my={1}>
|
||||||
<Label>
|
<Label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={mapState && mapState.editFlags.includes("fog")}
|
||||||
mapState !== null && mapState.editFlags.includes("fog")
|
disabled={!mapState}
|
||||||
}
|
|
||||||
disabled={mapState === null}
|
|
||||||
onChange={(e) => handleFlagChange(e, "fog")}
|
onChange={(e) => handleFlagChange(e, "fog")}
|
||||||
/>
|
/>
|
||||||
Fog
|
Fog
|
||||||
</Label>
|
</Label>
|
||||||
<Label>
|
<Label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={mapState && mapState.editFlags.includes("drawing")}
|
||||||
mapState !== null && mapState.editFlags.includes("drawing")
|
|
||||||
}
|
|
||||||
disabled={mapState === null}
|
disabled={mapState === null}
|
||||||
onChange={(e) => handleFlagChange(e, "drawing")}
|
onChange={(e) => handleFlagChange(e, "drawing")}
|
||||||
/>
|
/>
|
||||||
@ -81,10 +77,8 @@ function MapSettings({
|
|||||||
</Label>
|
</Label>
|
||||||
<Label>
|
<Label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={mapState && mapState.editFlags.includes("tokens")}
|
||||||
mapState !== null && mapState.editFlags.includes("tokens")
|
disabled={!mapState}
|
||||||
}
|
|
||||||
disabled={mapState === null}
|
|
||||||
onChange={(e) => handleFlagChange(e, "tokens")}
|
onChange={(e) => handleFlagChange(e, "tokens")}
|
||||||
/>
|
/>
|
||||||
Tokens
|
Tokens
|
||||||
@ -97,7 +91,7 @@ function MapSettings({
|
|||||||
name="name"
|
name="name"
|
||||||
value={(map && map.name) || ""}
|
value={(map && map.name) || ""}
|
||||||
onChange={(e) => onSettingsChange("name", e.target.value)}
|
onChange={(e) => onSettingsChange("name", e.target.value)}
|
||||||
disabled={map === null || map.type === "default"}
|
disabled={!map || map.type === "default"}
|
||||||
my={1}
|
my={1}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@ -115,7 +109,7 @@ function MapSettings({
|
|||||||
}}
|
}}
|
||||||
aria-label={showMore ? "Show Less" : "Show More"}
|
aria-label={showMore ? "Show Less" : "Show More"}
|
||||||
title={showMore ? "Show Less" : "Show More"}
|
title={showMore ? "Show Less" : "Show More"}
|
||||||
disabled={map === null}
|
disabled={!map}
|
||||||
>
|
>
|
||||||
<ExpandMoreIcon />
|
<ExpandMoreIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -6,7 +6,7 @@ import ResetMapIcon from "../../icons/ResetMapIcon";
|
|||||||
import ExpandMoreDotIcon from "../../icons/ExpandMoreDotIcon";
|
import ExpandMoreDotIcon from "../../icons/ExpandMoreDotIcon";
|
||||||
|
|
||||||
import useDataSource from "../../helpers/useDataSource";
|
import useDataSource from "../../helpers/useDataSource";
|
||||||
import { mapSources as defaultMapSources } from "../../maps";
|
import { mapSources as defaultMapSources, unknownSource } from "../../maps";
|
||||||
|
|
||||||
function MapTile({
|
function MapTile({
|
||||||
map,
|
map,
|
||||||
@ -15,9 +15,9 @@ function MapTile({
|
|||||||
onMapSelect,
|
onMapSelect,
|
||||||
onMapRemove,
|
onMapRemove,
|
||||||
onMapReset,
|
onMapReset,
|
||||||
onSubmit,
|
onDone,
|
||||||
}) {
|
}) {
|
||||||
const mapSource = useDataSource(map, defaultMapSources);
|
const mapSource = useDataSource(map, defaultMapSources, unknownSource);
|
||||||
const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false);
|
const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false);
|
||||||
const isDefault = map.type === "default";
|
const isDefault = map.type === "default";
|
||||||
const hasMapState =
|
const hasMapState =
|
||||||
@ -108,7 +108,7 @@ function MapTile({
|
|||||||
}}
|
}}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
if (!isMapTileMenuOpen) {
|
if (!isMapTileMenuOpen) {
|
||||||
onSubmit(e);
|
onDone(e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -17,7 +17,7 @@ function MapTiles({
|
|||||||
onMapAdd,
|
onMapAdd,
|
||||||
onMapRemove,
|
onMapRemove,
|
||||||
onMapReset,
|
onMapReset,
|
||||||
onSubmit,
|
onDone,
|
||||||
}) {
|
}) {
|
||||||
const { databaseStatus } = useContext(DatabaseContext);
|
const { databaseStatus } = useContext(DatabaseContext);
|
||||||
return (
|
return (
|
||||||
@ -69,7 +69,7 @@ function MapTiles({
|
|||||||
onMapSelect={onMapSelect}
|
onMapSelect={onMapSelect}
|
||||||
onMapRemove={onMapRemove}
|
onMapRemove={onMapRemove}
|
||||||
onMapReset={onMapReset}
|
onMapReset={onMapReset}
|
||||||
onSubmit={onSubmit}
|
onDone={onDone}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
@ -1,69 +1,230 @@
|
|||||||
import React, { useRef } from "react";
|
import React, { useContext, useState, useEffect, useRef } from "react";
|
||||||
import { Box, Image } from "theme-ui";
|
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 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 }) {
|
import TokenStatus from "../token/TokenStatus";
|
||||||
const imageSource = useDataSource(token, tokenSources);
|
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();
|
const imageRef = useRef();
|
||||||
// Stop touch to prevent 3d touch gesutre on iOS
|
useEffect(() => {
|
||||||
usePreventTouch(imageRef);
|
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 (
|
return (
|
||||||
<Box
|
<animated.Group
|
||||||
style={{
|
{...props}
|
||||||
transform: `translate(${tokenState.x * 100}%, ${tokenState.y * 100}%)`,
|
width={tokenWidth}
|
||||||
width: "100%",
|
height={tokenHeight}
|
||||||
height: "100%",
|
draggable={draggable}
|
||||||
}}
|
onMouseDown={handlePointerDown}
|
||||||
sx={{
|
onMouseUp={handlePointerUp}
|
||||||
position: "absolute",
|
onMouseOver={handlePointerOver}
|
||||||
pointerEvents: "none",
|
onMouseOut={handlePointerOut}
|
||||||
}}
|
onTouchStart={handlePointerDown}
|
||||||
|
onTouchEnd={handlePointerUp}
|
||||||
|
onClick={handleClick}
|
||||||
|
onTap={handleClick}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
opacity={tokenOpacity}
|
||||||
|
name={token && token.isVehicle ? "vehicle" : "token"}
|
||||||
|
id={tokenState.id}
|
||||||
>
|
>
|
||||||
<Box
|
<KonvaImage
|
||||||
style={{
|
ref={imageRef}
|
||||||
width: `${tokenSizePercent * (tokenState.size || 1)}%`,
|
width={tokenWidth}
|
||||||
}}
|
height={tokenHeight}
|
||||||
sx={{
|
x={0}
|
||||||
position: "absolute",
|
y={0}
|
||||||
pointerEvents: "all",
|
image={tokenSourceImage}
|
||||||
}}
|
rotation={tokenState.rotation}
|
||||||
>
|
offsetX={tokenWidth / 2}
|
||||||
<Box
|
offsetY={tokenHeight / 2}
|
||||||
sx={{
|
/>
|
||||||
position: "absolute",
|
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
|
||||||
display: "flex", // Set display to flex to fix height being calculated wrong
|
<TokenStatus
|
||||||
width: "100%",
|
tokenState={tokenState}
|
||||||
flexDirection: "column",
|
width={tokenWidth}
|
||||||
}}
|
height={tokenHeight}
|
||||||
>
|
/>
|
||||||
<Image
|
<TokenLabel
|
||||||
className={className}
|
tokenState={tokenState}
|
||||||
sx={{
|
width={tokenWidth}
|
||||||
userSelect: "none",
|
height={tokenHeight}
|
||||||
touchAction: "none",
|
/>
|
||||||
width: "100%",
|
</Group>
|
||||||
}}
|
</animated.Group>
|
||||||
src={imageSource}
|
|
||||||
// pass id into the dom element which is then used by the ProxyToken
|
|
||||||
data-id={tokenState.id}
|
|
||||||
ref={imageRef}
|
|
||||||
/>
|
|
||||||
{tokenState.statuses && (
|
|
||||||
<TokenStatus statuses={tokenState.statuses} />
|
|
||||||
)}
|
|
||||||
{tokenState.label && <TokenLabel label={tokenState.label} />}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,16 +1,26 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useContext } from "react";
|
||||||
import { IconButton } from "theme-ui";
|
import { IconButton } from "theme-ui";
|
||||||
|
|
||||||
import SelectMapModal from "../../modals/SelectMapModal";
|
import SelectMapModal from "../../modals/SelectMapModal";
|
||||||
import SelectMapIcon from "../../icons/SelectMapIcon";
|
import SelectMapIcon from "../../icons/SelectMapIcon";
|
||||||
|
|
||||||
function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) {
|
import MapDataContext from "../../contexts/MapDataContext";
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
|
||||||
|
function SelectMapButton({
|
||||||
|
onMapChange,
|
||||||
|
onMapStateChange,
|
||||||
|
currentMap,
|
||||||
|
currentMapState,
|
||||||
|
}) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const { updateMapState } = useContext(MapDataContext);
|
||||||
function openModal() {
|
function openModal() {
|
||||||
setIsAddModalOpen(true);
|
currentMapState && updateMapState(currentMapState.mapId, currentMapState);
|
||||||
|
setIsModalOpen(true);
|
||||||
}
|
}
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
setIsAddModalOpen(false);
|
setIsModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDone() {
|
function handleDone() {
|
||||||
@ -27,7 +37,7 @@ function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) {
|
|||||||
<SelectMapIcon />
|
<SelectMapIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<SelectMapModal
|
<SelectMapModal
|
||||||
isOpen={isAddModalOpen}
|
isOpen={isModalOpen}
|
||||||
onRequestClose={closeModal}
|
onRequestClose={closeModal}
|
||||||
onDone={handleDone}
|
onDone={handleDone}
|
||||||
onMapChange={onMapChange}
|
onMapChange={onMapChange}
|
||||||
|
@ -11,7 +11,7 @@ import BrushFillIcon from "../../../icons/BrushFillIcon";
|
|||||||
import UndoButton from "./UndoButton";
|
import UndoButton from "./UndoButton";
|
||||||
import RedoButton from "./RedoButton";
|
import RedoButton from "./RedoButton";
|
||||||
|
|
||||||
import Divider from "./Divider";
|
import Divider from "../../Divider";
|
||||||
|
|
||||||
function BrushToolSettings({
|
function BrushToolSettings({
|
||||||
settings,
|
settings,
|
||||||
|
@ -6,7 +6,7 @@ import EraseAllIcon from "../../../icons/EraseAllIcon";
|
|||||||
import UndoButton from "./UndoButton";
|
import UndoButton from "./UndoButton";
|
||||||
import RedoButton from "./RedoButton";
|
import RedoButton from "./RedoButton";
|
||||||
|
|
||||||
import Divider from "./Divider";
|
import Divider from "../../Divider";
|
||||||
|
|
||||||
function EraseToolSettings({ onToolAction, disabledActions }) {
|
function EraseToolSettings({ onToolAction, disabledActions }) {
|
||||||
return (
|
return (
|
||||||
|
@ -12,7 +12,7 @@ import FogToggleIcon from "../../../icons/FogToggleIcon";
|
|||||||
import UndoButton from "./UndoButton";
|
import UndoButton from "./UndoButton";
|
||||||
import RedoButton from "./RedoButton";
|
import RedoButton from "./RedoButton";
|
||||||
|
|
||||||
import Divider from "./Divider";
|
import Divider from "../../Divider";
|
||||||
|
|
||||||
function BrushToolSettings({
|
function BrushToolSettings({
|
||||||
settings,
|
settings,
|
||||||
|
@ -12,7 +12,7 @@ import ShapeTriangleIcon from "../../../icons/ShapeTriangleIcon";
|
|||||||
import UndoButton from "./UndoButton";
|
import UndoButton from "./UndoButton";
|
||||||
import RedoButton from "./RedoButton";
|
import RedoButton from "./RedoButton";
|
||||||
|
|
||||||
import Divider from "./Divider";
|
import Divider from "../../Divider";
|
||||||
|
|
||||||
function ShapeToolSettings({
|
function ShapeToolSettings({
|
||||||
settings,
|
settings,
|
||||||
|
@ -4,10 +4,10 @@ import { Box, Image } from "theme-ui";
|
|||||||
import usePreventTouch from "../../helpers/usePreventTouch";
|
import usePreventTouch from "../../helpers/usePreventTouch";
|
||||||
import useDataSource from "../../helpers/useDataSource";
|
import useDataSource from "../../helpers/useDataSource";
|
||||||
|
|
||||||
import { tokenSources } from "../../tokens";
|
import { tokenSources, unknownSource } from "../../tokens";
|
||||||
|
|
||||||
function ListToken({ token, className }) {
|
function ListToken({ token, className }) {
|
||||||
const imageSource = useDataSource(token, tokenSources);
|
const imageSource = useDataSource(token, tokenSources, unknownSource);
|
||||||
|
|
||||||
const imageRef = useRef();
|
const imageRef = useRef();
|
||||||
// Stop touch to prevent 3d touch gesutre on iOS
|
// Stop touch to prevent 3d touch gesutre on iOS
|
||||||
@ -19,7 +19,13 @@ function ListToken({ token, className }) {
|
|||||||
src={imageSource}
|
src={imageSource}
|
||||||
ref={imageRef}
|
ref={imageRef}
|
||||||
className={className}
|
className={className}
|
||||||
sx={{ userSelect: "none", touchAction: "none" }}
|
sx={{
|
||||||
|
userSelect: "none",
|
||||||
|
touchAction: "none",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
// pass id into the dom element which is then used by the ProxyToken
|
// pass id into the dom element which is then used by the ProxyToken
|
||||||
data-id={token.id}
|
data-id={token.id}
|
||||||
/>
|
/>
|
||||||
|
@ -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 ReactDOM from "react-dom";
|
||||||
import { Image, Box } from "theme-ui";
|
import { Image, Box } from "theme-ui";
|
||||||
import interact from "interactjs";
|
import interact from "interactjs";
|
||||||
|
|
||||||
import usePortal from "../../helpers/usePortal";
|
import usePortal from "../../helpers/usePortal";
|
||||||
|
|
||||||
import TokenLabel from "./TokenLabel";
|
import MapStageContext from "../../contexts/MapStageContext";
|
||||||
import TokenStatus from "./TokenStatus";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @callback onProxyDragEnd
|
* @callback onProxyDragEnd
|
||||||
@ -19,46 +18,33 @@ import TokenStatus from "./TokenStatus";
|
|||||||
* @param {string} tokenClassName The class name to attach the interactjs handler to
|
* @param {string} tokenClassName The class name to attach the interactjs handler to
|
||||||
* @param {onProxyDragEnd} onProxyDragEnd Called when the proxy token is dropped
|
* @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} 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({
|
function ProxyToken({ tokenClassName, onProxyDragEnd, tokens }) {
|
||||||
tokenClassName,
|
|
||||||
onProxyDragEnd,
|
|
||||||
tokens,
|
|
||||||
disabledTokens,
|
|
||||||
}) {
|
|
||||||
const proxyContainer = usePortal("root");
|
const proxyContainer = usePortal("root");
|
||||||
|
|
||||||
const [imageSource, setImageSource] = useState("");
|
const [imageSource, setImageSource] = useState("");
|
||||||
const [tokenId, setTokenId] = useState(null);
|
|
||||||
const proxyRef = useRef();
|
const proxyRef = useRef();
|
||||||
|
|
||||||
// Store the tokens in a ref and access in the interactjs loop
|
// Store the tokens in a ref and access in the interactjs loop
|
||||||
// This is needed to stop interactjs from creating multiple listeners
|
// This is needed to stop interactjs from creating multiple listeners
|
||||||
const tokensRef = useRef(tokens);
|
const tokensRef = useRef(tokens);
|
||||||
const disabledTokensRef = useRef(disabledTokens);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tokensRef.current = tokens;
|
tokensRef.current = tokens;
|
||||||
disabledTokensRef.current = disabledTokens;
|
}, [tokens]);
|
||||||
}, [tokens, disabledTokens]);
|
|
||||||
|
|
||||||
const proxyOnMap = useRef(false);
|
const proxyOnMap = useRef(false);
|
||||||
|
const mapStageRef = useContext(MapStageContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
interact(`.${tokenClassName}`).draggable({
|
interact(`.${tokenClassName}`).draggable({
|
||||||
listeners: {
|
listeners: {
|
||||||
start: (event) => {
|
start: (event) => {
|
||||||
let target = event.target;
|
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
|
// Hide the token and copy it's image to the proxy
|
||||||
target.parentElement.style.opacity = "0.25";
|
target.parentElement.style.opacity = "0.25";
|
||||||
setImageSource(target.src);
|
setImageSource(target.src);
|
||||||
setTokenId(id);
|
|
||||||
|
|
||||||
let proxy = proxyRef.current;
|
let proxy = proxyRef.current;
|
||||||
if (proxy) {
|
if (proxy) {
|
||||||
@ -105,23 +91,29 @@ function ProxyToken({
|
|||||||
end: (event) => {
|
end: (event) => {
|
||||||
let target = event.target;
|
let target = event.target;
|
||||||
const id = target.dataset.id;
|
const id = target.dataset.id;
|
||||||
if (id in disabledTokensRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let proxy = proxyRef.current;
|
let proxy = proxyRef.current;
|
||||||
if (proxy) {
|
if (proxy) {
|
||||||
const mapImage = document.querySelector(".mapImage");
|
const mapStage = mapStageRef.current;
|
||||||
if (onProxyDragEnd && mapImage) {
|
if (onProxyDragEnd && mapStage) {
|
||||||
const mapImageRect = mapImage.getBoundingClientRect();
|
const mapImageRect = mapStage
|
||||||
|
.findOne("#mapImage")
|
||||||
|
.getClientRect();
|
||||||
|
|
||||||
|
const map = document.querySelector(".map");
|
||||||
|
const mapRect = map.getBoundingClientRect();
|
||||||
|
|
||||||
let x = parseFloat(proxy.getAttribute("data-x")) || 0;
|
let x = parseFloat(proxy.getAttribute("data-x")) || 0;
|
||||||
let y = parseFloat(proxy.getAttribute("data-y")) || 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
|
// Convert coordiantes to be relative to the map
|
||||||
x = x - mapImageRect.left;
|
x = x - mapRect.left - mapImageRect.x;
|
||||||
y = y - mapImageRect.top;
|
y = y - mapRect.top - mapImageRect.y;
|
||||||
|
|
||||||
// Normalize to map width
|
// Normalize to map width
|
||||||
x = x / (mapImageRect.right - mapImageRect.left);
|
x = x / mapImageRect.width;
|
||||||
y = y / (mapImageRect.bottom - mapImageRect.top);
|
y = y / mapImageRect.height;
|
||||||
|
|
||||||
// Get the token from the supplied tokens if it exists
|
// Get the token from the supplied tokens if it exists
|
||||||
const token = tokensRef.current[id] || {};
|
const token = tokensRef.current[id] || {};
|
||||||
@ -145,7 +137,7 @@ function ProxyToken({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [onProxyDragEnd, tokenClassName, proxyContainer]);
|
}, [onProxyDragEnd, tokenClassName, proxyContainer, mapStageRef]);
|
||||||
|
|
||||||
if (!imageSource) {
|
if (!imageSource) {
|
||||||
return null;
|
return null;
|
||||||
@ -175,12 +167,6 @@ function ProxyToken({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{tokens[tokenId] && tokens[tokenId].statuses && (
|
|
||||||
<TokenStatus statuses={tokens[tokenId].statuses} />
|
|
||||||
)}
|
|
||||||
{tokens[tokenId] && tokens[tokenId].label && (
|
|
||||||
<TokenLabel label={tokens[tokenId].label} />
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>,
|
</Box>,
|
||||||
proxyContainer
|
proxyContainer
|
||||||
@ -189,7 +175,6 @@ function ProxyToken({
|
|||||||
|
|
||||||
ProxyToken.defaultProps = {
|
ProxyToken.defaultProps = {
|
||||||
tokens: {},
|
tokens: {},
|
||||||
disabledTokens: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProxyToken;
|
export default ProxyToken;
|
||||||
|
38
src/components/token/SelectTokensButton.js
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Edit Tokens"
|
||||||
|
title="Edit Tokens"
|
||||||
|
onClick={openModal}
|
||||||
|
>
|
||||||
|
<SelectTokensIcon />
|
||||||
|
</IconButton>
|
||||||
|
<SelectTokensModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onRequestClose={closeModal}
|
||||||
|
onDone={handleDone}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectTokensButton;
|
133
src/components/token/TokenDragOverlay.js
Normal file
@ -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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "32px",
|
||||||
|
left: "50%",
|
||||||
|
borderRadius: "50%",
|
||||||
|
transform: isRemoveHovered
|
||||||
|
? "translateX(-50%) scale(2.0)"
|
||||||
|
: "translateX(-50%) scale(1.5)",
|
||||||
|
transition: "transform 250ms ease",
|
||||||
|
color: isRemoveHovered ? "primary" : "text",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
bg="overlay"
|
||||||
|
ref={removeTokenRef}
|
||||||
|
>
|
||||||
|
<IconButton>
|
||||||
|
<RemoveTokenIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokenDragOverlay;
|
@ -1,51 +1,50 @@
|
|||||||
import React from "react";
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
import { Image, Box, Text } from "theme-ui";
|
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 (
|
return (
|
||||||
<Box
|
<Group y={height - (fontSize + paddingY) / 2}>
|
||||||
sx={{
|
<Rect
|
||||||
position: "absolute",
|
y={-paddingY / 2}
|
||||||
transform: "scale(0.3) translate(0, 20%)",
|
width={rectWidth}
|
||||||
transformOrigin: "bottom center",
|
offsetX={width / 2}
|
||||||
pointerEvents: "none",
|
x={width - rectWidth / 2}
|
||||||
width: "100%",
|
height={fontSize + paddingY}
|
||||||
display: "flex", // Set display to flex to fix height being calculated wrong
|
fill="hsla(230, 25%, 18%, 0.8)"
|
||||||
flexDirection: "column",
|
cornerRadius={(fontSize + paddingY) / 2}
|
||||||
}}
|
/>
|
||||||
>
|
<Text
|
||||||
<Image sx={{ width: "100%" }} src={tokenLabel} />
|
ref={textRef}
|
||||||
<svg
|
width={width}
|
||||||
style={{
|
text={tokenState.label}
|
||||||
position: "absolute",
|
fontSize={fontSize}
|
||||||
top: 0,
|
lineHeight={1}
|
||||||
left: 0,
|
align="center"
|
||||||
}}
|
verticalAlign="bottom"
|
||||||
viewBox="0 0 100 100"
|
fill="white"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
paddingX={paddingX}
|
||||||
>
|
paddingY={paddingY}
|
||||||
<foreignObject width="100%" height="100%">
|
wrap="none"
|
||||||
<Text
|
ellipsis={true}
|
||||||
as="p"
|
hitFunc={() => {}}
|
||||||
variant="heading"
|
/>
|
||||||
sx={{
|
</Group>
|
||||||
// This value is actually 66%
|
|
||||||
fontSize: "66px",
|
|
||||||
width: "100px",
|
|
||||||
height: "100px",
|
|
||||||
textAlign: "center",
|
|
||||||
verticalAlign: "middle",
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
color="hsl(210, 50%, 96%)"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</foreignObject>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,119 +1,78 @@
|
|||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import interact from "interactjs";
|
import { Box, Input, Slider, Flex, Text } from "theme-ui";
|
||||||
import { Box, Input } from "theme-ui";
|
|
||||||
|
|
||||||
import MapMenu from "../map/MapMenu";
|
import MapMenu from "../map/MapMenu";
|
||||||
|
|
||||||
import colors, { colorOptions } from "../../helpers/colors";
|
import colors, { colorOptions } from "../../helpers/colors";
|
||||||
|
|
||||||
/**
|
import usePrevious from "../../helpers/usePrevious";
|
||||||
* @callback onTokenChange
|
|
||||||
* @param {Object} token the token that was changed
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
const defaultTokenMaxSize = 6;
|
||||||
*
|
function TokenMenu({
|
||||||
* @param {string} tokenClassName The class name to attach the interactjs handler to
|
isOpen,
|
||||||
* @param {onProxyDragEnd} onTokenChange Called when the the token data is changed
|
onRequestClose,
|
||||||
* @param {Object} tokens An mapping of tokens to use as a base when calling onTokenChange
|
tokenState,
|
||||||
* @param {Object} disabledTokens An optional mapping of tokens that shouldn't allow interaction
|
tokenImage,
|
||||||
*/
|
onTokenStateChange,
|
||||||
function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const wasOpen = usePrevious(isOpen);
|
||||||
|
|
||||||
function handleRequestClose() {
|
const [tokenMaxSize, setTokenMaxSize] = useState(defaultTokenMaxSize);
|
||||||
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 [menuLeft, setMenuLeft] = useState(0);
|
const [menuLeft, setMenuLeft] = useState(0);
|
||||||
const [menuTop, setMenuTop] = 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) {
|
function handleLabelChange(event) {
|
||||||
// Slice to remove Label: text
|
const label = event.target.value;
|
||||||
const label = event.target.value.slice(7);
|
onTokenStateChange({ [tokenState.id]: { ...tokenState, label: label } });
|
||||||
if (label.length <= 1) {
|
|
||||||
setCurrentToken((prevToken) => ({
|
|
||||||
...prevToken,
|
|
||||||
label: label,
|
|
||||||
}));
|
|
||||||
|
|
||||||
onTokenChange({ ...currentToken, label: label });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStatusChange(status) {
|
function handleStatusChange(status) {
|
||||||
const statuses = currentToken.statuses;
|
const statuses = tokenState.statuses;
|
||||||
let newStatuses = [];
|
let newStatuses = [];
|
||||||
if (statuses.includes(status)) {
|
if (statuses.includes(status)) {
|
||||||
newStatuses = statuses.filter((s) => s !== status);
|
newStatuses = statuses.filter((s) => s !== status);
|
||||||
} else {
|
} else {
|
||||||
newStatuses = [...statuses, status];
|
newStatuses = [...statuses, status];
|
||||||
}
|
}
|
||||||
setCurrentToken((prevToken) => ({
|
onTokenStateChange({
|
||||||
...prevToken,
|
[tokenState.id]: { ...tokenState, statuses: newStatuses },
|
||||||
statuses: newStatuses,
|
});
|
||||||
}));
|
|
||||||
onTokenChange({ ...currentToken, statuses: newStatuses });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
function handleSizeChange(event) {
|
||||||
function handleTokenMenuOpen(event) {
|
const newSize = parseInt(event.target.value);
|
||||||
const target = event.target;
|
onTokenStateChange({ [tokenState.id]: { ...tokenState, size: newSize } });
|
||||||
const id = target.getAttribute("data-id");
|
}
|
||||||
if (id in disabledTokensRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const token = tokensRef.current[id] || {};
|
|
||||||
setCurrentToken(token);
|
|
||||||
|
|
||||||
const targetRect = target.getBoundingClientRect();
|
function handleRotationChange(event) {
|
||||||
setMenuLeft(targetRect.left);
|
const newRotation = parseInt(event.target.value);
|
||||||
setMenuTop(targetRect.bottom);
|
onTokenStateChange({
|
||||||
|
[tokenState.id]: { ...tokenState, rotation: newRotation },
|
||||||
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 handleModalContent(node) {
|
function handleModalContent(node) {
|
||||||
if (node) {
|
if (node) {
|
||||||
// Focus input
|
// Focus input
|
||||||
const tokenLabelInput = node.querySelector("#changeTokenLabel");
|
const tokenLabelInput = node.querySelector("#changeTokenLabel");
|
||||||
tokenLabelInput.focus();
|
tokenLabelInput.focus();
|
||||||
tokenLabelInput.setSelectionRange(7, 8);
|
tokenLabelInput.select();
|
||||||
|
|
||||||
// Ensure menu is in bounds
|
// Ensure menu is in bounds
|
||||||
const nodeRect = node.getBoundingClientRect();
|
const nodeRect = node.getBoundingClientRect();
|
||||||
@ -134,23 +93,32 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
|
|||||||
return (
|
return (
|
||||||
<MapMenu
|
<MapMenu
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onRequestClose={handleRequestClose}
|
onRequestClose={onRequestClose}
|
||||||
top={`${menuTop}px`}
|
top={`${menuTop}px`}
|
||||||
left={`${menuLeft}px`}
|
left={`${menuLeft}px`}
|
||||||
onModalContent={handleModalContent}
|
onModalContent={handleModalContent}
|
||||||
>
|
>
|
||||||
<Box sx={{ width: "104px" }} p={1}>
|
<Box sx={{ width: "156px" }} p={1}>
|
||||||
<Box
|
<Flex
|
||||||
as="form"
|
as="form"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleRequestClose();
|
onRequestClose();
|
||||||
}}
|
}}
|
||||||
|
sx={{ alignItems: "center" }}
|
||||||
>
|
>
|
||||||
|
<Text
|
||||||
|
as="label"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ width: "45%", fontSize: "16px" }}
|
||||||
|
p={1}
|
||||||
|
>
|
||||||
|
Label:
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
id="changeTokenLabel"
|
id="changeTokenLabel"
|
||||||
onChange={handleLabelChange}
|
onChange={handleLabelChange}
|
||||||
value={`Label: ${currentToken.label}`}
|
value={(tokenState && tokenState.label) || ""}
|
||||||
sx={{
|
sx={{
|
||||||
padding: "4px",
|
padding: "4px",
|
||||||
border: "none",
|
border: "none",
|
||||||
@ -160,7 +128,7 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
|
|||||||
}}
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Flex>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@ -172,8 +140,8 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
|
|||||||
<Box
|
<Box
|
||||||
key={color}
|
key={color}
|
||||||
sx={{
|
sx={{
|
||||||
width: "25%",
|
width: "16.66%",
|
||||||
paddingTop: "25%",
|
paddingTop: "16.66%",
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
transform: "scale(0.75)",
|
transform: "scale(0.75)",
|
||||||
backgroundColor: colors[color],
|
backgroundColor: colors[color],
|
||||||
@ -182,21 +150,59 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
|
|||||||
onClick={() => handleStatusChange(color)}
|
onClick={() => handleStatusChange(color)}
|
||||||
aria-label={`Token label Color ${color}`}
|
aria-label={`Token label Color ${color}`}
|
||||||
>
|
>
|
||||||
{currentToken.statuses && currentToken.statuses.includes(color) && (
|
{tokenState &&
|
||||||
<Box
|
tokenState.statuses &&
|
||||||
sx={{
|
tokenState.statuses.includes(color) && (
|
||||||
width: "100%",
|
<Box
|
||||||
height: "100%",
|
sx={{
|
||||||
border: "2px solid white",
|
width: "100%",
|
||||||
position: "absolute",
|
height: "100%",
|
||||||
top: 0,
|
border: "2px solid white",
|
||||||
borderRadius: "50%",
|
position: "absolute",
|
||||||
}}
|
top: 0,
|
||||||
/>
|
borderRadius: "50%",
|
||||||
)}
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Flex sx={{ alignItems: "center" }}>
|
||||||
|
<Text
|
||||||
|
as="label"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ width: "40%", fontSize: "16px" }}
|
||||||
|
p={1}
|
||||||
|
>
|
||||||
|
Size:
|
||||||
|
</Text>
|
||||||
|
<Slider
|
||||||
|
value={(tokenState && tokenState.size) || 1}
|
||||||
|
onChange={handleSizeChange}
|
||||||
|
step={1}
|
||||||
|
min={1}
|
||||||
|
max={tokenMaxSize}
|
||||||
|
mr={1}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Flex sx={{ alignItems: "center" }}>
|
||||||
|
<Text
|
||||||
|
as="label"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ width: "95%", fontSize: "16px" }}
|
||||||
|
p={1}
|
||||||
|
>
|
||||||
|
Rotation:
|
||||||
|
</Text>
|
||||||
|
<Slider
|
||||||
|
value={(tokenState && tokenState.rotation) || 0}
|
||||||
|
onChange={handleRotationChange}
|
||||||
|
step={45}
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
mr={1}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
</MapMenu>
|
</MapMenu>
|
||||||
);
|
);
|
||||||
|
90
src/components/token/TokenSettings.js
Normal file
@ -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 (
|
||||||
|
<Flex sx={{ flexDirection: "column" }}>
|
||||||
|
<Flex>
|
||||||
|
<Box mt={2} sx={{ flexGrow: 1 }}>
|
||||||
|
<Label htmlFor="tokenSize">Default Size</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="tokenSize"
|
||||||
|
value={(token && token.defaultSize) || 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
onSettingsChange("defaultSize", parseInt(e.target.value))
|
||||||
|
}
|
||||||
|
disabled={!token || token.type === "default"}
|
||||||
|
min={1}
|
||||||
|
my={1}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
{showMore && (
|
||||||
|
<>
|
||||||
|
<Box mt={2} sx={{ flexGrow: 1 }}>
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
value={(token && token.name) || ""}
|
||||||
|
onChange={(e) => onSettingsChange("name", e.target.value)}
|
||||||
|
disabled={!token || token.type === "default"}
|
||||||
|
my={1}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Flex my={2}>
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<Label>
|
||||||
|
<Checkbox
|
||||||
|
checked={token && token.isVehicle}
|
||||||
|
disabled={!token || token.type === "default"}
|
||||||
|
onChange={(e) =>
|
||||||
|
onSettingsChange("isVehicle", e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Vehicle / Mount
|
||||||
|
</Label>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<Label>
|
||||||
|
<Checkbox
|
||||||
|
checked={token && token.hideInSidebar}
|
||||||
|
disabled={!token || token.type === "default"}
|
||||||
|
onChange={(e) =>
|
||||||
|
onSettingsChange("hideInSidebar", e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Hide in Sidebar
|
||||||
|
</Label>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
onClick={(e) => {
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<ExpandMoreIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokenSettings;
|
@ -1,46 +1,25 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box } from "theme-ui";
|
import { Circle, Group } from "react-konva";
|
||||||
|
|
||||||
import colors from "../../helpers/colors";
|
import colors from "../../helpers/colors";
|
||||||
|
|
||||||
function TokenStatus({ statuses }) {
|
function TokenStatus({ tokenState, width, height }) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Group x={width} y={height} offsetX={width / 2} offsetY={height / 2}>
|
||||||
sx={{
|
{tokenState.statuses.map((status, index) => (
|
||||||
position: "absolute",
|
<Circle
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{statuses.map((status, index) => (
|
|
||||||
<Box
|
|
||||||
key={status}
|
key={status}
|
||||||
sx={{
|
width={width}
|
||||||
width: "100%",
|
height={height}
|
||||||
height: "100%",
|
stroke={colors[status]}
|
||||||
position: "absolute",
|
strokeWidth={width / 20 / tokenState.size}
|
||||||
opacity: 0.8,
|
scaleX={1 - index / 10 / tokenState.size}
|
||||||
transform: `scale(${1 - index / 10})`,
|
scaleY={1 - index / 10 / tokenState.size}
|
||||||
}}
|
opacity={0.8}
|
||||||
>
|
fillEnabled={false}
|
||||||
<svg
|
/>
|
||||||
style={{ position: "absolute" }}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
r={47}
|
|
||||||
cx={50}
|
|
||||||
cy={50}
|
|
||||||
fill="none"
|
|
||||||
stroke={colors[status]}
|
|
||||||
strokeWidth={4}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
81
src/components/token/TokenTile.js
Normal file
@ -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 (
|
||||||
|
<Flex
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||||
|
src={tokenSource}
|
||||||
|
/>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background:
|
||||||
|
"linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.65) 100%);",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
p={2}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="heading"
|
||||||
|
color="hsl(210, 50%, 96%)"
|
||||||
|
sx={{ textAlign: "center" }}
|
||||||
|
>
|
||||||
|
{token.name}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
{isSelected && !isDefault && (
|
||||||
|
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Remove Map"
|
||||||
|
title="Remove Map"
|
||||||
|
onClick={() => {
|
||||||
|
onTokenRemove(token.id);
|
||||||
|
}}
|
||||||
|
bg="overlay"
|
||||||
|
sx={{ borderRadius: "50%" }}
|
||||||
|
m={1}
|
||||||
|
>
|
||||||
|
<RemoveTokenIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokenTile;
|
67
src/components/token/TokenTiles.js
Normal file
@ -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 (
|
||||||
|
<SimpleBar style={{ maxHeight: "300px", width: "500px" }}>
|
||||||
|
<Flex
|
||||||
|
py={2}
|
||||||
|
bg="muted"
|
||||||
|
sx={{
|
||||||
|
flexWrap: "wrap",
|
||||||
|
width: "500px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
onClick={onTokenAdd}
|
||||||
|
sx={{
|
||||||
|
":hover": {
|
||||||
|
color: "primary",
|
||||||
|
},
|
||||||
|
":focus": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
":active": {
|
||||||
|
color: "secondary",
|
||||||
|
},
|
||||||
|
width: "150px",
|
||||||
|
height: "150px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
m={2}
|
||||||
|
bg="muted"
|
||||||
|
aria-label="Add Token"
|
||||||
|
title="Add Token"
|
||||||
|
>
|
||||||
|
<AddIcon large />
|
||||||
|
</Flex>
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<TokenTile
|
||||||
|
key={token.id}
|
||||||
|
token={token}
|
||||||
|
isSelected={selectedToken && token.id === selectedToken.id}
|
||||||
|
onTokenSelect={onTokenSelect}
|
||||||
|
onTokenRemove={onTokenRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</SimpleBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokenTiles;
|
@ -1,34 +1,38 @@
|
|||||||
import React, { useState, useContext } from "react";
|
import React, { useContext } from "react";
|
||||||
import { Box } from "theme-ui";
|
import { Box, Flex } from "theme-ui";
|
||||||
import shortid from "shortid";
|
import shortid from "shortid";
|
||||||
import SimpleBar from "simplebar-react";
|
import SimpleBar from "simplebar-react";
|
||||||
|
|
||||||
import ListToken from "./ListToken";
|
import ListToken from "./ListToken";
|
||||||
import ProxyToken from "./ProxyToken";
|
import ProxyToken from "./ProxyToken";
|
||||||
import NumberInput from "../NumberInput";
|
|
||||||
|
import SelectTokensButton from "./SelectTokensButton";
|
||||||
|
|
||||||
import { fromEntries } from "../../helpers/shared";
|
import { fromEntries } from "../../helpers/shared";
|
||||||
|
|
||||||
import AuthContext from "../../contexts/AuthContext";
|
import AuthContext from "../../contexts/AuthContext";
|
||||||
|
import TokenDataContext from "../../contexts/TokenDataContext";
|
||||||
|
|
||||||
const listTokenClassName = "list-token";
|
const listTokenClassName = "list-token";
|
||||||
|
|
||||||
function Tokens({ onCreateMapTokenState, tokens }) {
|
function Tokens({ onMapTokenStateCreate }) {
|
||||||
const [tokenSize, setTokenSize] = useState(1);
|
|
||||||
const { userId } = useContext(AuthContext);
|
const { userId } = useContext(AuthContext);
|
||||||
|
const { ownedTokens, tokens } = useContext(TokenDataContext);
|
||||||
|
|
||||||
function handleProxyDragEnd(isOnMap, token) {
|
function handleProxyDragEnd(isOnMap, token) {
|
||||||
if (isOnMap && onCreateMapTokenState) {
|
if (isOnMap && onMapTokenStateCreate) {
|
||||||
// Create a token state from the dragged token
|
// Create a token state from the dragged token
|
||||||
onCreateMapTokenState({
|
onMapTokenStateCreate({
|
||||||
id: shortid.generate(),
|
id: shortid.generate(),
|
||||||
tokenId: token.id,
|
tokenId: token.id,
|
||||||
owner: userId,
|
owner: userId,
|
||||||
size: tokenSize,
|
size: token.defaultSize,
|
||||||
label: "",
|
label: "",
|
||||||
statuses: [],
|
statuses: [],
|
||||||
x: token.x,
|
x: token.x,
|
||||||
y: token.y,
|
y: token.y,
|
||||||
|
lastEditedBy: userId,
|
||||||
|
rotation: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -43,24 +47,27 @@ function Tokens({ onCreateMapTokenState, tokens }) {
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SimpleBar style={{ height: "calc(100% - 58px)", overflowX: "hidden" }}>
|
<SimpleBar style={{ height: "calc(100% - 48px)", overflowX: "hidden" }}>
|
||||||
{tokens.map((token) => (
|
{ownedTokens
|
||||||
<ListToken
|
.filter((token) => !token.hideInSidebar)
|
||||||
key={token.id}
|
.map((token) => (
|
||||||
token={token}
|
<ListToken
|
||||||
className={listTokenClassName}
|
key={token.id}
|
||||||
/>
|
token={token}
|
||||||
))}
|
className={listTokenClassName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</SimpleBar>
|
</SimpleBar>
|
||||||
<Box pt={1} bg="muted" sx={{ height: "58px" }}>
|
<Flex
|
||||||
<NumberInput
|
bg="muted"
|
||||||
value={tokenSize}
|
sx={{
|
||||||
onChange={setTokenSize}
|
justifyContent: "center",
|
||||||
title="Size"
|
height: "48px",
|
||||||
min={1}
|
alignItems: "center",
|
||||||
max={9}
|
}}
|
||||||
/>
|
>
|
||||||
</Box>
|
<SelectTokensButton />
|
||||||
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
<ProxyToken
|
<ProxyToken
|
||||||
tokenClassName={listTokenClassName}
|
tokenClassName={listTokenClassName}
|
||||||
|
31
src/contexts/DiceLoadingContext.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
const DiceLoadingContext = React.createContext();
|
||||||
|
|
||||||
|
export function DiceLoadingProvider({ 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 (
|
||||||
|
<DiceLoadingContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</DiceLoadingContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiceLoadingContext;
|
166
src/contexts/MapDataContext.js
Normal file
@ -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 (
|
||||||
|
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapDataContext;
|
@ -1,8 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const MapInteractionContext = React.createContext({
|
const MapInteractionContext = React.createContext({
|
||||||
translateRef: null,
|
stageScale: 1,
|
||||||
scaleRef: null,
|
stageWidth: 1,
|
||||||
|
stageHeight: 1,
|
||||||
|
stageDragState: "none",
|
||||||
|
setPreventMapInteraction: () => {},
|
||||||
|
mapWidth: 1,
|
||||||
|
mapHeight: 1,
|
||||||
|
mapDragPositionRef: { current: undefined },
|
||||||
});
|
});
|
||||||
export const MapInteractionProvider = MapInteractionContext.Provider;
|
export const MapInteractionProvider = MapInteractionContext.Provider;
|
||||||
|
|
||||||
|
31
src/contexts/MapLoadingContext.js
Normal file
@ -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 (
|
||||||
|
<MapLoadingContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</MapLoadingContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapLoadingContext;
|
8
src/contexts/MapStageContext.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const MapStageContext = React.createContext({
|
||||||
|
mapStageRef: { current: null },
|
||||||
|
});
|
||||||
|
export const MapStageProvider = MapStageContext.Provider;
|
||||||
|
|
||||||
|
export default MapStageContext;
|
112
src/contexts/TokenDataContext.js
Normal file
@ -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 (
|
||||||
|
<TokenDataContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</TokenDataContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokenDataContext;
|
@ -26,6 +26,69 @@ function loadVersions(db) {
|
|||||||
map.file = mapBuffers[map.id];
|
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
|
// Get the dexie database used in DatabaseContext
|
||||||
|
154
src/dice/Dice.js
Normal file
@ -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;
|
175
src/dice/diceTray/DiceTray.js
Normal file
@ -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;
|
BIN
src/dice/diceTray/double.glb
Normal file
BIN
src/dice/diceTray/doubleAlbedo.jpg
Normal file
After Width: | Height: | Size: 1.9 MiB |
BIN
src/dice/diceTray/doubleMetalRoughness.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
src/dice/diceTray/doubleNormal.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
src/dice/diceTray/single.glb
Normal file
BIN
src/dice/diceTray/singleAlbedo.jpg
Normal file
After Width: | Height: | Size: 1.8 MiB |
BIN
src/dice/diceTray/singleMetalRoughness.jpg
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
src/dice/diceTray/singleNormal.jpg
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
src/dice/environment.dds
Executable file
37
src/dice/galaxy/GalaxyDice.js
Normal file
@ -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;
|
BIN
src/dice/galaxy/albedo.jpg
Executable file
After Width: | Height: | Size: 2.4 MiB |
BIN
src/dice/galaxy/metalRoughness.jpg
Executable file
After Width: | Height: | Size: 1.7 MiB |
BIN
src/dice/galaxy/normal.jpg
Executable file
After Width: | Height: | Size: 292 KiB |
BIN
src/dice/galaxy/preview.png
Normal file
After Width: | Height: | Size: 69 KiB |
64
src/dice/gemstone/GemstoneDice.js
Normal file
@ -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;
|
BIN
src/dice/gemstone/albedo.jpg
Executable file
After Width: | Height: | Size: 2.1 MiB |
BIN
src/dice/gemstone/metalRoughness.jpg
Executable file
After Width: | Height: | Size: 1.9 MiB |
BIN
src/dice/gemstone/normal.jpg
Executable file
After Width: | Height: | Size: 291 KiB |
BIN
src/dice/gemstone/preview.png
Normal file
After Width: | Height: | Size: 71 KiB |
66
src/dice/glass/GlassDice.js
Normal file
@ -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;
|
BIN
src/dice/glass/albedo.jpg
Executable file
After Width: | Height: | Size: 585 KiB |
BIN
src/dice/glass/mask.png
Normal file
After Width: | Height: | Size: 217 KiB |
BIN
src/dice/glass/normal.jpg
Executable file
After Width: | Height: | Size: 292 KiB |
BIN
src/dice/glass/preview.png
Normal file
After Width: | Height: | Size: 44 KiB |
48
src/dice/index.js
Normal file
@ -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],
|
||||||
|
}));
|
42
src/dice/iron/IronDice.js
Normal file
@ -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;
|
BIN
src/dice/iron/albedo.jpg
Executable file
After Width: | Height: | Size: 1.1 MiB |
BIN
src/dice/iron/metalRoughness.jpg
Executable file
After Width: | Height: | Size: 1.3 MiB |
BIN
src/dice/iron/normal.jpg
Executable file
After Width: | Height: | Size: 316 KiB |
BIN
src/dice/iron/preview.png
Normal file
After Width: | Height: | Size: 54 KiB |
37
src/dice/nebula/NebulaDice.js
Normal file
@ -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;
|
BIN
src/dice/nebula/albedo.jpg
Executable file
After Width: | Height: | Size: 1.9 MiB |
BIN
src/dice/nebula/metalRoughness.jpg
Executable file
After Width: | Height: | Size: 1.5 MiB |
BIN
src/dice/nebula/normal.jpg
Executable file
After Width: | Height: | Size: 613 KiB |
BIN
src/dice/nebula/preview.png
Normal file
After Width: | Height: | Size: 74 KiB |
BIN
src/dice/shared/d10.glb
Normal file
BIN
src/dice/shared/d100.glb
Normal file
BIN
src/dice/shared/d12.glb
Normal file
BIN
src/dice/shared/d20.glb
Normal file
BIN
src/dice/shared/d4.glb
Normal file
BIN
src/dice/shared/d6.glb
Normal file
BIN
src/dice/shared/d8.glb
Normal file
37
src/dice/sunrise/SunriseDice.js
Normal file
@ -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;
|
BIN
src/dice/sunrise/albedo.jpg
Executable file
After Width: | Height: | Size: 1.5 MiB |
BIN
src/dice/sunrise/metalRoughness.jpg
Executable file
After Width: | Height: | Size: 972 KiB |
BIN
src/dice/sunrise/normal.jpg
Executable file
After Width: | Height: | Size: 292 KiB |
BIN
src/dice/sunrise/preview.png
Normal file
After Width: | Height: | Size: 58 KiB |
37
src/dice/sunset/SunsetDice.js
Normal file
@ -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;
|
BIN
src/dice/sunset/albedo.jpg
Executable file
After Width: | Height: | Size: 1.4 MiB |
BIN
src/dice/sunset/metalRoughness.jpg
Executable file
After Width: | Height: | Size: 916 KiB |