From ce69b7f771f24ea0665da72a4e72622bcaac6b67 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Tue, 19 May 2026 14:28:40 -0400 Subject: [PATCH] temp --- ui/src/components/ColorPanel.tsx | 11 +- ui/src/components/ControlPanel.tsx | 16 +- ui/src/components/{ => Grid}/Grid.css | 0 ui/src/components/Grid/constants.ts | 17 + .../components/{Grid.tsx => Grid/index.tsx} | 407 ++---------------- ui/src/components/Grid/render.ts | 250 +++++++++++ ui/src/components/Grid/types.ts | 34 ++ ui/src/components/Grid/utils.ts | 74 ++++ ui/src/components/TokenStackPicker.tsx | 2 +- 9 files changed, 421 insertions(+), 390 deletions(-) rename ui/src/components/{ => Grid}/Grid.css (100%) create mode 100644 ui/src/components/Grid/constants.ts rename ui/src/components/{Grid.tsx => Grid/index.tsx} (85%) create mode 100644 ui/src/components/Grid/render.ts create mode 100644 ui/src/components/Grid/types.ts create mode 100644 ui/src/components/Grid/utils.ts diff --git a/ui/src/components/ColorPanel.tsx b/ui/src/components/ColorPanel.tsx index 8430977..0265e41 100644 --- a/ui/src/components/ColorPanel.tsx +++ b/ui/src/components/ColorPanel.tsx @@ -17,16 +17,17 @@ export default function ColorPanel({ // One hidden color input ref per slot const inputRefs = useRef<(HTMLInputElement | null)[]>([]); - // Keys 1–9 select the corresponding color slot (plain, no shift) + // Shift+1–9 select the corresponding color slot useEffect(() => { 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 ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement ) 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) { onColorChange(colors[num - 1]); } @@ -57,9 +58,9 @@ export default function ColorPanel({ style={{ background: c }} onClick={() => onColorChange(c)} onDoubleClick={() => handleDoubleClick(i)} - title={`${c} (${i + 1}) — double-click to edit`} + title={`${c} (Shift+${i + 1}) — double-click to edit`} > - {i + 1} + ⇧{i + 1} {/* Hidden color picker for this slot */} , title: "Pan – drag to move the map", - shortcut: "Shift+1", + shortcut: "1", }, { id: "zoom", icon: , title: "Zoom – click to zoom in/out", - shortcut: "Shift+2", + shortcut: "2", }, { id: "draw", icon: , title: "Draw – left-click to paint, right-click to erase, Shift+click to fill/replace", - shortcut: "Shift+3", + shortcut: "3", }, { id: "token", icon: , title: "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, onRedo, }: Props) { - // Keyboard shortcuts: Shift+1/2/3/4 for tools + // Keyboard shortcuts: 1/2/3/4 for tools useEffect(() => { const handler = (e: KeyboardEvent) => { if ( @@ -69,21 +69,17 @@ export default function ControlPanel({ e.target instanceof HTMLTextAreaElement ) return; - if (!e.shiftKey) return; + if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; switch (e.key) { - case "!": // Shift+1 on many layouts case "1": onToolChange("pan"); break; - case "@": // Shift+2 case "2": onToolChange("zoom"); break; - case "#": // Shift+3 case "3": onToolChange("draw"); break; - case "$": // Shift+4 case "4": onToolChange("token"); break; diff --git a/ui/src/components/Grid.css b/ui/src/components/Grid/Grid.css similarity index 100% rename from ui/src/components/Grid.css rename to ui/src/components/Grid/Grid.css diff --git a/ui/src/components/Grid/constants.ts b/ui/src/components/Grid/constants.ts new file mode 100644 index 0000000..25b163d --- /dev/null +++ b/ui/src/components/Grid/constants.ts @@ -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; diff --git a/ui/src/components/Grid.tsx b/ui/src/components/Grid/index.tsx similarity index 85% rename from ui/src/components/Grid.tsx rename to ui/src/components/Grid/index.tsx index d4ac32b..ba5875d 100644 --- a/ui/src/components/Grid.tsx +++ b/ui/src/components/Grid/index.tsx @@ -6,383 +6,42 @@ import { forwardRef, useImperativeHandle, } from "react"; -import type { - GridCell, - GridToken, - Tool, - MovementRule, - ServerMessage, - ClientMessage, -} from "../types"; -import { useWebSocket } from "../hooks/useWebSocket"; -import TokenDialog from "./TokenDialog"; -import TokenStackPicker from "./TokenStackPicker"; +import type { GridCell, GridToken, ServerMessage, ClientMessage } from "../../types"; +import { useWebSocket } from "../../hooks/useWebSocket"; +import TokenDialog from "../TokenDialog"; +import TokenStackPicker from "../TokenStackPicker"; import "./Grid.css"; -const DEFAULT_ZOOM = 40; -const MIN_ZOOM = 8; -const MAX_ZOOM = 160; -const ZOOM_STEP = 1.12; +import { + DEFAULT_ZOOM, + MIN_ZOOM, + 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"; -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, -): Array<{ x: number; y: number }> | null { - const visited = new Set(); - 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, -): Array<{ x: number; y: number }> | null { - const visited = new Set(); - 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, - tokens: Map, - 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; - } -} +export type { GridHandle }; const Grid = forwardRef(function Grid( { diff --git a/ui/src/components/Grid/render.ts b/ui/src/components/Grid/render.ts new file mode 100644 index 0000000..74e95df --- /dev/null +++ b/ui/src/components/Grid/render.ts @@ -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, +): Array<{ x: number; y: number }> | null { + const visited = new Set(); + 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, +): Array<{ x: number; y: number }> | null { + const visited = new Set(); + 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, + tokens: Map, + 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; + } +} diff --git a/ui/src/components/Grid/types.ts b/ui/src/components/Grid/types.ts new file mode 100644 index 0000000..2960586 --- /dev/null +++ b/ui/src/components/Grid/types.ts @@ -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; +} diff --git a/ui/src/components/Grid/utils.ts b/ui/src/components/Grid/utils.ts new file mode 100644 index 0000000..65e2052 --- /dev/null +++ b/ui/src/components/Grid/utils.ts @@ -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); +} diff --git a/ui/src/components/TokenStackPicker.tsx b/ui/src/components/TokenStackPicker.tsx index f3135a3..4f64fbe 100644 --- a/ui/src/components/TokenStackPicker.tsx +++ b/ui/src/components/TokenStackPicker.tsx @@ -59,7 +59,7 @@ export default function TokenStackPicker({ className="stack-picker-item stack-picker-move-all" onClick={onMoveStack} > - Move entire stack + Move stack