Merge pull request #8 from mitchemmc/release/v1.3.0

Release/v1.3.0
This commit is contained in:
Mitchell McCaffrey 2020-05-29 09:24:40 +10:00 committed by GitHub
commit 93fa46321b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
198 changed files with 4961 additions and 1675 deletions

View File

@ -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": {

View File

@ -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">
<MapLoadingProvider>
<MapDataProvider>
<TokenDataProvider>
<Game /> <Game />
</TokenDataProvider>
</MapDataProvider>
</MapLoadingProvider>
</Route> </Route>
<Route path="/"> <Route path="/">
<Home /> <Home />

View File

@ -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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -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}
aspectRatio={aspectRatio} controls={
isEnabled={selectedToolId === "pan"} <>
controls={mapControls} {mapControls}
loading={loading} {tokenMenu}
> {tokenDragOverlay}
{map && mapImage} <MapDice />
{map && mapDrawing} {isLoading && <LoadingOverlay />}
{map && mapFog}
{map && mapTokens}
</MapInteraction>
<ProxyToken
tokenClassName={mapTokenProxyClassName}
onProxyDragEnd={handleProxyDragEnd}
tokens={mapState && mapState.tokens}
disabledTokens={disabledTokens}
/>
<TokenMenu
tokenClassName={mapTokenMenuClassName}
onTokenChange={onMapTokenStateChange}
tokens={mapState && mapState.tokens}
disabledTokens={disabledTokens}
/>
</> </>
}
selectedToolId={selectedToolId}
>
{mapDrawing}
{mapTokens}
{mapFog}
</MapInteraction>
); );
} }

View File

@ -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>

View 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;

View File

