This commit is contained in:
2026-05-19 14:28:40 -04:00
parent a900e5e96a
commit ce69b7f771
9 changed files with 421 additions and 390 deletions

View File

@@ -17,16 +17,17 @@ export default function ColorPanel({
// One hidden color input ref per slot // One hidden color input ref per slot
const inputRefs = useRef<(HTMLInputElement | null)[]>([]); const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
// Keys 19 select the corresponding color slot (plain, no shift) // Shift+19 select the corresponding color slot
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; if (!e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
if ( if (
e.target instanceof HTMLInputElement || e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement e.target instanceof HTMLTextAreaElement
) )
return; return;
const num = parseInt(e.key, 10); if (!e.code.startsWith("Digit")) return;
const num = parseInt(e.code.replace("Digit", ""), 10);
if (num >= 1 && num <= colors.length) { if (num >= 1 && num <= colors.length) {
onColorChange(colors[num - 1]); onColorChange(colors[num - 1]);
} }
@@ -57,9 +58,9 @@ export default function ColorPanel({
style={{ background: c }} style={{ background: c }}
onClick={() => onColorChange(c)} onClick={() => onColorChange(c)}
onDoubleClick={() => handleDoubleClick(i)} onDoubleClick={() => handleDoubleClick(i)}
title={`${c} (${i + 1}) — double-click to edit`} title={`${c} (Shift+${i + 1}) — double-click to edit`}
> >
<span className="cp-key-hint">{i + 1}</span> <span className="cp-key-hint">{i + 1}</span>
</button> </button>
{/* Hidden color picker for this slot */} {/* Hidden color picker for this slot */}
<input <input

View File

@@ -29,27 +29,27 @@ const TOOLS: {
id: "pan", id: "pan",
icon: <MdPanTool />, icon: <MdPanTool />,
title: "Pan drag to move the map", title: "Pan drag to move the map",
shortcut: "Shift+1", shortcut: "1",
}, },
{ {
id: "zoom", id: "zoom",
icon: <MdZoomIn />, icon: <MdZoomIn />,
title: "Zoom click to zoom in/out", title: "Zoom click to zoom in/out",
shortcut: "Shift+2", shortcut: "2",
}, },
{ {
id: "draw", id: "draw",
icon: <MdBrush />, icon: <MdBrush />,
title: title:
"Draw left-click to paint, right-click to erase, Shift+click to fill/replace", "Draw left-click to paint, right-click to erase, Shift+click to fill/replace",
shortcut: "Shift+3", shortcut: "3",
}, },
{ {
id: "token", id: "token",
icon: <MdPerson />, icon: <MdPerson />,
title: title:
"Token click to place, drag to move, Shift+click to resize, double-click to edit, right-click to delete", "Token click to place, drag to move, Shift+click to resize, double-click to edit, right-click to delete",
shortcut: "Shift+4", shortcut: "4",
}, },
]; ];
@@ -61,7 +61,7 @@ export default function ControlPanel({
onUndo, onUndo,
onRedo, onRedo,
}: Props) { }: Props) {
// Keyboard shortcuts: Shift+1/2/3/4 for tools // Keyboard shortcuts: 1/2/3/4 for tools
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if ( if (
@@ -69,21 +69,17 @@ export default function ControlPanel({
e.target instanceof HTMLTextAreaElement e.target instanceof HTMLTextAreaElement
) )
return; return;
if (!e.shiftKey) return; if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
switch (e.key) { switch (e.key) {
case "!": // Shift+1 on many layouts
case "1": case "1":
onToolChange("pan"); onToolChange("pan");
break; break;
case "@": // Shift+2
case "2": case "2":
onToolChange("zoom"); onToolChange("zoom");
break; break;
case "#": // Shift+3
case "3": case "3":
onToolChange("draw"); onToolChange("draw");
break; break;
case "$": // Shift+4
case "4": case "4":
onToolChange("token"); onToolChange("token");
break; break;

View File

@@ -0,0 +1,17 @@
export const DEFAULT_ZOOM = 40;
export const MIN_ZOOM = 8;
export const MAX_ZOOM = 160;
export const ZOOM_STEP = 1.12;
export const BG_COLOR = "#111827";
export const GRID_COLOR = "rgba(255,255,255,0.07)";
export const GRID_COLOR_MAJOR = "rgba(255,255,255,0.16)";
/** BFS stops at this many cells; region is considered unbounded → paint only the clicked cell. */
export const MAX_FLOOD_CELLS = 2500;
/** World units per second for WASD keyboard panning. */
export const WASD_PAN_SPEED = 12;
/** Maximum token size (NxN). */
export const MAX_TOKEN_SIZE = 9;

View File

@@ -6,383 +6,42 @@ import {
forwardRef, forwardRef,
useImperativeHandle, useImperativeHandle,
} from "react"; } from "react";
import type { import type { GridCell, GridToken, ServerMessage, ClientMessage } from "../../types";
GridCell, import { useWebSocket } from "../../hooks/useWebSocket";
GridToken, import TokenDialog from "../TokenDialog";
Tool, import TokenStackPicker from "../TokenStackPicker";
MovementRule,
ServerMessage,
ClientMessage,
} from "../types";
import { useWebSocket } from "../hooks/useWebSocket";
import TokenDialog from "./TokenDialog";
import TokenStackPicker from "./TokenStackPicker";
import "./Grid.css"; import "./Grid.css";
const DEFAULT_ZOOM = 40; import {
const MIN_ZOOM = 8; DEFAULT_ZOOM,
const MAX_ZOOM = 160; MIN_ZOOM,
const ZOOM_STEP = 1.12; MAX_ZOOM,
ZOOM_STEP,
BG_COLOR,
GRID_COLOR,
GRID_COLOR_MAJOR,
WASD_PAN_SPEED,
MAX_TOKEN_SIZE,
} from "./constants";
import type { Camera, UndoAction, Props, GridHandle } from "./types";
import {
cellKey,
parseCellKey,
canvasToCell,
canvasToCenterAnchor,
cellToCanvas,
clamp,
calcDndSquares,
} from "./utils";
import {
drawToken,
drawStackBadge,
floodFill,
floodFillColored,
clampCameraToContent,
} from "./render";
const BG_COLOR = "#111827"; export type { GridHandle };
const GRID_COLOR = "rgba(255,255,255,0.07)";
const GRID_COLOR_MAJOR = "rgba(255,255,255,0.16)";
/** BFS stops at this many cells; region is considered unbounded → paint only the clicked cell. */
const MAX_FLOOD_CELLS = 2500;
/** World units per second for WASD keyboard panning. */
const WASD_PAN_SPEED = 12;
/** Maximum token size (NxN). */
const MAX_TOKEN_SIZE = 9;
interface Camera {
offsetX: number;
offsetY: number;
zoom: number;
}
interface UndoAction {
revert: () => void;
apply: () => void;
}
interface Props {
mapId: string;
tool: Tool;
paintColor: string;
tokenColor: string;
onColorsLoaded: (colors: string[]) => void;
/** How many real-world units one grid square represents (default 5). */
unitsPerSquare: number;
/** Label for the unit, e.g. "ft" or "m". */
unitLabel: string;
/** Which diagonal movement rule to use. */
movementRule: MovementRule;
/** Called whenever the undo/redo stack availability changes. */
onUndoStateChange?: (canUndo: boolean, canRedo: boolean) => void;
}
export interface GridHandle {
sendColorUpdate: (colors: string[]) => void;
undo: () => void;
redo: () => void;
}
function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
function parseCellKey(key: string): { x: number; y: number } {
const [xs, ys] = key.split(",");
return { x: Number(xs), y: Number(ys) };
}
function canvasToCell(
cx: number,
cy: number,
cam: Camera,
): { x: number; y: number } {
return {
x: Math.floor(cx / cam.zoom + cam.offsetX),
y: Math.floor(cy / cam.zoom + cam.offsetY),
};
}
/**
* Convert a canvas point to the token anchor whose CENTER is closest to the cursor.
* For a size-N token: anchor = round(cursorWorld - N/2).
* For size=1 this is equivalent to canvasToCell (Math.floor).
*/
function canvasToCenterAnchor(
cx: number,
cy: number,
size: number,
cam: Camera,
): { x: number; y: number } {
const worldX = cx / cam.zoom + cam.offsetX;
const worldY = cy / cam.zoom + cam.offsetY;
return {
x: Math.round(worldX - size / 2),
y: Math.round(worldY - size / 2),
};
}
function cellToCanvas(
cellX: number,
cellY: number,
cam: Camera,
): { x: number; y: number } {
return {
x: (cellX - cam.offsetX) * cam.zoom,
y: (cellY - cam.offsetY) * cam.zoom,
};
}
function drawToken(
ctx: CanvasRenderingContext2D,
cellX: number,
cellY: number,
label: string,
color: string,
cam: Camera,
size: number = 1,
) {
const zoom = cam.zoom;
const { x: px, y: py } = cellToCanvas(cellX, cellY, cam);
// Center of the NxN area
const cx = px + (zoom * size) / 2;
const cy = py + (zoom * size) / 2;
const r = zoom * size * 0.38;
ctx.shadowColor = "rgba(0,0,0,0.6)";
ctx.shadowBlur = 5;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.55)";
ctx.lineWidth = Math.max(1, zoom * 0.04);
ctx.stroke();
ctx.shadowBlur = 0;
if (zoom >= 14) {
const words = label.trim().split(/\s+/);
const initials =
words.length >= 2
? (words[0][0] + words[1][0]).toUpperCase()
: label.slice(0, 2).toUpperCase();
ctx.fillStyle = "#ffffff";
ctx.font = `bold ${Math.round(zoom * size * 0.3)}px system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(initials, cx, cy);
}
}
/** Draw a small count badge at the top-right of the token circle. */
function drawStackBadge(
ctx: CanvasRenderingContext2D,
cellX: number,
cellY: number,
count: number,
cam: Camera,
size: number = 1,
) {
const zoom = cam.zoom;
const { x: px, y: py } = cellToCanvas(cellX, cellY, cam);
const tokenR = zoom * size * 0.38;
// Badge centre: top-right of the token circle
const bx = px + (zoom * size) / 2 + tokenR * 0.68;
const by = py + (zoom * size) / 2 - tokenR * 0.68;
const br = Math.max(5, zoom * 0.18);
ctx.beginPath();
ctx.arc(bx, by, br, 0, Math.PI * 2);
ctx.fillStyle = "#ef4444";
ctx.fill();
ctx.strokeStyle = "#1f2937";
ctx.lineWidth = Math.max(1, zoom * 0.025);
ctx.stroke();
if (zoom >= 14) {
ctx.fillStyle = "#ffffff";
ctx.font = `bold ${Math.round(br * 1.2)}px system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(count), bx, by);
}
}
/**
* Calculate movement distance in grid squares using D&D rules.
*/
function calcDndSquares(dx: number, dy: number, rule: MovementRule): number {
const adx = Math.abs(dx);
const ady = Math.abs(dy);
const straight = Math.abs(adx - ady);
const diagonal = Math.min(adx, ady);
if (rule === "free") {
return straight + diagonal;
}
return straight + diagonal + Math.floor(diagonal / 2);
}
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
/**
* BFS flood fill from (startX, startY) through uncolored (empty) cells.
*/
function floodFill(
startX: number,
startY: number,
cells: Map<string, GridCell>,
): Array<{ x: number; y: number }> | null {
const visited = new Set<string>();
const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }];
const found: Array<{ x: number; y: number }> = [];
visited.add(cellKey(startX, startY));
const dirs = [
{ dx: 1, dy: 0 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 },
];
while (queue.length > 0) {
const { x, y } = queue.shift()!;
found.push({ x, y });
if (found.length > MAX_FLOOD_CELLS) return null;
for (const { dx, dy } of dirs) {
const nx = x + dx;
const ny = y + dy;
const key = cellKey(nx, ny);
if (!visited.has(key) && !cells.has(key)) {
visited.add(key);
queue.push({ x: nx, y: ny });
}
}
}
return found;
}
/**
* BFS flood fill from (startX, startY) through cells matching targetColor.
* Used for shift+click color replacement.
*/
function floodFillColored(
startX: number,
startY: number,
targetColor: string,
cells: Map<string, GridCell>,
): Array<{ x: number; y: number }> | null {
const visited = new Set<string>();
const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }];
const found: Array<{ x: number; y: number }> = [];
visited.add(cellKey(startX, startY));
const dirs = [
{ dx: 1, dy: 0 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 },
];
while (queue.length > 0) {
const { x, y } = queue.shift()!;
found.push({ x, y });
if (found.length > MAX_FLOOD_CELLS) return null;
for (const { dx, dy } of dirs) {
const nx = x + dx;
const ny = y + dy;
const key = cellKey(nx, ny);
if (!visited.has(key)) {
const neighbor = cells.get(key);
if (neighbor && neighbor.color === targetColor) {
visited.add(key);
queue.push({ x: nx, y: ny });
}
}
}
}
return found;
}
/**
* Clamp the camera so at least one colored cell or token remains visible.
*/
function clampCameraToContent(
cam: Camera,
cells: Map<string, GridCell>,
tokens: Map<string, GridToken>,
canvasW: number,
canvasH: number,
) {
if (cells.size === 0 && tokens.size === 0) return;
const viewLeft = cam.offsetX;
const viewRight = cam.offsetX + canvasW / cam.zoom;
const viewTop = cam.offsetY;
const viewBottom = cam.offsetY + canvasH / cam.zoom;
let anyVisible = false;
cellLoop: for (const cell of cells.values()) {
if (
cell.x + 1 > viewLeft &&
cell.x < viewRight &&
cell.y + 1 > viewTop &&
cell.y < viewBottom
) {
anyVisible = true;
break cellLoop;
}
}
if (!anyVisible) {
for (const tok of tokens.values()) {
if (
tok.x + 1 > viewLeft &&
tok.x < viewRight &&
tok.y + 1 > viewTop &&
tok.y < viewBottom
) {
anyVisible = true;
break;
}
}
}
if (anyVisible) return;
let minX = Infinity,
maxX = -Infinity;
let minY = Infinity,
maxY = -Infinity;
for (const c of cells.values()) {
if (c.x < minX) minX = c.x;
if (c.x > maxX) maxX = c.x;
if (c.y < minY) minY = c.y;
if (c.y > maxY) maxY = c.y;
}
for (const t of tokens.values()) {
if (t.x < minX) minX = t.x;
if (t.x > maxX) maxX = t.x;
if (t.y < minY) minY = t.y;
if (t.y > maxY) maxY = t.y;
}
const viewW = canvasW / cam.zoom;
const viewH = canvasH / cam.zoom;
if (maxX + 1 <= viewLeft) {
cam.offsetX = maxX;
} else if (minX >= viewRight) {
cam.offsetX = minX - viewW + 1;
}
if (maxY + 1 <= viewTop) {
cam.offsetY = maxY;
} else if (minY >= viewBottom) {
cam.offsetY = minY - viewH + 1;
}
}
const Grid = forwardRef<GridHandle, Props>(function Grid( const Grid = forwardRef<GridHandle, Props>(function Grid(
{ {

View File

@@ -0,0 +1,250 @@
import type { GridCell, GridToken } from "../../types";
import type { Camera } from "./types";
import { cellKey, cellToCanvas } from "./utils";
import { MAX_FLOOD_CELLS } from "./constants";
export function drawToken(
ctx: CanvasRenderingContext2D,
cellX: number,
cellY: number,
label: string,
color: string,
cam: Camera,
size: number = 1,
) {
const zoom = cam.zoom;
const { x: px, y: py } = cellToCanvas(cellX, cellY, cam);
// Center of the NxN area
const cx = px + (zoom * size) / 2;
const cy = py + (zoom * size) / 2;
const r = zoom * size * 0.38;
ctx.shadowColor = "rgba(0,0,0,0.6)";
ctx.shadowBlur = 5;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.55)";
ctx.lineWidth = Math.max(1, zoom * 0.04);
ctx.stroke();
ctx.shadowBlur = 0;
if (zoom >= 14) {
const words = label.trim().split(/\s+/);
const initials =
words.length >= 2
? (words[0][0] + words[1][0]).toUpperCase()
: label.slice(0, 2).toUpperCase();
ctx.fillStyle = "#ffffff";
ctx.font = `bold ${Math.round(zoom * size * 0.3)}px system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(initials, cx, cy);
}
}
/** Draw a small count badge at the top-right of the token circle. */
export function drawStackBadge(
ctx: CanvasRenderingContext2D,
cellX: number,
cellY: number,
count: number,
cam: Camera,
size: number = 1,
) {
const zoom = cam.zoom;
const { x: px, y: py } = cellToCanvas(cellX, cellY, cam);
const tokenR = zoom * size * 0.38;
// Badge centre: top-right of the token circle
const bx = px + (zoom * size) / 2 + tokenR * 0.68;
const by = py + (zoom * size) / 2 - tokenR * 0.68;
const br = Math.max(5, zoom * 0.18);
ctx.beginPath();
ctx.arc(bx, by, br, 0, Math.PI * 2);
ctx.fillStyle = "#ef4444";
ctx.fill();
ctx.strokeStyle = "#1f2937";
ctx.lineWidth = Math.max(1, zoom * 0.025);
ctx.stroke();
if (zoom >= 14) {
ctx.fillStyle = "#ffffff";
ctx.font = `bold ${Math.round(br * 1.2)}px system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(count), bx, by);
}
}
/**
* BFS flood fill from (startX, startY) through uncolored (empty) cells.
*/
export function floodFill(
startX: number,
startY: number,
cells: Map<string, GridCell>,
): Array<{ x: number; y: number }> | null {
const visited = new Set<string>();
const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }];
const found: Array<{ x: number; y: number }> = [];
visited.add(cellKey(startX, startY));
const dirs = [
{ dx: 1, dy: 0 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 },
];
while (queue.length > 0) {
const { x, y } = queue.shift()!;
found.push({ x, y });
if (found.length > MAX_FLOOD_CELLS) return null;
for (const { dx, dy } of dirs) {
const nx = x + dx;
const ny = y + dy;
const key = cellKey(nx, ny);
if (!visited.has(key) && !cells.has(key)) {
visited.add(key);
queue.push({ x: nx, y: ny });
}
}
}
return found;
}
/**
* BFS flood fill from (startX, startY) through cells matching targetColor.
* Used for shift+click color replacement.
*/
export function floodFillColored(
startX: number,
startY: number,
targetColor: string,
cells: Map<string, GridCell>,
): Array<{ x: number; y: number }> | null {
const visited = new Set<string>();
const queue: Array<{ x: number; y: number }> = [{ x: startX, y: startY }];
const found: Array<{ x: number; y: number }> = [];
visited.add(cellKey(startX, startY));
const dirs = [
{ dx: 1, dy: 0 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 },
];
while (queue.length > 0) {
const { x, y } = queue.shift()!;
found.push({ x, y });
if (found.length > MAX_FLOOD_CELLS) return null;
for (const { dx, dy } of dirs) {
const nx = x + dx;
const ny = y + dy;
const key = cellKey(nx, ny);
if (!visited.has(key)) {
const neighbor = cells.get(key);
if (neighbor && neighbor.color === targetColor) {
visited.add(key);
queue.push({ x: nx, y: ny });
}
}
}
}
return found;
}
/**
* Clamp the camera so at least one colored cell or token remains visible.
*/
export function clampCameraToContent(
cam: Camera,
cells: Map<string, GridCell>,
tokens: Map<string, GridToken>,
canvasW: number,
canvasH: number,
) {
if (cells.size === 0 && tokens.size === 0) return;
const viewLeft = cam.offsetX;
const viewRight = cam.offsetX + canvasW / cam.zoom;
const viewTop = cam.offsetY;
const viewBottom = cam.offsetY + canvasH / cam.zoom;
let anyVisible = false;
cellLoop: for (const cell of cells.values()) {
if (
cell.x + 1 > viewLeft &&
cell.x < viewRight &&
cell.y + 1 > viewTop &&
cell.y < viewBottom
) {
anyVisible = true;
break cellLoop;
}
}
if (!anyVisible) {
for (const tok of tokens.values()) {
if (
tok.x + 1 > viewLeft &&
tok.x < viewRight &&
tok.y + 1 > viewTop &&
tok.y < viewBottom
) {
anyVisible = true;
break;
}
}
}
if (anyVisible) return;
let minX = Infinity,
maxX = -Infinity;
let minY = Infinity,
maxY = -Infinity;
for (const c of cells.values()) {
if (c.x < minX) minX = c.x;
if (c.x > maxX) maxX = c.x;
if (c.y < minY) minY = c.y;
if (c.y > maxY) maxY = c.y;
}
for (const t of tokens.values()) {
if (t.x < minX) minX = t.x;
if (t.x > maxX) maxX = t.x;
if (t.y < minY) minY = t.y;
if (t.y > maxY) maxY = t.y;
}
const viewW = canvasW / cam.zoom;
const viewH = canvasH / cam.zoom;
if (maxX + 1 <= viewLeft) {
cam.offsetX = maxX;
} else if (minX >= viewRight) {
cam.offsetX = minX - viewW + 1;
}
if (maxY + 1 <= viewTop) {
cam.offsetY = maxY;
} else if (minY >= viewBottom) {
cam.offsetY = minY - viewH + 1;
}
}

View File

@@ -0,0 +1,34 @@
import type { MovementRule, Tool } from "../../types";
export interface Camera {
offsetX: number;
offsetY: number;
zoom: number;
}
export interface UndoAction {
revert: () => void;
apply: () => void;
}
export interface Props {
mapId: string;
tool: Tool;
paintColor: string;
tokenColor: string;
onColorsLoaded: (colors: string[]) => void;
/** How many real-world units one grid square represents (default 5). */
unitsPerSquare: number;
/** Label for the unit, e.g. "ft" or "m". */
unitLabel: string;
/** Which diagonal movement rule to use. */
movementRule: MovementRule;
/** Called whenever the undo/redo stack availability changes. */
onUndoStateChange?: (canUndo: boolean, canRedo: boolean) => void;
}
export interface GridHandle {
sendColorUpdate: (colors: string[]) => void;
undo: () => void;
redo: () => void;
}

View File

@@ -0,0 +1,74 @@
import type { Camera } from "./types";
import type { MovementRule } from "../../types";
export function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
export function parseCellKey(key: string): { x: number; y: number } {
const [xs, ys] = key.split(",");
return { x: Number(xs), y: Number(ys) };
}
export function canvasToCell(
cx: number,
cy: number,
cam: Camera,
): { x: number; y: number } {
return {
x: Math.floor(cx / cam.zoom + cam.offsetX),
y: Math.floor(cy / cam.zoom + cam.offsetY),
};
}
/**
* Convert a canvas point to the token anchor whose CENTER is closest to the cursor.
* For a size-N token: anchor = round(cursorWorld - N/2).
* For size=1 this is equivalent to canvasToCell (Math.floor).
*/
export function canvasToCenterAnchor(
cx: number,
cy: number,
size: number,
cam: Camera,
): { x: number; y: number } {
const worldX = cx / cam.zoom + cam.offsetX;
const worldY = cy / cam.zoom + cam.offsetY;
return {
x: Math.round(worldX - size / 2),
y: Math.round(worldY - size / 2),
};
}
export function cellToCanvas(
cellX: number,
cellY: number,
cam: Camera,
): { x: number; y: number } {
return {
x: (cellX - cam.offsetX) * cam.zoom,
y: (cellY - cam.offsetY) * cam.zoom,
};
}
export function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
/**
* Calculate movement distance in grid squares using D&D rules.
*/
export function calcDndSquares(
dx: number,
dy: number,
rule: MovementRule,
): number {
const adx = Math.abs(dx);
const ady = Math.abs(dy);
const straight = Math.abs(adx - ady);
const diagonal = Math.min(adx, ady);
if (rule === "free") {
return straight + diagonal;
}
return straight + diagonal + Math.floor(diagonal / 2);
}

View File

@@ -59,7 +59,7 @@ export default function TokenStackPicker({
className="stack-picker-item stack-picker-move-all" className="stack-picker-item stack-picker-move-all"
onClick={onMoveStack} onClick={onMoveStack}
> >
Move entire stack Move stack
</button> </button>
</div> </div>
</> </>