temp
This commit is contained in:
@@ -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 1–9 select the corresponding color slot (plain, no shift)
|
// Shift+1–9 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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
17
ui/src/components/Grid/constants.ts
Normal file
17
ui/src/components/Grid/constants.ts
Normal 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;
|
||||||
@@ -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(
|
||||||
{
|
{
|
||||||
250
ui/src/components/Grid/render.ts
Normal file
250
ui/src/components/Grid/render.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
ui/src/components/Grid/types.ts
Normal file
34
ui/src/components/Grid/types.ts
Normal 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;
|
||||||
|
}
|
||||||
74
ui/src/components/Grid/utils.ts
Normal file
74
ui/src/components/Grid/utils.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user