@ -1,123 +1,81 @@
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(() => {
setPointerPosition({ x: -1, y: -1 });
}, [selectedTool]);
function handleStart(event) {
if (!isEditing) {
return;
}
if (event.touches && event.touches.length !== 1) {
setIsPointerDown(false);
setDrawingShape(null);
return;
}
const pointer = event.touches ? event.touches[0] : event;
const position = getRelativePointerPosition(pointer, containerRef.current);
setPointerPosition(position);
setIsPointerDown(true);
const brushPosition = getBrushPositionForTool( const brushPosition = getBrushPositionForTool(
position, mapBrushPosition,
selectedTool, selectedToolId,
toolSettings, selectedToolSettings,
gridSize, gridSize,
shapes shapes
); );
const commonShapeData = { const commonShapeData = {
color: toolSettings && toolSettings.color, color: selectedToolSettings && selectedToolSettings.color,
blend: toolSettings && toolSettings.useBlending, blend: selectedToolSettings && selectedToolSettings.useBlending,
id: shortid.generate(), id: shortid.generate(),
}; };
if (selectedTool === "brush") { if (selectedToolId === "brush") {
setDrawingShape({ setDrawingShape({
type: "path", type: "path",
pathType: toolSettings.type, pathType: selectedToolSettings.type,
data: { points: [brushPosition] }, data: { points: [brushPosition] },
strokeWidth: toolSettings.type === "stroke" ? 1 : 0, strokeWidth: selectedToolSettings.type === "stroke" ? 1 : 0,
...commonShapeData, ...commonShapeData,
}); });
} else if (selectedTool === "shape") { } else if (selectedToolId === "shape") {
setDrawingShape({ setDrawingShape({
type: "shape", type: "shape",
shapeType: toolSettings.type, shapeType: selectedToolSettings.type,
data: getDefaultShapeData(toolSettings.type, brushPosition), data: getDefaultShapeData(selectedToolSettings.type, brushPosition),
strokeWidth: 0, strokeWidth: 0,
...commonShapeData, ...commonShapeData,
}); });
} }
} }
function handleMove(event) { function continueShape() {
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( const brushPosition = getBrushPositionForTool(
position, mapBrushPosition,
selectedTool, selectedToolId,
toolSettings, selectedToolSettings,
gridSize, gridSize,
shapes shapes
); );
if (selectedTool === "brush") { if (selectedToolId === "brush") {
setDrawingShape((prevShape) => { setDrawingShape((prevShape) => {
const prevPoints = prevShape.data.points; const prevPoints = prevShape.data.points;
if ( if (
@ -132,14 +90,14 @@ function MapDrawing({
const simplified = simplifyPoints( const simplified = simplifyPoints(
[...prevPoints, brushPosition], [...prevPoints, brushPosition],
gridSize, gridSize,
scaleRef.current stageScale
); );
return { return {
...prevShape, ...prevShape,
data: { points: simplified }, data: { points: simplified },
}; };
}); });
} else if (selectedTool === "shape") { } else if (selectedToolId === "shape") {
setDrawingShape((prevShape) => ({ setDrawingShape((prevShape) => ({
...prevShape, ...prevShape,
data: getUpdatedShapeData( data: getUpdatedShapeData(
@ -151,110 +109,147 @@ function MapDrawing({
})); }));
} }
} }
}
function handleStop(event) { function endShape() {
if (!isEditing) { if (selectedToolId === "brush" && drawingShape) {
return;
}
if (event.touches && event.touches.length !== 0) {
return;
}
if (selectedTool === "brush" && drawingShape) {
if (drawingShape.data.points.length > 1) { if (drawingShape.data.points.length > 1) {
onShapeAdd(drawingShape); onShapeAdd(drawingShape);
} }
} else if (selectedTool === "shape" && drawingShape) { } else if (selectedToolId === "shape" && drawingShape) {
onShapeAdd(drawingShape); onShapeAdd(drawingShape);
} }
if (selectedTool === "erase" && hoveredShapeRef.current && isPointerDown) {
onShapeRemove(hoveredShapeRef.current.id);
}
setIsPointerDown(false);
setDrawingShape(null); setDrawingShape(null);
} }
// Add listeners for draw events on map to allow drawing past the bounds switch (brushState) {
// of the container case "first":
useEffect(() => { startShape();
const map = document.querySelector(".map"); return;
map.addEventListener("mousedown", handleStart); case "drawing":
map.addEventListener("mousemove", handleMove); continueShape();
map.addEventListener("mouseup", handleStop); return;
map.addEventListener("touchstart", handleStart); case "last":
map.addEventListener("touchmove", handleMove); endShape();
map.addEventListener("touchend", handleStop); return;
default:
return () => { 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); [
} selectedToolId,
if (drawingShape) { selectedToolSettings,
drawShape(drawingShape, context, gridSize, width, height);
}
if (hoveredShape) {
const shape = { ...hoveredShape, color: "#BB99FF", blend: true };
drawShape(shape, context, gridSize, width, height);
}
hoveredShapeRef.current = hoveredShape;
}
}, [
shapes,
width,
height,
pointerPosition,
isPointerDown,
selectedTool,
drawingShape,
gridSize, gridSize,
shouldHover, stageScale,
]); onShapeAdd,
shapes,
drawingShape,
]
);
useMapBrush(isEditing, handleShapeDraw);
function handleShapeClick(_, shape) {
if (selectedToolId === "erase") {
onShapeRemove(shape.id);
}
}
function handleShapeMouseOver(event, shape) {
if (shouldHover) {
const path = event.target;
const hoverColor = "#BB99FF";
path.fill(hoverColor);
if (shape.type === "path") {
path.stroke(hoverColor);
}
path.getLayer().draw();
}
}
function handleShapeMouseOut(event, shape) {
if (shouldHover) {
const path = event.target;
const color = colors[shape.color] || shape.color;
path.fill(color);
if (shape.type === "path") {
path.stroke(color);
}
path.getLayer().draw();
}
}
function renderShape(shape) {
const defaultProps = {
key: shape.id,
onMouseOver: (e) => handleShapeMouseOver(e, shape),
onMouseOut: (e) => handleShapeMouseOut(e, shape),
onClick: (e) => handleShapeClick(e, shape),
onTap: (e) => handleShapeClick(e, shape),
fill: colors[shape.color] || shape.color,
opacity: shape.blend ? 0.5 : 1,
};
if (shape.type === "path") {
return (
<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>
); );
} }

View File

@ -1,104 +1,74 @@
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]);
function handleStart(event) {
if (!isEditing) {
return;
}
if (event.touches && event.touches.length !== 1) {
setIsPointerDown(false);
setDrawingShape(null);
return;
}
const pointer = event.touches ? event.touches[0] : event;
const position = getRelativePointerPosition(pointer, containerRef.current);
setPointerPosition(position);
setIsPointerDown(true);
const brushPosition = getBrushPositionForTool( const brushPosition = getBrushPositionForTool(
position, mapBrushPosition,
"fog", selectedToolId,
toolSettings, selectedToolSettings,
gridSize, gridSize,
shapes shapes
); );
if (isEditing && toolSettings.type === "add") { if (selectedToolSettings.type === "add") {
setDrawingShape({ setDrawingShape({
type: "fog", type: "fog",
data: { points: [brushPosition] }, data: { points: [brushPosition] },
strokeWidth: 0.5, strokeWidth: 0.5,
color: "black", color: "black",
blend: true, // Blend while drawing blend: false,
id: shortid.generate(), id: shortid.generate(),
visible: true, visible: true,
}); });
} }
} }
function handleMove(event) { function continueShape() {
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( const brushPosition = getBrushPositionForTool(
position, mapBrushPosition,
"fog", selectedToolId,
toolSettings, selectedToolSettings,
gridSize, gridSize,
shapes shapes
); );
if (isEditing && toolSettings.type === "add" && drawingShape) { if (selectedToolSettings.type === "add") {
setDrawingShape((prevShape) => { setDrawingShape((prevShape) => {
const prevPoints = prevShape.data.points; const prevPoints = prevShape.data.points;
if ( if (
@ -117,16 +87,9 @@ function MapFog({
}); });
} }
} }
}
function handleStop(event) { function endShape() {
if (!isEditing) { if (selectedToolSettings.type === "add" && drawingShape) {
return;
}
if (event.touches && event.touches.length !== 0) {
return;
}
if (isEditing && toolSettings.type === "add" && drawingShape) {
if (drawingShape.data.points.length > 1) { if (drawingShape.data.points.length > 1) {
const shape = { const shape = {
...drawingShape, ...drawingShape,
@ -135,141 +98,116 @@ function MapFog({
drawingShape.data.points, drawingShape.data.points,
gridSize, gridSize,
// Downscale fog as smoothing doesn't currently work with edge snapping // Downscale fog as smoothing doesn't currently work with edge snapping
scaleRef.current / 2 stageScale / 2
), ),
}, },
blend: false,
}; };
onShapeAdd(shape); 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); setDrawingShape(null);
setIsPointerDown(false);
} }
// Add listeners for draw events on map to allow drawing past the bounds switch (brushState) {
// of the container case "first":
useEffect(() => { startShape();
const map = document.querySelector(".map"); return;
map.addEventListener("mousedown", handleStart); case "drawing":
map.addEventListener("mousemove", handleMove); continueShape();
map.addEventListener("mouseup", handleStop); return;
map.addEventListener("touchstart", handleStart); case "last":
map.addEventListener("touchmove", handleMove); endShape();
map.addEventListener("touchend", handleStop); return;
default:
return () => { 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 (shouldHover) {
if (
isShapeHovered(shape, context, pointerPosition, width, height)
) {
hoveredShape = shape;
} }
}
drawShape(
{
...shape,
blend: true,
color: shape.visible ? "black" : editPattern,
}, },
context, [
selectedToolId,
selectedToolSettings,
gridSize, gridSize,
width, stageScale,
height onShapeAdd,
shapes,
drawingShape,
]
);
useMapBrush(isEditing, handleShapeDraw);
function handleShapeClick(_, shape) {
if (!isEditing) {
return;
}
if (selectedToolSettings.type === "remove") {
onShapeRemove(shape.id);
} else if (selectedToolSettings.type === "toggle") {
onShapeEdit({ ...shape, visible: !shape.visible });
}
}
function handleShapeMouseOver(event, shape) {
if (shouldHover) {
const path = event.target;
if (shape.visible) {
const hoverColor = "#BB99FF";
path.fill(hoverColor);
} else {
path.opacity(1);
}
path.getLayer().draw();
}
}
function handleShapeMouseOut(event, shape) {
if (shouldHover) {
const path = event.target;
if (shape.visible) {
const color = colors[shape.color] || shape.color;
path.fill(color);
} else {
path.opacity(0.5);
}
path.getLayer().draw();
}
}
function renderShape(shape) {
return (
<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"}
/>
); );
} }
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;
}
}, [
shapes,
width,
height,
pointerPosition,
isEditing,
drawingShape,
gridSize,
shouldHover,
]);
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>
); );
} }

