Add hex support to useGridSnapping

This commit is contained in:
Mitchell McCaffrey 2021-02-07 11:16:36 +11:00
parent fbea8bd7a7
commit 502f872fbf
8 changed files with 172 additions and 157 deletions

View File

@ -1,12 +1,7 @@
import React from "react";
import { Line, Group, RegularPolygon } from "react-konva";
import {
getCellLocation,
gridClipFunction,
shouldClipCell,
getNearestCellCoordinates,
} from "../helpers/grid";
import { getCellLocation } from "../helpers/grid";
import Vector2 from "../helpers/Vector2";
import { useGrid } from "../contexts/GridContext";
@ -25,6 +20,7 @@ function Grid({ strokeWidth, stroke }) {
}
const negativeGridOffset = Vector2.multiply(gridOffset, -1);
const finalStrokeWidth = gridStrokeWidth * strokeWidth;
const shapes = [];
if (grid.type === "square") {
@ -39,7 +35,7 @@ function Grid({ strokeWidth, stroke }) {
gridPixelSize.height,
]}
stroke={stroke}
strokeWidth={gridStrokeWidth * strokeWidth}
strokeWidth={finalStrokeWidth}
opacity={0.5}
offset={negativeGridOffset}
/>
@ -56,54 +52,56 @@ function Grid({ strokeWidth, stroke }) {
y * gridCellPixelSize.height,
]}
stroke={stroke}
strokeWidth={gridStrokeWidth * strokeWidth}
strokeWidth={finalStrokeWidth}
opacity={0.5}
offset={negativeGridOffset}
/>
);
}
} else if (grid.type === "hexVertical" || grid.type === "hexHorizontal") {
// Start at -1 to overshoot the bounds of the grid to ensure all lines are drawn
for (let x = -1; x < grid.size.x; x++) {
for (let y = -1; y < grid.size.y; y++) {
// End at grid size + 1 to overshoot the bounds of the grid to ensure all lines are drawn
for (let x = 0; x < grid.size.x + 1; x++) {
for (let y = 0; y < grid.size.y + 1; y++) {
const cellLocation = getCellLocation(grid, x, y, gridCellPixelSize);
shapes.push(
<Group
key={`grid_${x}_${y}`}
clipFunc={
shouldClipCell(grid, x, y) &&
((context) =>
gridClipFunction(context, grid, x, y, gridCellPixelSize))
}
x={cellLocation.x}
y={cellLocation.y}
offset={negativeGridOffset}
>
<RegularPolygon
sides={6}
radius={gridCellPixelSize.radius}
stroke={stroke}
strokeWidth={gridStrokeWidth * strokeWidth}
opacity={0.5}
rotation={grid.type === "hexVertical" ? 0 : 90}
onMouseDown={() => {
console.log(
getNearestCellCoordinates(
grid,
cellLocation.x,
cellLocation.y,
gridCellPixelSize
)
);
}}
/>
{/* Offset the hex tile to align to top left of grid */}
<Group offset={Vector2.multiply(gridCellPixelSize, -0.5)}>
<RegularPolygon
sides={6}
radius={gridCellPixelSize.radius}
stroke={stroke}
strokeWidth={finalStrokeWidth}
opacity={0.5}
rotation={grid.type === "hexVertical" ? 0 : 90}
/>
</Group>
</Group>
);
}
}
}
return <Group>{shapes}</Group>;
return (
<Group
// Clip grid to bounds to cover hex overshoot
clipFunc={(context) => {
context.rect(
gridOffset.x - finalStrokeWidth / 2,
gridOffset.y - finalStrokeWidth / 2,
gridPixelSize.width + finalStrokeWidth,
gridPixelSize.height + finalStrokeWidth
);
}}
>
{shapes}
</Group>
);
}
Grid.defaultProps = {

View File

@ -178,9 +178,12 @@ function MapToken({
}
}
const tokenWidth = gridCellPixelSize.width * tokenState.size;
const tokenHeight =
(gridCellPixelSize.width / tokenAspectRatio) * tokenState.size;
const minCellSize = Math.min(
gridCellPixelSize.width,
gridCellPixelSize.height
);
const tokenWidth = minCellSize * tokenState.size;
const tokenHeight = (minCellSize / tokenAspectRatio) * tokenState.size;
const debouncedStageScale = useDebounce(stageScale, 50);
const imageRef = useRef();

View File

@ -27,7 +27,11 @@ function Note({
const { mapWidth, mapHeight, setPreventMapInteraction } = useMapInteraction();
const { gridCellPixelSize } = useGrid();
const noteWidth = gridCellPixelSize.width * note.size;
const minCellSize = Math.min(
gridCellPixelSize.width,
gridCellPixelSize.height
);
const noteWidth = minCellSize * note.size;
const noteHeight = noteWidth;
const notePadding = noteWidth / 10;

View File

@ -2,6 +2,7 @@ import {
toRadians,
roundTo as roundToNumber,
lerp as lerpNumber,
floorTo as floorToNumber,
} from "./shared";
/**
@ -186,6 +187,19 @@ class Vector2 {
};
}
/**
* Floors `p` to the nearest value of `to`
* @param {Vector2} p
* @param {Vector2} to
* @returns {Vector2}
*/
static floorTo(p, to) {
return {
x: floorToNumber(p.x, to.x),
y: floorToNumber(p.y, to.y),
};
}
/**
* @param {Vector2} a
* @returns {Vector2} The component wise sign of `a`
@ -473,6 +487,20 @@ class Vector2 {
return resampledPoints;
}
/**
* Rotate a vector 90 degrees
* @param {Vector2} p Point
* @param {("counterClockwise"|"clockwise")=} direction Direction to rotate the vector
* @returns {Vector2}
*/
static rotate90(p, direction = "clockwise") {
if (direction === "clockwise") {
return { x: p.y, y: -p.x };
} else {
return { x: -p.y, y: p.x };
}
}
}
export default Vector2;

View File

@ -57,7 +57,7 @@ export function getCellPixelSize(grid, gridWidth, gridHeight) {
}
/**
* Find the location of a cell in the grid.
* Find the center location of a cell in the grid.
* Hex is addressed in an offset coordinate system with even numbered columns/rows offset to the right
* @param {Grid} grid
* @param {number} col X-axis coordinate of the cell
@ -68,16 +68,19 @@ export function getCellPixelSize(grid, gridWidth, gridHeight) {
export function getCellLocation(grid, col, row, cellSize) {
switch (grid.type) {
case "square":
return { x: col * cellSize.width, y: row * cellSize.height };
return {
x: col * cellSize.width + cellSize.width / 2,
y: row * cellSize.height + cellSize.height / 2,
};
case "hexVertical":
return {
x: col * cellSize.width + (cellSize.width * (1 + (row & 1))) / 2,
y: row * cellSize.height * (3 / 4) + cellSize.radius,
x: cellSize.radius * SQRT3 * (col - 0.5 * (row & 1)),
y: ((cellSize.radius * 3) / 2) * row,
};
case "hexHorizontal":
return {
x: col * cellSize.width * (3 / 4) + cellSize.radius,
y: row * cellSize.height + (cellSize.height * (1 + (col & 1))) / 2,
x: ((cellSize.radius * 3) / 2) * col,
y: cellSize.radius * SQRT3 * (row - 0.5 * (col & 1)),
};
default:
throw GRID_TYPE_NOT_IMPLEMENTED;
@ -95,7 +98,7 @@ export function getCellLocation(grid, col, row, cellSize) {
export function getNearestCellCoordinates(grid, x, y, cellSize) {
switch (grid.type) {
case "square":
return Vector2.roundTo({ x, y }, cellSize);
return Vector2.divide(Vector2.floorTo({ x, y }, cellSize), cellSize);
case "hexVertical":
// Find nearest cell in cube coordinates the convert to offset coordinates
const cubeXVert = ((SQRT3 / 3) * x - (1 / 3) * y) / cellSize.radius;
@ -115,62 +118,49 @@ export function getNearestCellCoordinates(grid, x, y, cellSize) {
}
/**
* Whether the cell located at `x, y` is out of bounds of the grid
* Find the corners of a grid cell
* @param {Grid} grid
* @param {number} col X-axis coordinate of the cell
* @param {number} row Y-axis coordinate of the cell
* @returns {boolean}
* @param {number} x X location of the cell in pixels
* @param {number} y Y location of the cell in pixels
* @param {Size} cellSize Cell size in pixels
* @returns {Vector2[]}
*/
export function shouldClipCell(grid, col, row) {
if (col < 0 || row < 0) {
return true;
}
export function getCellCorners(grid, x, y, cellSize) {
const position = new Vector2(x, y);
switch (grid.type) {
case "square":
return false;
const halfSize = Vector2.multiply(cellSize, 0.5);
return [
Vector2.add(position, Vector2.multiply(halfSize, { x: -1, y: -1 })),
Vector2.add(position, Vector2.multiply(halfSize, { x: 1, y: -1 })),
Vector2.add(position, Vector2.multiply(halfSize, { x: 1, y: 1 })),
Vector2.add(position, Vector2.multiply(halfSize, { x: -1, y: 1 })),
];
case "hexVertical":
return col === grid.size.x - 1 && (row & 1) !== 0;
const up = Vector2.subtract(position, { x: 0, y: cellSize.radius });
return [
up,
Vector2.rotate(up, position, 60),
Vector2.rotate(up, position, 120),
Vector2.rotate(up, position, 180),
Vector2.rotate(up, position, 240),
Vector2.rotate(up, position, 300),
];
case "hexHorizontal":
return row === grid.size.y - 1 && (col & 1) !== 0;
const right = Vector2.add(position, { x: cellSize.radius, y: 0 });
return [
right,
Vector2.rotate(right, position, 60),
Vector2.rotate(right, position, 120),
Vector2.rotate(right, position, 180),
Vector2.rotate(right, position, 240),
Vector2.rotate(right, position, 300),
];
default:
throw GRID_TYPE_NOT_IMPLEMENTED;
}
}
/**
* Canvas clip function for culling hex cells that overshoot/undershoot the grid
* @param {CanvasRenderingContext2D} context The canvas context of the clip function
* @param {Grid} grid
* @param {number} col X-axis coordinate of the cell
* @param {number} row Y-axis coordinate of the cell
* @param {Size} cellSize Cell size in pixels
*/
export function gridClipFunction(context, grid, col, row, cellSize) {
// Clip the undershooting cells unless they are needed to fill out a specific grid type
if (
(col < 0 && grid.type !== "hexVertical") ||
(col < 0 && (row & 1) === 0)
) {
return;
}
if (
(row < 0 && grid.type !== "hexHorizontal") ||
(row < 0 && (col & 1) === 0)
) {
return;
}
context.rect(
col < 0 ? 0 : -cellSize.radius,
row < 0 ? 0 : -cellSize.radius,
col > 0 && grid.type === "hexVertical"
? cellSize.radius
: cellSize.radius * 2,
row > 0 && grid.type === "hexVertical"
? cellSize.radius * 2
: cellSize.radius
);
}
/**
* Get the height of a grid based off of its width
* @param {Grid} grid
@ -243,7 +233,7 @@ export function getGridMaxZoom(grid) {
* @param {("hexVertical"|"hexHorizontal")} type
* @returns {Vector2}
*/
function hexCubeToOffset(cube, type) {
export function hexCubeToOffset(cube, type) {
if (type === "hexVertical") {
const x = cube.x + (cube.z + (cube.z & 1)) / 2;
const y = cube.z;
@ -260,7 +250,7 @@ function hexCubeToOffset(cube, type) {
* @param {("hexVertical"|"hexHorizontal")} type
* @returns {Vector3}
*/
function hexOffsetToCube(offset, type) {
export function hexOffsetToCube(offset, type) {
if (type === "hexVertical") {
const x = offset.x - (offset.y - (offset.y & 1)) / 2;
const z = offset.y;

View File

@ -223,7 +223,7 @@ export function Trail({ position, size, duration, segments, color }) {
const drawOffsetLine = (from, to, alpha) => {
const forward = Vector2.normalize(Vector2.subtract(from, to));
// Rotate the forward vector 90 degrees based off of the direction
const side = { x: forward.y, y: -forward.x };
const side = Vector2.rotate90(forward);
// Offset the `to` position by the size of the point and in the side direction
const toSize = (alpha * size) / 2;

View File

@ -28,6 +28,10 @@ export function roundTo(x, to) {
return Math.round(x / to) * to;
}
export function floorTo(x, to) {
return Math.floor(x / to) * to;
}
export function toRadians(angle) {
return angle * (Math.PI / 180);
}

View File

@ -1,7 +1,13 @@
// Load Konva for auto complete
// eslint-disable-next-line no-unused-vars
import Konva from "konva";
import Vector2 from "../helpers/Vector2";
import { getCellLocation } from "../helpers/grid";
import {
getCellLocation,
getNearestCellCoordinates,
getCellCorners,
} from "../helpers/grid";
import { useGrid } from "../contexts/GridContext";
@ -10,79 +16,61 @@ import { useGrid } from "../contexts/GridContext";
* @param {number} snappingThreshold 1 = Always snap, 0 = never snap
*/
function useGridSnapping(snappingThreshold) {
const { gridOffset, gridCellPixelSize } = useGrid();
const { grid, gridOffset, gridCellPixelSize } = useGrid();
/**
* @param {Konva.Node} node The node to snap
*/
function snapNodeToGrid(node) {
const position = node.position();
const halfSize = Vector2.divide({ x: node.width(), y: node.height() }, 2);
// Offsets to tranform the centered position into the four corners
const cornerOffsets = [
{ x: 0, y: 0 },
// halfSize,
// { x: -halfSize.x, y: -halfSize.y },
// { x: halfSize.x, y: -halfSize.y },
// { x: -halfSize.x, y: halfSize.y },
];
// Minimum distance from a corner to the grid
let minCornerGridDistance = Number.MAX_VALUE;
// Minimum component of the difference between the min corner and the grid
let minCornerMinComponent;
// Closest grid value
let minGridSnap;
// Find the closest corner to the grid
for (let cornerOffset of cornerOffsets) {
const corner = Vector2.add(position, cornerOffset);
// Transform into gridOffset space, round, then transform back
const gridSnap = Vector2.add(
Vector2.roundTo(
Vector2.subtract(corner, gridOffset),
gridCellPixelSize
),
gridOffset
// Account for grid offset
let offsetPosition = Vector2.subtract(position, gridOffset);
// Move hex tiles to top left
if (grid.type === "hexVertical" || grid.type === "hexHorizontal") {
offsetPosition = Vector2.subtract(
offsetPosition,
Vector2.multiply(gridCellPixelSize, 0.5)
);
const gridDistance = Vector2.length(Vector2.subtract(gridSnap, corner));
const minComponent = Vector2.min(gridCellPixelSize);
if (gridDistance < minCornerGridDistance) {
minCornerGridDistance = gridDistance;
minCornerMinComponent = minComponent;
// Move the grid value back to the center
minGridSnap = Vector2.subtract(gridSnap, cornerOffset);
}
}
// Snap to center of grid
// Subtract gridOffset and half grid size to transform it into gridOffset half space then transform it back
const halfGridSize = Vector2.multiply(gridCellPixelSize, 0.5);
const centerSnap = Vector2.add(
Vector2.add(
Vector2.roundTo(
Vector2.subtract(
Vector2.subtract(position, gridOffset),
halfGridSize
),
gridCellPixelSize
),
halfGridSize
),
gridOffset
const nearsetCell = getNearestCellCoordinates(
grid,
offsetPosition.x,
offsetPosition.y,
gridCellPixelSize
);
const centerDistance = Vector2.length(
Vector2.subtract(centerSnap, position)
const cellPosition = getCellLocation(
grid,
nearsetCell.x,
nearsetCell.y,
gridCellPixelSize
);
const cellCorners = getCellCorners(
grid,
cellPosition.x,
cellPosition.y,
gridCellPixelSize
);
if (minCornerGridDistance < minCornerMinComponent * snappingThreshold) {
node.position(minGridSnap);
} else if (
centerDistance <
Vector2.min(gridCellPixelSize) * snappingThreshold
) {
node.position(centerSnap);
const snapPoints = [cellPosition, ...cellCorners];
for (let snapPoint of snapPoints) {
const distanceToSnapPoint = Vector2.distance(offsetPosition, snapPoint);
if (
distanceToSnapPoint <
Vector2.min(gridCellPixelSize) * snappingThreshold
) {
// Reverse grid offset
let offsetSnapPoint = Vector2.add(snapPoint, gridOffset);
// Reverse offset for hex tiles
if (grid.type === "hexVertical" || grid.type === "hexHorizontal") {
offsetSnapPoint = Vector2.add(
offsetSnapPoint,
Vector2.multiply(gridCellPixelSize, 0.5)
);
}
node.position(offsetSnapPoint);
return;
}
}
}