1
This commit is contained in:
276
src/components/puzzle-runtime/puzzleRuntimeShape.ts
Normal file
276
src/components/puzzle-runtime/puzzleRuntimeShape.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user