View File

@ -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 = {
let newScale = scale; x: 0,
let newTranslate = translate; y: -(mapHeight - stageHeightRef.current) / 2,
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,
}; };
} layer.x(newTranslate.x);
setTranslateAndScale(newTranslate, newScale); layer.y(newTranslate.y);
} layer.draw();
const mapInteract = interact(".map") stageTranslateRef.current = newTranslate;
.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 () => { setStageScale(1);
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);
});
const bind = useGesture({
onWheelStart: ({ event }) => {
isInteractingCanvas.current =
event.target === mapLayerRef.current.getCanvas()._canvas;
},
onWheel: ({ delta }) => {
if (preventMapInteraction || !isInteractingCanvas.current) {
return;
}
const newScale = Math.min(
Math.max(stageScale + delta[1] * wheelZoomSpeed, minZoom),
maxZoom
);
setStageScale(newScale);
},
onPinch: ({ da, origin, first }) => {
const [distance] = da;
const [originX, originY] = origin;
if (first) {
pinchPreviousDistanceRef.current = distance;
pinchPreviousOriginRef.current = { x: originX, y: originY };
} }
return () => { // Apply scale
if (mapContainer) { const distanceDelta = distance - pinchPreviousDistanceRef.current;
mapContainer.removeEventListener("wheel", handleZoom); 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 }}
ref={mapStageRef}
> >
<Box ref={mapMoveContainerRef}> <Layer ref={mapLayerRef}>
<Box <Image
sx={{ image={mapSourceImage}
width: "100%", width={mapWidth}
height: 0, height={mapHeight}
paddingBottom: `${(1 / aspectRatio) * 100}%`, id="mapImage"
}} ref={mapImageRef}
/> />
<MapInteractionProvider {/* Forward auth context to konva elements */}
value={{ <AuthContext.Provider value={auth}>
translateRef: mapTranslateRef, <MapInteractionProvider value={mapInteraction}>
scaleRef: mapScaleRef,
}}
>
{children} {children}
</MapInteractionProvider> </MapInteractionProvider>
</Box> </AuthContext.Provider>
</Box> </Layer>
</Stage>
</ReactResizeDetector>
<MapInteractionProvider value={mapInteraction}>
{controls} {controls}
{loading && <LoadingOverlay />} </MapInteractionProvider>
</Box> </Box>
); );
} }

