temp
This commit is contained in:
@@ -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`}
|
||||
>
|
||||
<span className="cp-key-hint">{i + 1}</span>
|
||||
<span className="cp-key-hint">⇧{i + 1}</span>
|
||||
</button>
|
||||
{/* Hidden color picker for this slot */}
|
||||
<input
|
||||
|
||||
@@ -29,27 +29,27 @@ const TOOLS: {
|
||||
id: "pan",
|
||||
icon: <MdPanTool />,
|
||||
title: "Pan – drag to move the map",
|
||||
shortcut: "Shift+1",
|
||||
shortcut: "1",
|
||||
},
|
||||
{
|
||||
id: "zoom",
|
||||
icon: <MdZoomIn />,
|
||||
title: "Zoom – click to zoom in/out",
|
||||
shortcut: "Shift+2",
|
||||
shortcut: "2",
|
||||
},
|
||||
{
|
||||
id: "draw",
|
||||
icon: <MdBrush />,
|
||||
title:
|
||||
"Draw – left-click to paint, right-click to erase, Shift+click to fill/replace",
|
||||
shortcut: "Shift+3",
|
||||
shortcut: "3",
|
||||
},
|
||||
{
|
||||
id: "token",
|
||||
icon: <MdPerson />,
|
||||
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;
|
||||
|
||||
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,
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
export type { GridHandle };
|
||||
|
||||
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"
|
||||
onClick={onMoveStack}
|
||||
>
|
||||
Move entire stack
|
||||
Move stack
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user