Add basic hex functionality and clean up hooks and Vector2 class

This commit is contained in:
Mitchell McCaffrey 2021-02-04 15:06:34 +11:00
parent 924b3e2481
commit de84e77e58
61 changed files with 835 additions and 573 deletions

View File

@ -1,71 +1,121 @@
import React from "react";
import { Line, Group } from "react-konva";
import { Line, Group, RegularPolygon } from "react-konva";
import { getStrokeWidth } from "../helpers/drawing";
import { getCellSize, getCellLocation, shouldClampCell } from "../helpers/grid";
function Grid({ gridX, gridY, gridInset, strokeWidth, width, height, stroke }) {
if (!gridX || !gridY) {
function Grid({ grid, strokeWidth, width, height, stroke }) {
if (!grid?.size.x || !grid?.size.y) {
return null;
}
const gridSizeNormalized = {
x: (gridInset.bottomRight.x - gridInset.topLeft.x) / gridX,
y: (gridInset.bottomRight.y - gridInset.topLeft.y) / gridY,
x: (grid.inset.bottomRight.x - grid.inset.topLeft.x) / grid.size.x,
y: (grid.inset.bottomRight.y - grid.inset.topLeft.y) / grid.size.y,
};
const insetWidth = (gridInset.bottomRight.x - gridInset.topLeft.x) * width;
const insetHeight = (gridInset.bottomRight.y - gridInset.topLeft.y) * height;
const insetWidth = (grid.inset.bottomRight.x - grid.inset.topLeft.x) * width;
const insetHeight =
(grid.inset.bottomRight.y - grid.inset.topLeft.y) * height;
const lineSpacingX = insetWidth / gridX;
const lineSpacingY = insetHeight / gridY;
const offsetX = grid.inset.topLeft.x * width * -1;
const offsetY = grid.inset.topLeft.y * height * -1;
const offsetX = gridInset.topLeft.x * width * -1;
const offsetY = gridInset.topLeft.y * height * -1;
const cellSize = getCellSize(grid, insetWidth, insetHeight);
const lines = [];
for (let x = 1; x < gridX; x++) {
lines.push(
<Line
key={`grid_x_${x}`}
points={[x * lineSpacingX, 0, x * lineSpacingX, insetHeight]}
stroke={stroke}
strokeWidth={getStrokeWidth(
strokeWidth,
gridSizeNormalized,
width,
height
)}
opacity={0.5}
offsetX={offsetX}
offsetY={offsetY}
/>
);
}
for (let y = 1; y < gridY; y++) {
lines.push(
<Line
key={`grid_y_${y}`}
points={[0, y * lineSpacingY, insetWidth, y * lineSpacingY]}
stroke={stroke}
strokeWidth={getStrokeWidth(
strokeWidth,
gridSizeNormalized,
width,
height
)}
opacity={0.5}
offsetX={offsetX}
offsetY={offsetY}
/>
);
const shapes = [];
if (grid.type === "square") {
for (let x = 1; x < grid.size.x; x++) {
shapes.push(
<Line
key={`grid_x_${x}`}
points={[x * cellSize.width, 0, x * cellSize.width, insetHeight]}
stroke={stroke}
strokeWidth={getStrokeWidth(
strokeWidth,
gridSizeNormalized,
width,
height
)}
opacity={0.5}
offsetX={offsetX}
offsetY={offsetY}
/>
);
}
for (let y = 1; y < grid.size.y; y++) {
shapes.push(
<Line
key={`grid_y_${y}`}
points={[0, y * cellSize.height, insetWidth, y * cellSize.height]}
stroke={stroke}
strokeWidth={getStrokeWidth(
strokeWidth,
gridSizeNormalized,
width,
height
)}
opacity={0.5}
offsetX={offsetX}
offsetY={offsetY}
/>
);
}
} else if (grid.type === "hexVertical" || grid.type === "hexHorizontal") {
for (let x = 0; x < grid.size.x; x++) {
for (let y = 0; y < grid.size.y; y++) {
const cellLocation = getCellLocation(grid, x, y, cellSize);
// If our hex shape will go past the bounds of the grid
const overshot = shouldClampCell(grid, x, y);
shapes.push(
<Group
key={`grid_${x}_${y}`}
// Clip the hex if it will overshoot
clipFunc={
overshot &&
((context) => {
context.rect(
-cellSize.radius,
-cellSize.radius,
grid.type === "hexVertical"
? cellSize.radius
: cellSize.radius * 2,
grid.type === "hexVertical"
? cellSize.radius * 2
: cellSize.radius
);
})
}
x={cellLocation.x}
y={cellLocation.y}
offsetX={offsetX}
offsetY={offsetY}
>
<RegularPolygon
sides={6}
radius={cellSize.radius}
stroke={stroke}
strokeWidth={getStrokeWidth(
strokeWidth,
gridSizeNormalized,
width,
height
)}
opacity={0.5}
rotation={grid.type === "hexVertical" ? 0 : 90}
/>
</Group>
);
}
}
}
return <Group>{lines}</Group>;
return <Group>{shapes}</Group>;
}
Grid.defaultProps = {
strokeWidth: 0.1,
gridInset: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 } },
stroke: "white",
};

View File

@ -19,7 +19,7 @@ import SelectDiceButton from "./SelectDiceButton";
import Divider from "../Divider";
import { dice } from "../../dice";
import useSetting from "../../helpers/useSetting";
import useSetting from "../../hooks/useSetting";
function DiceButtons({
diceRolls,

View File

@ -16,7 +16,7 @@ import "@babylonjs/loaders/glTF";
import ReactResizeDetector from "react-resize-detector";
import usePreventTouch from "../../helpers/usePreventTouch";
import usePreventTouch from "../../hooks/usePreventTouch";
const diceThrowSpeed = 2;

View File

@ -24,7 +24,7 @@ import DiceTray from "../../dice/diceTray/DiceTray";
import DiceLoadingContext from "../../contexts/DiceLoadingContext";
import { getDiceRoll } from "../../helpers/dice";
import useSetting from "../../helpers/useSetting";
import useSetting from "../../hooks/useSetting";
function DiceTrayOverlay({
isOpen,

View File

@ -21,7 +21,7 @@ import FullScreenIcon from "../../icons/FullScreenIcon";
import FullScreenExitIcon from "../../icons/FullScreenExitIcon";
import NoteToolIcon from "../../icons/NoteToolIcon";
import useSetting from "../../helpers/useSetting";
import useSetting from "../../hooks/useSetting";
function MapContols({
onMapChange,

View File

@ -5,7 +5,7 @@ import { Group, Line, Rect, Circle } from "react-konva";
import MapInteractionContext from "../../contexts/MapInteractionContext";
import MapStageContext from "../../contexts/MapStageContext";
import { compare as comparePoints } from "../../helpers/vector2";
import Vector2 from "../../helpers/Vector2";
import {
getBrushPosition,
getDefaultShapeData,
@ -92,7 +92,7 @@ function MapDrawing({
setDrawingShape((prevShape) => {
const prevPoints = prevShape.data.points;
if (
comparePoints(
Vector2.compare(
prevPoints[prevPoints.length - 1],
brushPosition,
0.001

View File

@ -3,12 +3,13 @@ import { Box, IconButton } from "theme-ui";
import { Stage, Layer, Image } from "react-konva";
import ReactResizeDetector from "react-resize-detector";
import useMapImage from "../../helpers/useMapImage";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useStageInteraction from "../../helpers/useStageInteraction";
import useImageCenter from "../../helpers/useImageCenter";
import { getMapDefaultInset, getMapMaxZoom } from "../../helpers/map";
import useResponsiveLayout from "../../helpers/useResponsiveLayout";
import useMapImage from "../../hooks/useMapImage";
import usePreventOverscroll from "../../hooks/usePreventOverscroll";
import useStageInteraction from "../../hooks/useStageInteraction";
import useImageCenter from "../../hooks/useImageCenter";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { getMapDefaultInset, getGridMaxZoom } from "../../helpers/grid";
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
import KeyboardContext from "../../contexts/KeyboardContext";
@ -65,7 +66,7 @@ function MapEditor({ map, onSettingsChange }) {
setStageScale,
stageTranslateRef,
mapLayerRef.current,
getMapMaxZoom(map),
getGridMaxZoom(map.grid),
"pan",
preventMapInteraction
);

View File

@ -14,7 +14,7 @@ import diagonalPattern from "../../images/DiagonalPattern.png";
import MapInteractionContext from "../../contexts/MapInteractionContext";
import MapStageContext from "../../contexts/MapStageContext";
import { compare as comparePoints } from "../../helpers/vector2";
import Vector2 from "../../helpers/Vector2";
import {
getFogBrushPosition,
simplifyPoints,
@ -23,8 +23,9 @@ import {
} from "../../helpers/drawing";
import colors from "../../helpers/colors";
import { HoleyLine, Tick } from "../../helpers/konva";
import useKeyboard from "../../helpers/useKeyboard";
import useDebounce from "../../helpers/useDebounce";
import useKeyboard from "../../hooks/useKeyboard";
import useDebounce from "../../hooks/useDebounce";
function MapFog({
map,
@ -120,7 +121,7 @@ function MapFog({
setDrawingShape((prevShape) => {
const prevPoints = prevShape.data.points;
if (
comparePoints(
Vector2.compare(
prevPoints[prevPoints.length - 1],
brushPosition,
0.001

View File

@ -3,7 +3,7 @@ import useImage from "use-image";
import MapInteractionContext from "../../contexts/MapInteractionContext";
import useDataSource from "../../helpers/useDataSource";
import useDataSource from "../../hooks/useDataSource";
import { mapSources as defaultMapSources } from "../../maps";
import { getImageLightness } from "../../helpers/image";
@ -33,16 +33,9 @@ function MapGrid({ map, strokeWidth }) {
}
}, [mapImage, mapLoadingStatus]);
const gridX = map && map.grid.size.x;
const gridY = map && map.grid.size.y;
const gridInset = map && map.grid.inset;
return (
<Grid
gridX={gridX}
gridY={gridY}
gridInset={gridInset}
grid={map?.grid}
strokeWidth={strokeWidth}
width={mapWidth}
height={mapHeight}

View File

@ -3,8 +3,9 @@ import { Group, Circle, Rect } from "react-konva";
import MapInteractionContext from "../../contexts/MapInteractionContext";
import * as Vector2 from "../../helpers/vector2";
import useKeyboard from "../../helpers/useKeyboard";
import Vector2 from "../../helpers/Vector2";
import useKeyboard from "../../hooks/useKeyboard";
function MapGridEditor({ map, onGridChange }) {
const {

View File

@ -4,12 +4,13 @@ import ReactResizeDetector from "react-resize-detector";
import { Stage, Layer, Image } from "react-konva";
import { EventEmitter } from "events";
import useMapImage from "../../helpers/useMapImage";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useKeyboard from "../../helpers/useKeyboard";
import useStageInteraction from "../../helpers/useStageInteraction";
import useImageCenter from "../../helpers/useImageCenter";
import { getMapMaxZoom } from "../../helpers/map";
import useMapImage from "../../hooks/useMapImage";
import usePreventOverscroll from "../../hooks/usePreventOverscroll";
import useKeyboard from "../../hooks/useKeyboard";
import useStageInteraction from "../../hooks/useStageInteraction";
import useImageCenter from "../../hooks/useImageCenter";
import { getGridMaxZoom } from "../../helpers/grid";
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
import MapStageContext, {
@ -87,7 +88,7 @@ function MapInteraction({
setStageScale,
stageTranslateRef,
mapLayerRef.current,
getMapMaxZoom(map),
getGridMaxZoom(map?.grid),
selectedToolId,
preventMapInteraction,
{

View File

@ -10,7 +10,7 @@ import {
getUpdatedShapeData,
getStrokeWidth,
} from "../../helpers/drawing";
import * as Vector2 from "../../helpers/vector2";
import Vector2 from "../../helpers/Vector2";
function MapMeasure({ map, selectedToolSettings, active, gridSize }) {
const { stageScale, mapWidth, mapHeight, interactionEmitter } = useContext(

View File

@ -9,7 +9,7 @@ import {
getRelativePointerPositionNormalized,
Trail,
} from "../../helpers/konva";
import { multiply } from "../../helpers/vector2";
import Vector2 from "../../helpers/Vector2";
import colors from "../../helpers/colors";
@ -69,7 +69,7 @@ function MapPointer({
<Group>
{visible && (
<Trail
position={multiply(position, { x: mapWidth, y: mapHeight })}
position={Vector2.multiply(position, { x: mapWidth, y: mapHeight })}
color={colors[color]}
size={size}
duration={200}

View File

@ -16,6 +16,12 @@ const qualitySettings = [
{ value: "original", label: "Original" },
];
const gridTypeSettings = [
{ value: "square", label: "Square" },
{ value: "hexVertical", label: "Hex Vertical" },
{ value: "hexHorizontal", label: "Hex Horizontal" },
];
function MapSettings({
map,
mapState,
@ -141,13 +147,15 @@ function MapSettings({
<Box mb={1} sx={{ width: "50%" }}>
<Label mb={1}>Grid Type</Label>
<Select
defaultValue={{ value: "square", label: "Square" }}
isDisabled={mapEmpty || map.type === "default"}
options={[
{ value: "square", label: "Square" },
{ value: "hex", label: "Hex (Coming Soon)" },
]}
isOptionDisabled={(option) => option.value === "hex"}
options={gridTypeSettings}
value={
!mapEmpty &&
gridTypeSettings.find((s) => s.value === map.grid.type)
}
onChange={(option) =>
onSettingsChange("grid", { ...map.grid, type: option.value })
}
isSearchable={false}
/>
</Box>

View File

@ -2,7 +2,7 @@ import React from "react";
import Tile from "../Tile";
import useDataSource from "../../helpers/useDataSource";
import useDataSource from "../../hooks/useDataSource";
import { mapSources as defaultMapSources, unknownSource } from "../../maps";
function MapTile({

View File

@ -13,7 +13,7 @@ import FilterBar from "../FilterBar";
import DatabaseContext from "../../contexts/DatabaseContext";
import useResponsiveLayout from "../../helpers/useResponsiveLayout";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function MapTiles({
maps,

View File

@ -4,10 +4,11 @@ import { useSpring, animated } from "react-spring/konva";
import useImage from "use-image";
import Konva from "konva";
import useDataSource from "../../helpers/useDataSource";
import useDebounce from "../../helpers/useDebounce";
import usePrevious from "../../helpers/usePrevious";
import { snapNodeToMap } from "../../helpers/map";
import useDataSource from "../../hooks/useDataSource";
import useDebounce from "../../hooks/useDebounce";
import usePrevious from "../../hooks/usePrevious";
import { snapNodeToGrid } from "../../helpers/grid";
import AuthContext from "../../contexts/AuthContext";
import MapInteractionContext from "../../contexts/MapInteractionContext";
@ -86,7 +87,13 @@ function MapToken({
const tokenGroup = event.target;
// Snap to corners of grid
if (map.snapToGrid) {
snapNodeToMap(map, mapWidth, mapHeight, tokenGroup, snappingThreshold);
snapNodeToGrid(
map.grid,
mapWidth,
mapHeight,
tokenGroup,
snappingThreshold
);
}
}

View File

@ -22,7 +22,7 @@ import RedoButton from "./RedoButton";
import Divider from "../../Divider";
import useKeyboard from "../../../helpers/useKeyboard";
import useKeyboard from "../../../hooks/useKeyboard";
function DrawingToolSettings({
settings,

View File

@ -20,7 +20,7 @@ import ToolSection from "./ToolSection";
import Divider from "../../Divider";
import useKeyboard from "../../../helpers/useKeyboard";
import useKeyboard from "../../../hooks/useKeyboard";
function BrushToolSettings({
settings,

View File

@ -9,7 +9,7 @@ import MeasureAlternatingIcon from "../../../icons/MeasureAlternatingIcon";
import Divider from "../../Divider";
import useKeyboard from "../../../helpers/useKeyboard";
import useKeyboard from "../../../hooks/useKeyboard";
function MeasureToolSettings({ settings, onSettingChange }) {
// Keyboard shortcuts

View File

@ -5,9 +5,10 @@ import { useSpring, animated } from "react-spring/konva";
import AuthContext from "../../contexts/AuthContext";
import MapInteractionContext from "../../contexts/MapInteractionContext";
import { snapNodeToMap } from "../../helpers/map";
import { snapNodeToGrid } from "../../helpers/grid";
import colors from "../../helpers/colors";
import usePrevious from "../../helpers/usePrevious";
import usePrevious from "../../hooks/usePrevious";
const snappingThreshold = 1 / 5;
@ -38,7 +39,13 @@ function Note({
const noteGroup = event.target;
// Snap to corners of grid
if (map.snapToGrid) {
snapNodeToMap(map, mapWidth, mapHeight, noteGroup, snappingThreshold);
snapNodeToGrid(
map.grid,
mapWidth,
mapHeight,
noteGroup,
snappingThreshold
);
}
}

View File

@ -7,7 +7,7 @@ import MapMenu from "../map/MapMenu";
import colors, { colorOptions } from "../../helpers/colors";
import usePrevious from "../../helpers/usePrevious";
import usePrevious from "../../hooks/usePrevious";
import LockIcon from "../../icons/TokenLockIcon";
import UnlockIcon from "../../icons/TokenUnlockIcon";

View File

@ -5,7 +5,7 @@ import ExpandMoreDiceIcon from "../../icons/ExpandMoreDiceIcon";
import { DiceLoadingProvider } from "../../contexts/DiceLoadingContext";
import useSetting from "../../helpers/useSetting";
import useSetting from "../../hooks/useSetting";
import LoadingOverlay from "../LoadingOverlay";

View File

@ -11,7 +11,7 @@ import StartTimerButton from "./StartTimerButton";
import Timer from "./Timer";
import DiceTrayButton from "./DiceTrayButton";
import useSetting from "../../helpers/useSetting";
import useSetting from "../../hooks/useSetting";
import PartyContext from "../../contexts/PartyContext";
import {

View File

@ -2,7 +2,7 @@ import React, { useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { Box, Progress } from "theme-ui";
import usePortal from "../../helpers/usePortal";
import usePortal from "../../hooks/usePortal";
function Timer({ timer, index }) {
const progressBarRef = useRef();

View File

@ -1,8 +1,8 @@
import React, { useRef } from "react";
import { Box, Image } from "theme-ui";
import usePreventTouch from "../../helpers/usePreventTouch";
import useDataSource from "../../helpers/useDataSource";
import usePreventTouch from "../../hooks/usePreventTouch";
import useDataSource from "../../hooks/useDataSource";
import { tokenSources, unknownSource } from "../../tokens";

View File

@ -3,7 +3,7 @@ import ReactDOM from "react-dom";
import { Image, Box } from "theme-ui";
import interact from "interactjs";
import usePortal from "../../helpers/usePortal";
import usePortal from "../../hooks/usePortal";
import MapStageContext from "../../contexts/MapStageContext";

View File

@ -1,7 +1,7 @@
import React, { useRef, useEffect, useState } from "react";
import { Rect, Text, Group } from "react-konva";
import useSetting from "../../helpers/useSetting";
import useSetting from "../../hooks/useSetting";
const maxTokenSize = 3;

View File

@ -7,7 +7,7 @@ import MapMenu from "../map/MapMenu";
import colors, { colorOptions } from "../../helpers/colors";
import usePrevious from "../../helpers/usePrevious";
import usePrevious from "../../hooks/usePrevious";
import LockIcon from "../../icons/TokenLockIcon";
import UnlockIcon from "../../icons/TokenUnlockIcon";

View File

@ -4,11 +4,11 @@ import { Stage, Layer, Image, Rect, Group } from "react-konva";
import ReactResizeDetector from "react-resize-detector";
import useImage from "use-image";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useStageInteraction from "../../helpers/useStageInteraction";
import useDataSource from "../../helpers/useDataSource";
import useImageCenter from "../../helpers/useImageCenter";
import useResponsiveLayout from "../../helpers/useResponsiveLayout";
import usePreventOverscroll from "../../hooks/usePreventOverscroll";
import useStageInteraction from "../../hooks/useStageInteraction";
import useDataSource from "../../hooks/useDataSource";
import useImageCenter from "../../hooks/useImageCenter";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import GridOnIcon from "../../icons/GridOnIcon";
import GridOffIcon from "../../icons/GridOffIcon";
@ -111,8 +111,14 @@ function TokenPreview({ token }) {
{showGridPreview && (
<Group offsetY={gridHeight - tokenHeight}>
<Grid
gridX={gridX}
gridY={gridY}
grid={{
size: { x: gridX, y: gridY },
inset: {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 },
},
type: "square",
}}
width={gridWidth}
height={gridHeight}
/>

View File

@ -2,7 +2,7 @@ import React from "react";
import Tile from "../Tile";
import useDataSource from "../../helpers/useDataSource";
import useDataSource from "../../hooks/useDataSource";
import {
tokenSources as defaultTokenSources,
unknownSource,

View File

@ -14,7 +14,7 @@ import FilterBar from "../FilterBar";
import DatabaseContext from "../../contexts/DatabaseContext";
import useResponsiveLayout from "../../helpers/useResponsiveLayout";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function TokenTiles({
tokens,

View File

@ -9,7 +9,8 @@ import ProxyToken from "./ProxyToken";
import SelectTokensButton from "./SelectTokensButton";
import { fromEntries } from "../../helpers/shared";
import useSetting from "../../helpers/useSetting";
import useSetting from "../../hooks/useSetting";
import AuthContext from "../../contexts/AuthContext";
import TokenDataContext from "../../contexts/TokenDataContext";

View File

@ -1,11 +1,12 @@
import React, { useEffect, useContext } from "react";
import useNetworkedState from "../helpers/useNetworkedState";
import DatabaseContext from "./DatabaseContext";
import AuthContext from "./AuthContext";
import { getRandomMonster } from "../helpers/monsters";
import useNetworkedState from "../hooks/useNetworkedState";
export const PlayerStateContext = React.createContext();
export const PlayerUpdaterContext = React.createContext(() => {});

View File

@ -1,7 +1,7 @@
import Dexie from "dexie";
import blobToBuffer from "./helpers/blobToBuffer";
import { getMapDefaultInset } from "./helpers/map";
import { getMapDefaultInset } from "./helpers/grid";
import { convertOldActionsToShapes } from "./actions";
function loadVersions(db) {

View File

@ -1,7 +1,7 @@
import simplify from "simplify-js";
import polygonClipping from "polygon-clipping";
import * as Vector2 from "./vector2";
import Vector2 from "./Vector2";
import { toDegrees } from "./shared";
import { getRelativePointerPositionNormalized } from "./konva";
import { logError } from "./logging";

View File

@ -1,8 +1,100 @@
import GridSizeModel from "../ml/gridSize/GridSizeModel";
import * as Vector2 from "./vector2";
import Vector2 from "./Vector2";
import { logError } from "./logging";
const SQRT3 = 1.73205;
/**
* @typedef GridInset
* @property {Vector2} topLeft
* @property {Vector2} bottomRight
*/
/**
* @typedef Grid
* @property {GridInset} inset
* @property {Vector2} size
* @property {("square"|"hexVertical"|"hexHorizontal")} type
*/
/**
* @typedef CellSize
* @property {number} width
* @property {number} height
* @property {number?} radius - Used for hex cell sizes
*/
/**
* Gets the cell size for a grid taking into account inset and grid type
* @param {Grid} grid
* @param {number} insetWidth
* @param {number} insetHeight
* @returns {CellSize}
*/
export function getCellSize(grid, insetWidth, insetHeight) {
if (grid.type === "square") {
return {
width: insetWidth / grid.size.x,
height: insetHeight / grid.size.y,
};
} else if (grid.type === "hexVertical") {
const radius = insetWidth / grid.size.x / SQRT3;
return { width: radius * SQRT3, height: radius + radius / 2, radius };
} else if (grid.type === "hexHorizontal") {
const radius = insetHeight / grid.size.y / SQRT3;
return { width: radius + radius / 2, height: radius * SQRT3, radius };
}
}
/**
* Find the location of cell in the grid
* @param {Grid} grid
* @param {number} x X-axis location of the cell
* @param {number} y Y-axis location of the cell
* @param {CellSize} cellSize
* @returns {Vector2}
*/
export function getCellLocation(grid, x, y, cellSize) {
if (grid.type === "square") {
return { x: x * cellSize.width, y: y * cellSize.height };
} else if (grid.type === "hexVertical") {
return {
x: x * cellSize.width + (cellSize.width * (1 + (y % 2))) / 2,
y: y * cellSize.height + cellSize.radius,
};
} else if (grid.type === "hexHorizontal") {
return {
x: x * cellSize.width + cellSize.radius,
y: y * cellSize.height + (cellSize.height * (1 + (x % 2))) / 2,
};
}
}
/**
* Whether the cell located at `x, y` is out of bounds of the grid
* @param {Grid} grid
* @param {number} x X-axis location of the cell
* @param {number} y Y-axis location of the cell
* @returns {boolean}
*/
export function shouldClampCell(grid, x, y) {
if (grid.type === "hexVertical") {
return x === grid.size.x - 1 && y % 2 !== 0;
} else if (grid.type === "hexHorizontal") {
return y === grid.size.y - 1 && x % 2 !== 0;
}
return false;
}
/**
* Get the default inset for a map
* @param {number} width Hidth of the map
* @param {number} height Height of the map
* @param {number} gridX Number of grid cells in the horizontal direction
* @param {number} gridY Number of grid cells in the vertical direction
* @returns {GridInset}
*/
export function getMapDefaultInset(width, height, gridX, gridY) {
// Max the width
const gridScale = width / gridX;
@ -11,14 +103,23 @@ export function getMapDefaultInset(width, height, gridX, gridY) {
return { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: yNorm } };
}
// Get all factors of a number
/**
* Get all factors of a number
* @param {number} n
* @returns {number[]}
*/
function factors(n) {
const numbers = Array.from(Array(n + 1), (_, i) => i);
return numbers.filter((i) => n % i === 0);
}
// Greatest common divisor
// Euclidean algorithm https://en.wikipedia.org/wiki/Euclidean_algorithm
/**
* Greatest common divisor
* Uses the Euclidean algorithm https://en.wikipedia.org/wiki/Euclidean_algorithm
* @param {number} a
* @param {number} b
* @returns {number}
*/
function gcd(a, b) {
while (b !== 0) {
const t = b;
@ -28,7 +129,12 @@ function gcd(a, b) {
return a;
}
// Find all dividers that fit into two numbers
/**
* Find all dividers that fit into two numbers
* @param {number} a
* @param {number} b
* @returns {number[]}
*/
function dividers(a, b) {
const d = gcd(a, b);
return factors(d);
@ -42,12 +148,24 @@ const gridSizeStd = { x: 14.438842, y: 15.582376 };
const minGridSize = 10;
const maxGridSize = 200;
/**
* Get whether the grid size is likely valid by checking whether it exceeds a bounds
* @param {number} x
* @param {number} y
* @returns {boolean}
*/
export function gridSizeVaild(x, y) {
return (
x > minGridSize && y > minGridSize && x < maxGridSize && y < maxGridSize
);
}
/**
* Finds a grid size for an image by finding the closest size to the average grid size
* @param {Image} image
* @param {number[]} candidates
* @returns {Vector2}
*/
function gridSizeHeuristic(image, candidates) {
const width = image.width;
const height = image.height;
@ -74,6 +192,12 @@ function gridSizeHeuristic(image, candidates) {
}
}
/**
* Finds the grid size of an image by running the image through a machine learning model
* @param {Image} image
* @param {number[]} candidates
* @returns {Vector2}
*/
async function gridSizeML(image, candidates) {
const width = image.width;
const height = image.height;
@ -134,6 +258,11 @@ async function gridSizeML(image, candidates) {
}
}
/**
* Finds the grid size of an image by either using a ML model or falling back to a heuristic
* @param {Image} image
* @returns {Vector2}
*/
export async function getGridSize(image) {
const candidates = dividers(image.width, image.height);
let prediction;
@ -155,32 +284,45 @@ export async function getGridSize(image) {
return prediction;
}
export function getMapMaxZoom(map) {
if (!map) {
/**
* Get the max zoom for a grid
* @param {Grid} grid
* @returns {number}
*/
export function getGridMaxZoom(grid) {
if (!grid) {
return 10;
}
// Return max grid size / 2
return Math.max(Math.max(map.grid.size.x, map.grid.size.y) / 2, 5);
return Math.max(Math.max(grid.size.x, grid.size.y) / 2, 5);
}
export function snapNodeToMap(
map,
/**
* Snap a Konva Node to a the closest grid cell
* @param {Grid} grid
* @param {number} mapWidth
* @param {number} mapHeight
* @param {Konva.node} node
* @param {number} snappingThreshold 1 = Always snap, 0 = never snap
*/
export function snapNodeToGrid(
grid,
mapWidth,
mapHeight,
node,
snappingThreshold
) {
const offset = Vector2.multiply(map.grid.inset.topLeft, {
const offset = Vector2.multiply(grid.inset.topLeft, {
x: mapWidth,
y: mapHeight,
});
const gridSize = {
x:
(mapWidth * (map.grid.inset.bottomRight.x - map.grid.inset.topLeft.x)) /
map.grid.size.x,
(mapWidth * (grid.inset.bottomRight.x - grid.inset.topLeft.x)) /
grid.size.x,
y:
(mapHeight * (map.grid.inset.bottomRight.y - map.grid.inset.topLeft.y)) /
map.grid.size.y,
(mapHeight * (grid.inset.bottomRight.y - grid.inset.topLeft.y)) /
grid.size.y,
};
const position = node.position();

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from "react";
import { Line, Group, Path, Circle } from "react-konva";
import Color from "color";
import * as Vector2 from "./vector2";
import Vector2 from "./Vector2";
// Holes should be wound in the opposite direction as the containing points array
export function HoleyLine({ holes, ...props }) {

View File

@ -5,440 +5,465 @@ import {
} from "./shared";
/**
* Vector class with x and y
* @typedef {Object} Vector2
* @property {number} x - X component of the vector
* @property {number} y - Y component of the vector
* Vector class with x, y and static helper methods
*/
class Vector2 {
/**
* @type {number} x - X component of the vector
*/
x;
/**
* @type {number} y - Y component of the vector
*/
y;
/**
* @param {Vector2} p
* @returns {number} Length squared of `p`
*/
export function lengthSquared(p) {
return p.x * p.x + p.y * p.y;
}
/**
* @param {Vector2} p
* @returns {number} Length of `p`
*/
export function length(p) {
return Math.sqrt(lengthSquared(p));
}
/**
* @param {Vector2} p
* @returns {Vector2} `p` normalized, if length of `p` is 0 `{x: 0, y: 0}` is returned
*/
export function normalize(p) {
const l = length(p);
if (l === 0) {
return { x: 0, y: 0 };
/**
* @param {Vector2} p
* @returns {number} Length squared of `p`
*/
static lengthSquared(p) {
return p.x * p.x + p.y * p.y;
}
return divide(p, l);
}
/**
* @param {Vector2} a
* @param {Vector2} b
* @returns {number} Dot product between `a` and `b`
*/
export function dot(a, b) {
return a.x * b.x + a.y * b.y;
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a - b
*/
export function subtract(a, b) {
if (typeof b === "number") {
return { x: a.x - b, y: a.y - b };
} else {
return { x: a.x - b.x, y: a.y - b.y };
/**
* @param {Vector2} p
* @returns {number} Length of `p`
*/
static length(p) {
return Math.sqrt(this.lengthSquared(p));
}
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a + b
*/
export function add(a, b) {
if (typeof b === "number") {
return { x: a.x + b, y: a.y + b };
} else {
return { x: a.x + b.x, y: a.y + b.y };
/**
* @param {Vector2} p
* @returns {Vector2} `p` normalized, if length of `p` is 0 `{x: 0, y: 0}` is returned
*/
static normalize(p) {
const l = this.length(p);
if (l === 0) {
return { x: 0, y: 0 };
}
return this.divide(p, l);
}
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a * b
*/
export function multiply(a, b) {
if (typeof b === "number") {
return { x: a.x * b, y: a.y * b };
} else {
return { x: a.x * b.x, y: a.y * b.y };
/**
* @param {Vector2} a
* @param {Vector2} b
* @returns {number} Dot product between `a` and `b`
*/
static dot(a, b) {
return a.x * b.x + a.y * b.y;
}
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a / b
*/
export function divide(a, b) {
if (typeof b === "number") {
return { x: a.x / b, y: a.y / b };
} else {
return { x: a.x / b.x, y: a.y / b.y };
}
}
/**
* Rotates a point around a given origin by an angle in degrees
* @param {Vector2} point Point to rotate
* @param {Vector2} origin Origin of the rotation
* @param {number} angle Angle of rotation in degrees
* @returns {Vector2} Rotated point
*/
export function rotate(point, origin, angle) {
const cos = Math.cos(toRadians(angle));
const sin = Math.sin(toRadians(angle));
const dif = subtract(point, origin);
return {
x: origin.x + cos * dif.x - sin * dif.y,
y: origin.y + sin * dif.x + cos * dif.y,
};
}
/**
* Rotates a direction by a given angle in degrees
* @param {Vector2} direction Direction to rotate
* @param {number} angle Angle of rotation in degrees
* @returns {Vector2} Rotated direction
*/
export function rotateDirection(direction, angle) {
return rotate(direction, { x: 0, y: 0 }, angle);
}
/**
* Returns the min of `value` and `minimum`, if `minimum` is undefined component wise min is returned instead
* @param {Vector2} a
* @param {(Vector2 | number)} [minimum] Value to compare
* @returns {(Vector2 | number)}
*/
export function min(a, minimum) {
if (minimum === undefined) {
return a.x < a.y ? a.x : a.y;
} else if (typeof minimum === "number") {
return { x: Math.min(a.x, minimum), y: Math.min(a.y, minimum) };
} else {
return { x: Math.min(a.x, minimum.x), y: Math.min(a.y, minimum.y) };
}
}
/**
* Returns the max of `a` and `maximum`, if `maximum` is undefined component wise max is returned instead
* @param {Vector2} a
* @param {(Vector2 | number)} [maximum] Value to compare
* @returns {(Vector2 | number)}
*/
export function max(a, maximum) {
if (maximum === undefined) {
return a.x > a.y ? a.x : a.y;
} else if (typeof maximum === "number") {
return { x: Math.max(a.x, maximum), y: Math.max(a.y, maximum) };
} else {
return { x: Math.max(a.x, maximum.x), y: Math.max(a.y, maximum.y) };
}
}
/**
* Rounds `p` to the nearest value of `to`
* @param {Vector2} p
* @param {Vector2} to
* @returns {Vector2}
*/
export function roundTo(p, to) {
return {
x: roundToNumber(p.x, to.x),
y: roundToNumber(p.y, to.y),
};
}
/**
* @param {Vector2} a
* @returns {Vector2} The component wise sign of `a`
*/
export function sign(a) {
return { x: Math.sign(a.x), y: Math.sign(a.y) };
}
/**
* @param {Vector2} a
* @returns {Vector2} The component wise absolute of `a`
*/
export function abs(a) {
return { x: Math.abs(a.x), y: Math.abs(a.y) };
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} `a` to the power of `b`
*/
export function pow(a, b) {
if (typeof b === "number") {
return { x: Math.pow(a.x, b), y: Math.pow(a.y, b) };
} else {
return { x: Math.pow(a.x, b.x), y: Math.pow(a.y, b.y) };
}
}
/**
* @param {Vector2} a
* @returns {number} The dot product between `a` and `a`
*/
export function dot2(a) {
return dot(a, a);
}
/**
* Clamps `a` between `min` and `max`
* @param {Vector2} a
* @param {number} min
* @param {number} max
* @returns {Vector2}
*/
export function clamp(a, min, max) {
return {
x: Math.min(Math.max(a.x, min), max),
y: Math.min(Math.max(a.y, min), max),
};
}
/**
* Calculates the distance between a point and a line segment
* See more at {@link https://www.iquilezles.org/www/articles/distfunctions2d/distfunctions2d.htm}
* @param {Vector2} p Point
* @param {Vector2} a Start of the line
* @param {Vector2} b End of the line
* @returns {Object} The distance to and the closest point on the line segment
*/
export function distanceToLine(p, a, b) {
const pa = subtract(p, a);
const ba = subtract(b, a);
const h = Math.min(Math.max(dot(pa, ba) / dot(ba, ba), 0), 1);
const distance = length(subtract(pa, multiply(ba, h)));
const point = add(a, multiply(ba, h));
return { distance, point };
}
/**
* Calculates the distance between a point and a quadratic bezier curve
* See more at {@link https://www.shadertoy.com/view/MlKcDD}
* @todo Fix the robustness of this to allow smoothing on fog layers
* @param {Vector2} pos Position
* @param {Vector2} A Start of the curve
* @param {Vector2} B Control point of the curve
* @param {Vector2} C End of the curve
* @returns {Object} The distance to and the closest point on the curve
*/
export function distanceToQuadraticBezier(pos, A, B, C) {
let distance = 0;
let point = { x: pos.x, y: pos.y };
const a = subtract(B, A);
const b = add(subtract(A, multiply(B, 2)), C);
const c = multiply(a, 2);
const d = subtract(A, pos);
// Solve cubic roots to find closest points
const kk = 1 / dot(b, b);
const kx = kk * dot(a, b);
const ky = (kk * (2 * dot(a, a) + dot(d, b))) / 3;
const kz = kk * dot(d, a);
const p = ky - kx * kx;
const p3 = p * p * p;
const q = kx * (2 * kx * kx - 3 * ky) + kz;
let h = q * q + 4 * p3;
if (h >= 0) {
// 1 root
h = Math.sqrt(h);
const x = divide(subtract({ x: h, y: -h }, q), 2);
const uv = multiply(sign(x), pow(abs(x), 1 / 3));
const t = Math.min(Math.max(uv.x + uv.y - kx, 0), 1);
point = add(A, multiply(add(c, multiply(b, t)), t));
distance = dot2(add(d, multiply(add(c, multiply(b, t)), t)));
} else {
// 3 roots but ignore the 3rd one as it will never be closest
// https://www.shadertoy.com/view/MdXBzB
const z = Math.sqrt(-p);
const v = Math.acos(q / (p * z * 2)) / 3;
const m = Math.cos(v);
const n = Math.sin(v) * 1.732050808;
const t = clamp(subtract(multiply({ x: m + m, y: -n - m }, z), kx), 0, 1);
const d1 = dot2(add(d, multiply(add(c, multiply(b, t.x)), t.x)));
const d2 = dot2(add(d, multiply(add(c, multiply(b, t.y)), t.y)));
distance = Math.min(d1, d2);
if (d1 < d2) {
point = add(d, multiply(add(c, multiply(b, t.x)), t.x));
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a - b
*/
static subtract(a, b) {
if (typeof b === "number") {
return { x: a.x - b, y: a.y - b };
} else {
point = add(d, multiply(add(c, multiply(b, t.y)), t.y));
return { x: a.x - b.x, y: a.y - b.y };
}
}
return { distance: Math.sqrt(distance), point: point };
}
/**
* Calculates an axis-aligned bounding box around an array of point
* @param {Vector2[]} points
* @returns {Object}
*/
export function getBounds(points) {
let minX = Number.MAX_VALUE;
let maxX = Number.MIN_VALUE;
let minY = Number.MAX_VALUE;
let maxY = Number.MIN_VALUE;
for (let point of points) {
minX = point.x < minX ? point.x : minX;
maxX = point.x > maxX ? point.x : maxX;
minY = point.y < minY ? point.y : minY;
maxY = point.y > maxY ? point.y : maxY;
}
return { minX, maxX, minY, maxY };
}
/**
* Checks to see if a point is in a polygon using ray casting
* See more at {@link https://en.wikipedia.org/wiki/Point_in_polygon#Ray_casting_algorithm}
* and {@link https://stackoverflow.com/questions/217578/how-can-i-determine-whether-a-2d-point-is-within-a-polygon/2922778}
* @param {Vector2} p
* @param {Vector2[]} points
* @returns {boolean}
*/
export function pointInPolygon(p, points) {
const { minX, maxX, minY, maxY } = getBounds(points);
if (p.x < minX || p.x > maxX || p.y < minY || p.y > maxY) {
return false;
}
let isInside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const a = points[i].y > p.y;
const b = points[j].y > p.y;
if (
a !== b &&
p.x <
((points[j].x - points[i].x) * (p.y - points[i].y)) /
(points[j].y - points[i].y) +
points[i].x
) {
isInside = !isInside;
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a + b
*/
static add(a, b) {
if (typeof b === "number") {
return { x: a.x + b, y: a.y + b };
} else {
return { x: a.x + b.x, y: a.y + b.y };
}
}
return isInside;
}
/**
* Returns true if a the distance between `a` and `b` is under `threshold`
* @param {Vector2} a
* @param {Vector2} b
* @param {number} threshold
*/
export function compare(a, b, threshold) {
return lengthSquared(subtract(a, b)) < threshold * threshold;
}
/**
* Returns the distance between two vectors
* @param {Vector2} a
* @param {Vector2} b
* @param {string} type - `chebyshev | euclidean | manhattan | alternating`
*/
export function distance(a, b, type) {
switch (type) {
case "chebyshev":
return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y));
case "euclidean":
return length(subtract(a, b));
case "manhattan":
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
case "alternating":
// Alternating diagonal distance like D&D 3.5 and Pathfinder
const delta = abs(subtract(a, b));
const ma = max(delta);
const mi = min(delta);
return ma - mi + Math.floor(1.5 * mi);
default:
return length(subtract(a, b));
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a * b
*/
static multiply(a, b) {
if (typeof b === "number") {
return { x: a.x * b, y: a.y * b };
} else {
return { x: a.x * b.x, y: a.y * b.y };
}
}
}
/**
* Linear interpolate between `a` and `b` by `alpha`
* @param {Vector2} a
* @param {Vector2} b
* @param {number} alpha
* @returns {Vector2}
*/
export function lerp(a, b, alpha) {
return { x: lerpNumber(a.x, b.x, alpha), y: lerpNumber(a.y, b.y, alpha) };
}
/**
* Returns total length of a an array of points treated as a path
* @param {Vector2[]} points the array of points in the path
*/
export function pathLength(points) {
let l = 0;
for (let i = 1; i < points.length; i++) {
l += distance(points[i - 1], points[i], "euclidean");
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a / b
*/
static divide(a, b) {
if (typeof b === "number") {
return { x: a.x / b, y: a.y / b };
} else {
return { x: a.x / b.x, y: a.y / b.y };
}
}
return l;
}
/**
* Resample a path to n number of evenly distributed points
* based off of http://depts.washington.edu/acelab/proj/dollar/index.html
* @param {Vector2[]} points the points to resample
* @param {number} n the number of new points
*/
export function resample(points, n) {
if (points.length === 0 || n <= 0) {
return [];
/**
* Rotates a point around a given origin by an angle in degrees
* @param {Vector2} point Point to rotate
* @param {Vector2} origin Origin of the rotation
* @param {number} angle Angle of rotation in degrees
* @returns {Vector2} Rotated point
*/
static rotate(point, origin, angle) {
const cos = Math.cos(toRadians(angle));
const sin = Math.sin(toRadians(angle));
const dif = this.subtract(point, origin);
return {
x: origin.x + cos * dif.x - sin * dif.y,
y: origin.y + sin * dif.x + cos * dif.y,
};
}
let localPoints = [...points];
const intervalLength = pathLength(localPoints) / (n - 1);
let resampledPoints = [localPoints[0]];
let currentDistance = 0;
for (let i = 1; i < localPoints.length; i++) {
let d = distance(localPoints[i - 1], localPoints[i], "euclidean");
if (currentDistance + d >= intervalLength) {
let newPoint = lerp(
localPoints[i - 1],
localPoints[i],
(intervalLength - currentDistance) / d
/**
* Rotates a direction by a given angle in degrees
* @param {Vector2} direction Direction to rotate
* @param {number} angle Angle of rotation in degrees
* @returns {Vector2} Rotated direction
*/
static rotateDirection(direction, angle) {
return this.rotate(direction, { x: 0, y: 0 }, angle);
}
/**
* Returns the min of `value` and `minimum`, if `minimum` is undefined component wise min is returned instead
* @param {Vector2} a
* @param {(Vector2 | number)} [minimum] Value to compare
* @returns {(Vector2 | number)}
*/
static min(a, minimum) {
if (minimum === undefined) {
return a.x < a.y ? a.x : a.y;
} else if (typeof minimum === "number") {
return { x: Math.min(a.x, minimum), y: Math.min(a.y, minimum) };
} else {
return { x: Math.min(a.x, minimum.x), y: Math.min(a.y, minimum.y) };
}
}
/**
* Returns the max of `a` and `maximum`, if `maximum` is undefined component wise max is returned instead
* @param {Vector2} a
* @param {(Vector2 | number)} [maximum] Value to compare
* @returns {(Vector2 | number)}
*/
static max(a, maximum) {
if (maximum === undefined) {
return a.x > a.y ? a.x : a.y;
} else if (typeof maximum === "number") {
return { x: Math.max(a.x, maximum), y: Math.max(a.y, maximum) };
} else {
return { x: Math.max(a.x, maximum.x), y: Math.max(a.y, maximum.y) };
}
}
/**
* Rounds `p` to the nearest value of `to`
* @param {Vector2} p
* @param {Vector2} to
* @returns {Vector2}
*/
static roundTo(p, to) {
return {
x: roundToNumber(p.x, to.x),
y: roundToNumber(p.y, to.y),
};
}
/**
* @param {Vector2} a
* @returns {Vector2} The component wise sign of `a`
*/
static sign(a) {
return { x: Math.sign(a.x), y: Math.sign(a.y) };
}
/**
* @param {Vector2} a
* @returns {Vector2} The component wise absolute of `a`
*/
static abs(a) {
return { x: Math.abs(a.x), y: Math.abs(a.y) };
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} `a` to the power of `b`
*/
static pow(a, b) {
if (typeof b === "number") {
return { x: Math.pow(a.x, b), y: Math.pow(a.y, b) };
} else {
return { x: Math.pow(a.x, b.x), y: Math.pow(a.y, b.y) };
}
}
/**
* @param {Vector2} a
* @returns {number} The dot product between `a` and `a`
*/
static dot2(a) {
return this.dot(a, a);
}
/**
* Clamps `a` between `min` and `max`
* @param {Vector2} a
* @param {number} min
* @param {number} max
* @returns {Vector2}
*/
static clamp(a, min, max) {
return {
x: Math.min(Math.max(a.x, min), max),
y: Math.min(Math.max(a.y, min), max),
};
}
/**
* Calculates the distance between a point and a line segment
* See more at {@link https://www.iquilezles.org/www/articles/distfunctions2d/distfunctions2d.htm}
* @param {Vector2} p Point
* @param {Vector2} a Start of the line
* @param {Vector2} b End of the line
* @returns {Object} The distance to and the closest point on the line segment
*/
static distanceToLine(p, a, b) {
const pa = this.subtract(p, a);
const ba = this.subtract(b, a);
const h = Math.min(Math.max(this.dot(pa, ba) / this.dot(ba, ba), 0), 1);
const distance = this.length(this.subtract(pa, this.multiply(ba, h)));
const point = this.add(a, this.multiply(ba, h));
return { distance, point };
}
/**
* Calculates the distance between a point and a quadratic bezier curve
* See more at {@link https://www.shadertoy.com/view/MlKcDD}
* @todo Fix the robustness of this to allow smoothing on fog layers
* @param {Vector2} pos Position
* @param {Vector2} A Start of the curve
* @param {Vector2} B Control point of the curve
* @param {Vector2} C End of the curve
* @returns {Object} The distance to and the closest point on the curve
*/
static distanceToQuadraticBezier(pos, A, B, C) {
let distance = 0;
let point = { x: pos.x, y: pos.y };
const a = this.subtract(B, A);
const b = this.add(this.subtract(A, this.multiply(B, 2)), C);
const c = this.multiply(a, 2);
const d = this.subtract(A, pos);
// Solve cubic roots to find closest points
const kk = 1 / this.dot(b, b);
const kx = kk * this.dot(a, b);
const ky = (kk * (2 * this.dot(a, a) + this.dot(d, b))) / 3;
const kz = kk * this.dot(d, a);
const p = ky - kx * kx;
const p3 = p * p * p;
const q = kx * (2 * kx * kx - 3 * ky) + kz;
let h = q * q + 4 * p3;
if (h >= 0) {
// 1 root
h = Math.sqrt(h);
const x = this.divide(this.subtract({ x: h, y: -h }, q), 2);
const uv = this.multiply(this.sign(x), this.pow(this.abs(x), 1 / 3));
const t = Math.min(Math.max(uv.x + uv.y - kx, 0), 1);
point = this.add(A, this.multiply(this.add(c, this.multiply(b, t)), t));
distance = this.dot2(
this.add(d, this.multiply(this.add(c, this.multiply(b, t)), t))
);
resampledPoints.push(newPoint);
localPoints.splice(i, 0, newPoint);
currentDistance = 0;
} else {
currentDistance += d;
// 3 roots but ignore the 3rd one as it will never be closest
// https://www.shadertoy.com/view/MdXBzB
const z = Math.sqrt(-p);
const v = Math.acos(q / (p * z * 2)) / 3;
const m = Math.cos(v);
const n = Math.sin(v) * 1.732050808;
const t = this.clamp(
this.subtract(this.multiply({ x: m + m, y: -n - m }, z), kx),
0,
1
);
const d1 = this.dot2(
this.add(d, this.multiply(this.add(c, this.multiply(b, t.x)), t.x))
);
const d2 = this.dot2(
this.add(d, this.multiply(this.add(c, this.multiply(b, t.y)), t.y))
);
distance = Math.min(d1, d2);
if (d1 < d2) {
point = this.add(
d,
this.multiply(this.add(c, this.multiply(b, t.x)), t.x)
);
} else {
point = this.add(
d,
this.multiply(this.add(c, this.multiply(b, t.y)), t.y)
);
}
}
}
if (resampledPoints.length === n - 1) {
resampledPoints.push(localPoints[localPoints.length - 1]);
return { distance: Math.sqrt(distance), point: point };
}
return resampledPoints;
/**
* Calculates an axis-aligned bounding box around an array of point
* @param {Vector2[]} points
* @returns {Object}
*/
static getBounds(points) {
let minX = Number.MAX_VALUE;
let maxX = Number.MIN_VALUE;
let minY = Number.MAX_VALUE;
let maxY = Number.MIN_VALUE;
for (let point of points) {
minX = point.x < minX ? point.x : minX;
maxX = point.x > maxX ? point.x : maxX;
minY = point.y < minY ? point.y : minY;
maxY = point.y > maxY ? point.y : maxY;
}
return { minX, maxX, minY, maxY };
}
/**
* Checks to see if a point is in a polygon using ray casting
* See more at {@link https://en.wikipedia.org/wiki/Point_in_polygon#Ray_casting_algorithm}
* and {@link https://stackoverflow.com/questions/217578/how-can-i-determine-whether-a-2d-point-is-within-a-polygon/2922778}
* @param {Vector2} p
* @param {Vector2[]} points
* @returns {boolean}
*/
static pointInPolygon(p, points) {
const { minX, maxX, minY, maxY } = this.getBounds(points);
if (p.x < minX || p.x > maxX || p.y < minY || p.y > maxY) {
return false;
}
let isInside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const a = points[i].y > p.y;
const b = points[j].y > p.y;
if (
a !== b &&
p.x <
((points[j].x - points[i].x) * (p.y - points[i].y)) /
(points[j].y - points[i].y) +
points[i].x
) {
isInside = !isInside;
}
}
return isInside;
}
/**
* Returns true if a the distance between `a` and `b` is under `threshold`
* @param {Vector2} a
* @param {Vector2} b
* @param {number} threshold
*/
static compare(a, b, threshold) {
return this.lengthSquared(this.subtract(a, b)) < threshold * threshold;
}
/**
* Returns the distance between two vectors
* @param {Vector2} a
* @param {Vector2} b
* @param {string?} type - `chebyshev | euclidean | manhattan | alternating`
*/
static distance(a, b, type = "euclidean") {
switch (type) {
case "chebyshev":
return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y));
case "euclidean":
return this.length(this.subtract(a, b));
case "manhattan":
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
case "alternating":
// Alternating diagonal distance like D&D 3.5 and Pathfinder
const delta = this.abs(this.subtract(a, b));
const ma = this.max(delta);
const mi = this.min(delta);
return ma - mi + Math.floor(1.5 * mi);
default:
return this.length(this.subtract(a, b));
}
}
/**
* Linear interpolate between `a` and `b` by `alpha`
* @param {Vector2} a
* @param {Vector2} b
* @param {number} alpha
* @returns {Vector2}
*/
static lerp(a, b, alpha) {
return { x: lerpNumber(a.x, b.x, alpha), y: lerpNumber(a.y, b.y, alpha) };
}
/**
* Returns total length of a an array of points treated as a path
* @param {Vector2[]} points the array of points in the path
*/
static pathLength(points) {
let l = 0;
for (let i = 1; i < points.length; i++) {
l += this.distance(points[i - 1], points[i]);
}
return l;
}
/**
* Resample a path to n number of evenly distributed points
* based off of http://depts.washington.edu/acelab/proj/dollar/index.html
* @param {Vector2[]} points the points to resample
* @param {number} n the number of new points
*/
static resample(points, n) {
if (points.length === 0 || n <= 0) {
return [];
}
let localPoints = [...points];
const intervalLength = this.pathLength(localPoints) / (n - 1);
let resampledPoints = [localPoints[0]];
let currentDistance = 0;
for (let i = 1; i < localPoints.length; i++) {
let d = this.distance(localPoints[i - 1], localPoints[i]);
if (currentDistance + d >= intervalLength) {
let newPoint = this.lerp(
localPoints[i - 1],
localPoints[i],
(intervalLength - currentDistance) / d
);
resampledPoints.push(newPoint);
localPoints.splice(i, 0, newPoint);
currentDistance = 0;
} else {
currentDistance += d;
}
}
if (resampledPoints.length === n - 1) {
resampledPoints.push(localPoints[localPoints.length - 1]);
}
return resampledPoints;
}
}
export default Vector2;

View File

@ -1,7 +1,7 @@
import { useEffect, useState, useRef, useCallback } from "react";
import useDebounce from "./useDebounce";
import { diff, applyChanges } from "./diff";
import { diff, applyChanges } from "../helpers/diff";
/**
* @callback setNetworkedState

View File

@ -8,8 +8,9 @@ import MapEditor from "../components/map/MapEditor";
import MapDataContext from "../contexts/MapDataContext";
import { isEmpty } from "../helpers/shared";
import { getMapDefaultInset } from "../helpers/map";
import useResponsiveLayout from "../helpers/useResponsiveLayout";
import { getMapDefaultInset } from "../helpers/grid";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
function EditMapModal({ isOpen, onDone, map, mapState }) {
const { updateMap, updateMapState } = useContext(MapDataContext);

View File

@ -8,7 +8,8 @@ import TokenPreview from "../components/token/TokenPreview";
import TokenDataContext from "../contexts/TokenDataContext";
import { isEmpty } from "../helpers/shared";
import useResponsiveLayout from "../helpers/useResponsiveLayout";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
function EditTokenModal({ isOpen, onDone, token }) {
const { updateToken } = useContext(TokenDataContext);

View File

@ -13,18 +13,22 @@ import ImageDrop from "../components/ImageDrop";
import LoadingOverlay from "../components/LoadingOverlay";
import blobToBuffer from "../helpers/blobToBuffer";
import useKeyboard from "../helpers/useKeyboard";
import { resizeImage } from "../helpers/image";
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
import { getMapDefaultInset, getGridSize, gridSizeVaild } from "../helpers/map";
import useResponsiveLayout from "../helpers/useResponsiveLayout";
import * as Vector2 from "../helpers/vector2";
import {
getMapDefaultInset,
getGridSize,
gridSizeVaild,
} from "../helpers/grid";
import Vector2 from "../helpers/Vector2";
import useKeyboard from "../hooks/useKeyboard";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
import MapDataContext from "../contexts/MapDataContext";
import AuthContext from "../contexts/AuthContext";
const defaultMapProps = {
// Grid type
showGrid: false,
snapToGrid: true,
quality: "original",

View File

@ -13,9 +13,10 @@ import TokenTiles from "../components/token/TokenTiles";
import LoadingOverlay from "../components/LoadingOverlay";
import blobToBuffer from "../helpers/blobToBuffer";
import useKeyboard from "../helpers/useKeyboard";
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
import useResponsiveLayout from "../helpers/useResponsiveLayout";
import useKeyboard from "../hooks/useKeyboard";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
import TokenDataContext from "../contexts/TokenDataContext";
import AuthContext from "../contexts/AuthContext";

View File

@ -16,7 +16,7 @@ import Slider from "../components/Slider";
import AuthContext from "../contexts/AuthContext";
import DatabaseContext from "../contexts/DatabaseContext";
import useSetting from "../helpers/useSetting";
import useSetting from "../hooks/useSetting";
import ConfirmModal from "./ConfirmModal";
import ImportExportModal from "./ImportExportModal";

View File

@ -5,7 +5,7 @@ import shortid from "shortid";
import AuthContext from "../contexts/AuthContext";
import useSetting from "../helpers/useSetting";
import useSetting from "../hooks/useSetting";
import Modal from "../components/Modal";

View File

@ -5,7 +5,7 @@ import Modal from "../components/Modal";
import { getHMSDuration, getDurationHMS } from "../helpers/timer";
import useSetting from "../helpers/useSetting";
import useSetting from "../hooks/useSetting";
function StartTimerModal({
isOpen,

View File

@ -8,8 +8,10 @@ import DatabaseContext from "../contexts/DatabaseContext";
import PartyContext from "../contexts/PartyContext";
import { omit } from "../helpers/shared";
import useDebounce from "../helpers/useDebounce";
import useNetworkedState from "../helpers/useNetworkedState";
import useDebounce from "../hooks/useDebounce";
import useNetworkedState from "../hooks/useNetworkedState";
// Load session for auto complete
// eslint-disable-next-line no-unused-vars
import Session from "./Session";

View File

@ -5,8 +5,9 @@ import AuthContext from "../contexts/AuthContext";
import MapPointer from "../components/map/MapPointer";
import { isEmpty } from "../helpers/shared";
import { lerp, compare } from "../helpers/vector2";
import useSetting from "../helpers/useSetting";
import Vector2 from "../helpers/Vector2";
import useSetting from "../hooks/useSetting";
// Send pointer updates every 50ms (20fps)
const sendTickRate = 50;
@ -108,7 +109,11 @@ function NetworkedMapPointer({ session, active, gridSize }) {
to: { ...pointer, time: performance.now() + sendTickRate },
};
} else if (
!compare(interpolations[id].to.position, pointer.position, 0.0001) ||
!Vector2.compare(
interpolations[id].to.position,
pointer.position,
0.0001
) ||
interpolations[id].to.visible !== pointer.visible
) {
const from = interpolations[id].to;
@ -153,7 +158,11 @@ function NetworkedMapPointer({ session, active, gridSize }) {
interpolatedPointerState[interp.id] = {
id: interp.id,
visible: interp.from.visible,
position: lerp(interp.from.position, interp.to.position, alpha),
position: Vector2.lerp(
interp.from.position,
interp.to.position,
alpha
),
color: interp.from.color,
};
}