View File

@ -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);

View File

@ -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>

View File

@ -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);
} }
}} }}
> >

View File

@ -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>

View File

@ -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={{
width: `${tokenSizePercent * (tokenState.size || 1)}%`,
}}
sx={{
position: "absolute",
pointerEvents: "all",
}}
>
<Box
sx={{
position: "absolute",
display: "flex", // Set display to flex to fix height being calculated wrong
width: "100%",
flexDirection: "column",
}}
>
<Image
className={className}
sx={{
userSelect: "none",
touchAction: "none",
width: "100%",
}}
src={imageSource}
// pass id into the dom element which is then used by the ProxyToken
data-id={tokenState.id}
ref={imageRef} ref={imageRef}
width={tokenWidth}
height={tokenHeight}
x={0}
y={0}
image={tokenSourceImage}
rotation={tokenState.rotation}
offsetX={tokenWidth / 2}
offsetY={tokenHeight / 2}
/> />
{tokenState.statuses && ( <Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
<TokenStatus statuses={tokenState.statuses} /> <TokenStatus
)} tokenState={tokenState}
{tokenState.label && <TokenLabel label={tokenState.label} />} width={tokenWidth}
</Box> height={tokenHeight}
</Box> />
</Box> <TokenLabel
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
</Group>
</animated.Group>
); );
} }

