This commit is contained in:
2026-05-01 20:29:09 +08:00
parent 8718472dbd
commit 87fbf41fab
137 changed files with 2922 additions and 989 deletions

View File

@@ -0,0 +1,276 @@
type PuzzleMergedGroupShape = {
colSpan: number;
rowSpan: number;
pieces: Array<{
localRow: number;
localCol: number;
}>;
};
type GridPoint = {
x: number;
y: number;
};
type GridEdge = {
start: GridPoint;
end: GridPoint;
};
const MERGED_GROUP_OUTLINE_CORNER_RADIUS = 0.16;
function buildLocalCellKey(row: number, col: number) {
return `${row}:${col}`;
}
function formatSvgNumber(value: number) {
const normalizedValue = Object.is(value, -0) ? 0 : value;
return Number(normalizedValue.toFixed(4)).toString();
}
function formatSvgPoint(point: GridPoint) {
return `${formatSvgNumber(point.x)} ${formatSvgNumber(point.y)}`;
}
function gridPointKey(point: GridPoint) {
return `${formatSvgNumber(point.x)}:${formatSvgNumber(point.y)}`;
}
function distanceBetweenGridPoints(first: GridPoint, second: GridPoint) {
return Math.hypot(second.x - first.x, second.y - first.y);
}
function moveGridPointToward(
from: GridPoint,
target: GridPoint,
distance: number,
) {
const fullDistance = distanceBetweenGridPoints(from, target);
if (fullDistance <= 0) {
return from;
}
const ratio = Math.min(1, distance / fullDistance);
return {
x: from.x + (target.x - from.x) * ratio,
y: from.y + (target.y - from.y) * ratio,
};
}
function isCollinearGridCorner(
previous: GridPoint,
current: GridPoint,
next: GridPoint,
) {
return (
(previous.x === current.x && current.x === next.x) ||
(previous.y === current.y && current.y === next.y)
);
}
function removeCollinearGridPoints(points: GridPoint[]) {
if (points.length <= 3) {
return points;
}
return points.filter((point, index) => {
const previous = points[(index - 1 + points.length) % points.length];
const next = points[(index + 1) % points.length];
return previous && next && !isCollinearGridCorner(previous, point, next);
});
}
function buildRoundedGridCyclePath(
points: GridPoint[],
radius: number,
transformPoint: (point: GridPoint) => GridPoint = (point) => point,
) {
const cyclePoints = removeCollinearGridPoints(points);
if (cyclePoints.length < 3) {
return '';
}
const resolveCorner = (index: number) => {
const point = cyclePoints[index];
const previous = cyclePoints[
(index - 1 + cyclePoints.length) % cyclePoints.length
];
const next = cyclePoints[(index + 1) % cyclePoints.length];
if (!point || !previous || !next) {
return null;
}
const safeRadius = Math.min(
radius,
distanceBetweenGridPoints(point, previous) / 2,
distanceBetweenGridPoints(point, next) / 2,
);
return {
point,
entry: moveGridPointToward(point, previous, safeRadius),
exit: moveGridPointToward(point, next, safeRadius),
};
};
const firstCorner = resolveCorner(0);
if (!firstCorner) {
return '';
}
const commands = [`M ${formatSvgPoint(transformPoint(firstCorner.exit))}`];
for (let index = 1; index <= cyclePoints.length; index += 1) {
const corner = resolveCorner(index % cyclePoints.length);
if (!corner) {
continue;
}
commands.push(`L ${formatSvgPoint(transformPoint(corner.entry))}`);
commands.push(
`Q ${formatSvgPoint(transformPoint(corner.point))} ${formatSvgPoint(
transformPoint(corner.exit),
)}`,
);
}
commands.push('Z');
return commands.join(' ');
}
function buildMergedGroupBoundaryCycles(group: PuzzleMergedGroupShape) {
const groupCellKeys = new Set(
group.pieces.map((piece) =>
buildLocalCellKey(piece.localRow, piece.localCol),
),
);
const hasCell = (row: number, col: number) =>
groupCellKeys.has(buildLocalCellKey(row, col));
const edges: GridEdge[] = [];
for (const piece of group.pieces) {
const { localRow: row, localCol: col } = piece;
if (!hasCell(row - 1, col)) {
edges.push({ start: { x: col, y: row }, end: { x: col + 1, y: row } });
}
if (!hasCell(row, col + 1)) {
edges.push({
start: { x: col + 1, y: row },
end: { x: col + 1, y: row + 1 },
});
}
if (!hasCell(row + 1, col)) {
edges.push({
start: { x: col + 1, y: row + 1 },
end: { x: col, y: row + 1 },
});
}
if (!hasCell(row, col - 1)) {
edges.push({ start: { x: col, y: row + 1 }, end: { x: col, y: row } });
}
}
const edgeIndexesByStart = new Map<string, number[]>();
edges.forEach((edge, index) => {
const key = gridPointKey(edge.start);
const indexes = edgeIndexesByStart.get(key) ?? [];
indexes.push(index);
edgeIndexesByStart.set(key, indexes);
});
const unusedEdgeIndexes = new Set(edges.map((_, index) => index));
const cycles: GridPoint[][] = [];
while (unusedEdgeIndexes.size > 0) {
const firstEdgeIndex = unusedEdgeIndexes.values().next().value as
| number
| undefined;
if (firstEdgeIndex === undefined) {
break;
}
const firstEdge = edges[firstEdgeIndex];
if (!firstEdge) {
unusedEdgeIndexes.delete(firstEdgeIndex);
continue;
}
const cycle: GridPoint[] = [firstEdge.start];
let currentEdge = firstEdge;
unusedEdgeIndexes.delete(firstEdgeIndex);
for (let guard = 0; guard < edges.length + 1; guard += 1) {
const currentEnd = currentEdge.end;
const cycleStart = cycle[0];
if (!cycleStart || gridPointKey(currentEnd) === gridPointKey(cycleStart)) {
break;
}
cycle.push(currentEnd);
const nextEdgeIndex = (
edgeIndexesByStart.get(gridPointKey(currentEnd)) ?? []
).find((index) => unusedEdgeIndexes.has(index));
if (nextEdgeIndex === undefined) {
break;
}
const nextEdge = edges[nextEdgeIndex];
if (!nextEdge) {
unusedEdgeIndexes.delete(nextEdgeIndex);
break;
}
currentEdge = nextEdge;
unusedEdgeIndexes.delete(nextEdgeIndex);
}
if (cycle.length >= 3) {
cycles.push(cycle);
}
}
return cycles;
}
export function buildMergedGroupOutlinePath(
group: PuzzleMergedGroupShape,
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
) {
// 合并块的凹入角不能靠单格 border-radius 稳定拼出来,必须先生成整体外轮廓。
return buildMergedGroupBoundaryCycles(group)
.map((cycle) => buildRoundedGridCyclePath(cycle, radius))
.filter(Boolean)
.join(' ');
}
export function buildMergedGroupClipPath(
group: PuzzleMergedGroupShape,
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
) {
return buildMergedGroupBoundaryCycles(group)
.map((cycle) =>
buildRoundedGridCyclePath(cycle, radius, (point) => ({
x: point.x / group.colSpan,
y: point.y / group.rowSpan,
})),
)
.filter(Boolean)
.join(' ');
}
export function sanitizeSvgId(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, '-');
}
export function resolveDraggedPieceCellLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 80;
}
export function resolveDraggedPieceLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 81;
}
export function resolveDraggedMergedGroupLayer(
groupId: string,
draggingGroupId: string | null,
) {
return groupId === draggingGroupId ? 90 : undefined;
}