From 502f872fbf5702bccdc11e01173f7d84505605ec Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Sun, 7 Feb 2021 11:16:36 +1100 Subject: [PATCH] Add hex support to useGridSnapping --- src/components/Grid.js | 68 ++++++++++---------- src/components/map/MapToken.js | 9 ++- src/components/note/Note.js | 6 +- src/helpers/Vector2.js | 28 ++++++++ src/helpers/grid.js | 98 +++++++++++++--------------- src/helpers/konva.js | 2 +- src/helpers/shared.js | 4 ++ src/hooks/useGridSnapping.js | 114 +++++++++++++++------------------ 8 files changed, 172 insertions(+), 157 deletions(-) diff --git a/src/components/Grid.js b/src/components/Grid.js index 0b2ffec..2850b0c 100644 --- a/src/components/Grid.js +++ b/src/components/Grid.js @@ -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( - gridClipFunction(context, grid, x, y, gridCellPixelSize)) - } x={cellLocation.x} y={cellLocation.y} offset={negativeGridOffset} > - { - console.log( - getNearestCellCoordinates( - grid, - cellLocation.x, - cellLocation.y, - gridCellPixelSize - ) - ); - }} - /> + {/* Offset the hex tile to align to top left of grid */} + + + ); } } } - return {shapes}; + return ( + { + context.rect( + gridOffset.x - finalStrokeWidth / 2, + gridOffset.y - finalStrokeWidth / 2, + gridPixelSize.width + finalStrokeWidth, + gridPixelSize.height + finalStrokeWidth + ); + }} + > + {shapes} + + ); } Grid.defaultProps = { diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js index a96c386..eae04c8 100644 --- a/src/components/map/MapToken.js +++ b/src/components/map/MapToken.js @@ -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(); diff --git a/src/components/note/Note.js b/src/components/note/Note.js index 2a45c97..914b78a 100644 --- a/src/components/note/Note.js +++ b/src/components/note/Note.js @@ -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; diff --git a/src/helpers/Vector2.js b/src/helpers/Vector2.js index 01dce79..30c430c 100644 --- a/src/helpers/Vector2.js +++ b/src/helpers/Vector2.js @@ -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; diff --git a/src/helpers/grid.js b/src/helpers/grid.js index e8707e9..f09d5e0 100644 --- a/src/helpers/grid.js +++ b/src/helpers/grid.js @@ -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; diff --git a/src/helpers/konva.js b/src/helpers/konva.js index 2eae222..39f6b98 100644 --- a/src/helpers/konva.js +++ b/src/helpers/konva.js @@ -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; diff --git a/src/helpers/shared.js b/src/helpers/shared.js index 7d420fb..a9ed206 100644 --- a/src/helpers/shared.js +++ b/src/helpers/shared.js @@ -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); } diff --git a/src/hooks/useGridSnapping.js b/src/hooks/useGridSnapping.js index 3336115..7da3cb4 100644 --- a/src/hooks/useGridSnapping.js +++ b/src/hooks/useGridSnapping.js @@ -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; + } } }