View File

@ -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}

View File

@ -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,

View File

@ -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 (

View File

@ -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,

View File

@ -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,

View File

@ -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}
/> />

View File

@ -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;

View 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;

View 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;

View File

@ -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}
}} />
>
<Image sx={{ width: "100%" }} src={tokenLabel} />
<svg
style={{
position: "absolute",
top: 0,
left: 0,
}}
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<foreignObject width="100%" height="100%">
<Text <Text
as="p" ref={textRef}
variant="heading" width={width}
sx={{ text={tokenState.label}
// This value is actually 66% fontSize={fontSize}
fontSize: "66px", lineHeight={1}
width: "100px", align="center"
height: "100px", verticalAlign="bottom"
textAlign: "center", fill="white"
verticalAlign: "middle", paddingX={paddingX}
lineHeight: 1.4, paddingY={paddingY}
}} wrap="none"
color="hsl(210, 50%, 96%)" ellipsis={true}
> hitFunc={() => {}}
{label} />
</Text> </Group>
</foreignObject>
</svg>
</Box>
); );
} }

View File

@ -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();
setMenuLeft(targetRect.left);
setMenuTop(targetRect.bottom);
setIsOpen(true);
} }
// Add listener for tap gesture function handleRotationChange(event) {
const tokenInteract = interact(`.${tokenClassName}`).on( const newRotation = parseInt(event.target.value);
"tap", onTokenStateChange({
handleTokenMenuOpen [tokenState.id]: { ...tokenState, rotation: newRotation },
); });
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,7 +150,9 @@ 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 &&
tokenState.statuses &&
tokenState.statuses.includes(color) && (
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
@ -197,6 +167,42 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
</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>
); );

View 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;

View File

@ -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%",
position: "absolute",
opacity: 0.8,
transform: `scale(${1 - index / 10})`,
}}
>
<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]} stroke={colors[status]}
strokeWidth={4} strokeWidth={width / 20 / tokenState.size}
scaleX={1 - index / 10 / tokenState.size}
scaleY={1 - index / 10 / tokenState.size}
opacity={0.8}
fillEnabled={false}
/> />
</svg>
</Box>
))} ))}
</Box> </Group>
); );
} }

View 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;

View 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;

View File

@ -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,8 +47,10 @@ 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
.filter((token) => !token.hideInSidebar)
.map((token) => (
<ListToken <ListToken
key={token.id} key={token.id}
token={token} token={token}
@ -52,15 +58,16 @@ function Tokens({ onCreateMapTokenState, tokens }) {
/> />
))} ))}
</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}

View 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;

View 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;

View File

@ -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;

View 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;

View File

@ -0,0 +1,8 @@
import React from "react";
const MapStageContext = React.createContext({
mapStageRef: { current: null },
});
export const MapStageProvider = MapStageContext.Provider;
export default MapStageContext;

View 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;

View File

@ -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
View 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;

View 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;

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
src/dice/environment.dds Executable file

Binary file not shown.

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
src/dice/galaxy/normal.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

BIN
src/dice/galaxy/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
src/dice/gemstone/normal.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

BIN
src/dice/glass/mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

BIN
src/dice/glass/normal.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

BIN
src/dice/glass/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

48
src/dice/index.js Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
src/dice/iron/metalRoughness.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
src/dice/iron/normal.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

BIN
src/dice/iron/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
src/dice/nebula/normal.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

BIN
src/dice/nebula/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
src/dice/shared/d10.glb Normal file

Binary file not shown.

BIN
src/dice/shared/d100.glb Normal file

Binary file not shown.

BIN
src/dice/shared/d12.glb Normal file

Binary file not shown.

BIN
src/dice/shared/d20.glb Normal file

Binary file not shown.

BIN
src/dice/shared/d4.glb Normal file

Binary file not shown.

BIN
src/dice/shared/d6.glb Normal file

Binary file not shown.

BIN
src/dice/shared/d8.glb Normal file

Binary file not shown.

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

BIN
src/dice/sunrise/normal.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

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