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

@@ -6,11 +6,12 @@ import { expect, test, vi } from 'vitest';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
PuzzleRuntimeShell,
buildMergedGroupOutlinePath,
resolveDraggedMergedGroupLayer,
resolveDraggedPieceCellLayer,
resolveDraggedPieceLayer,
} from './PuzzleRuntimeShell';
} from './puzzleRuntimeShape';
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src: string | null) => ({
@@ -450,16 +451,44 @@ test('合并块按实际拼块外轮廓描边', () => {
expect(outlinedPieces).toHaveLength(3);
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
expect(outlinedPieces[0]?.className).toContain('border-r-0');
expect(outlinedPieces[0]?.className).toContain('border-b-0');
expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]');
expect(outlinedPieces[0]?.className).toContain('rounded-br-[0.35rem]');
expect(outlinedPieces[1]?.className).toContain('border-l-0');
expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
expect(outlinedPieces[1]?.className).toContain('rounded-bl-[0.35rem]');
expect(outlinedPieces[2]?.className).toContain('border-t-0');
expect(outlinedPieces[2]?.className).toContain('rounded-tr-[0.35rem]');
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
expect(
container.querySelector('[data-merged-group-outline="true"]'),
).toBeTruthy();
const outlineStroke = container.querySelector(
'[data-merged-group-outline-stroke="true"]',
);
expect(outlineStroke).toBeTruthy();
expect(outlineStroke?.getAttribute('d')).toContain('Q 2 1 1.84 1');
expect(outlineStroke?.getAttribute('d')).toContain('Q 1 1 1 1.16');
expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe('');
const clippedLayer = container.querySelector(
'[style*="clip-path"]',
) as HTMLElement | null;
expect(clippedLayer?.style.clipPath).toContain('url(#');
});
test('合并块轮廓路径为内凹角生成圆角曲线', () => {
const outlinePath = buildMergedGroupOutlinePath({
rowSpan: 2,
colSpan: 2,
pieces: [
{
localRow: 0,
localCol: 0,
},
{
localRow: 0,
localCol: 1,
},
{
localRow: 1,
localCol: 0,
},
],
});
expect(outlinePath).toContain('Q 2 1 1.84 1');
expect(outlinePath).toContain('Q 1 1 1 1.16');
});
test('基础单块使用圆角裁剪图片', () => {
@@ -634,7 +663,7 @@ test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', as
fireEvent.click(screen.getByRole('button', { name: '提示' }));
expect(screen.getByRole('dialog', { name: '使用提示' })).toBeTruthy();
expect(screen.getByText('消耗 1 陶泥币')).toBeTruthy();
expect(screen.getByText('消耗 1 光点')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
await act(async () => {
@@ -651,7 +680,7 @@ test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', as
test('道具使用失败时保留确认弹窗和暂停态', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockRejectedValue(new Error('陶泥币余额不足'));
const onUseProp = vi.fn().mockRejectedValue(new Error('光点余额不足'));
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
@@ -684,7 +713,7 @@ test('道具使用失败时保留确认弹窗和暂停态', async () => {
});
expect(screen.getByRole('dialog', { name: '冻结时间' })).toBeTruthy();
expect(screen.getByText('陶泥币余额不足')).toBeTruthy();
expect(screen.getByText('光点余额不足')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
});
@@ -836,7 +865,7 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
within(failedDialog).getByRole('button', { name: '继续1分钟' }),
);
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('消耗 1 陶泥币')).toBeTruthy();
expect(screen.getByText('消耗 1 光点')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
@@ -846,7 +875,7 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
});
test('失败续时扣费失败时保留确认弹窗', async () => {
const onUseProp = vi.fn().mockRejectedValue(new Error('陶泥币余额不足'));
const onUseProp = vi.fn().mockRejectedValue(new Error('光点余额不足'));
const failedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
@@ -878,7 +907,7 @@ test('失败续时扣费失败时保留确认弹窗', async () => {
});
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('陶泥币余额不足')).toBeTruthy();
expect(screen.getByText('光点余额不足')).toBeTruthy();
});
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {

View File

@@ -9,7 +9,7 @@ import {
Sparkles,
Trophy,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import type {
DragPuzzlePieceRequest,
@@ -27,6 +27,14 @@ import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildMergedGroupClipPath,
buildMergedGroupOutlinePath,
resolveDraggedMergedGroupLayer,
resolveDraggedPieceCellLayer,
resolveDraggedPieceLayer,
sanitizeSvgId,
} from './puzzleRuntimeShape';
type PuzzleRuntimeShellProps = {
run: PuzzleRunSnapshot | null;
@@ -85,127 +93,6 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
}));
}
function buildLocalCellKey(row: number, col: number) {
return `${row}:${col}`;
}
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;
}
function resolveMergedPieceOutlineClass(
group: PuzzleMergedGroupViewModel,
piece: PuzzleMergedGroupViewModel['pieces'][number],
) {
const groupCellKeys = new Set(
group.pieces.map((groupPiece) =>
buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
),
);
const hasCell = (row: number, col: number) =>
groupCellKeys.has(buildLocalCellKey(row, col));
const hasTopBoundary = (row: number, col: number) => !hasCell(row - 1, col);
const hasRightBoundary = (row: number, col: number) => !hasCell(row, col + 1);
const hasBottomBoundary = (row: number, col: number) =>
!hasCell(row + 1, col);
const hasLeftBoundary = (row: number, col: number) => !hasCell(row, col - 1);
const hasTopEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow - 1, piece.localCol),
);
const hasRightEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow, piece.localCol + 1),
);
const hasBottomEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow + 1, piece.localCol),
);
const hasLeftEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow, piece.localCol - 1),
);
const topLeftRadius =
hasTopEdge && hasLeftEdge
? 'rounded-tl-[0.85rem]'
: (!hasTopEdge && !hasLeftEdge) ||
(hasTopEdge &&
!hasLeftEdge &&
!hasTopBoundary(piece.localRow, piece.localCol - 1)) ||
(hasLeftEdge &&
!hasTopEdge &&
!hasLeftBoundary(piece.localRow - 1, piece.localCol))
? 'rounded-tl-[0.35rem]'
: 'rounded-tl-none';
const topRightRadius =
hasTopEdge && hasRightEdge
? 'rounded-tr-[0.85rem]'
: (!hasTopEdge && !hasRightEdge) ||
(hasTopEdge &&
!hasRightEdge &&
!hasTopBoundary(piece.localRow, piece.localCol + 1)) ||
(hasRightEdge &&
!hasTopEdge &&
!hasRightBoundary(piece.localRow - 1, piece.localCol))
? 'rounded-tr-[0.35rem]'
: 'rounded-tr-none';
const bottomRightRadius =
hasBottomEdge && hasRightEdge
? 'rounded-br-[0.85rem]'
: (!hasBottomEdge && !hasRightEdge) ||
(hasBottomEdge &&
!hasRightEdge &&
!hasBottomBoundary(piece.localRow, piece.localCol + 1)) ||
(hasRightEdge &&
!hasBottomEdge &&
!hasRightBoundary(piece.localRow + 1, piece.localCol))
? 'rounded-br-[0.35rem]'
: 'rounded-br-none';
const bottomLeftRadius =
hasBottomEdge && hasLeftEdge
? 'rounded-bl-[0.85rem]'
: (!hasBottomEdge && !hasLeftEdge) ||
(hasBottomEdge &&
!hasLeftEdge &&
!hasBottomBoundary(piece.localRow, piece.localCol - 1)) ||
(hasLeftEdge &&
!hasBottomEdge &&
!hasLeftBoundary(piece.localRow + 1, piece.localCol))
? 'rounded-bl-[0.35rem]'
: 'rounded-bl-none';
return [
hasTopEdge ? 'border-t-2' : 'border-t-0',
hasRightEdge ? 'border-r-2' : 'border-r-0',
hasBottomEdge ? 'border-b-2' : 'border-b-0',
hasLeftEdge ? 'border-l-2' : 'border-l-0',
topLeftRadius,
topRightRadius,
bottomRightRadius,
bottomLeftRadius,
].join(' ');
}
function buildMergedGroupViewModels(
groups: PuzzleMergedGroupState[],
pieces: PuzzleBoardPieceViewModel[],
@@ -372,6 +259,7 @@ export function PuzzleRuntimeShell({
onUseProp,
onTimeExpired,
}: PuzzleRuntimeShellProps) {
const mergedGroupSvgIdPrefix = sanitizeSvgId(useId());
const authUi = useAuthUi();
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
@@ -1315,100 +1203,150 @@ export function PuzzleRuntimeShell({
</div>
);
})}
{mergedGroups.map((group) => (
<div
key={group.groupId}
ref={(node) => {
if (node) {
groupElementRefMap.current.set(group.groupId, node);
return;
}
groupElementRefMap.current.delete(group.groupId);
}}
data-merged-group-id={group.groupId}
className="pointer-events-none absolute z-10"
style={{
zIndex: resolveDraggedMergedGroupLayer(
group.groupId,
draggingGroupId,
),
transform: hintDemo?.pieceIds.some((pieceId) =>
group.pieceIds.includes(pieceId),
)
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.02)`
: undefined,
transition: hintDemo?.pieceIds.some((pieceId) =>
group.pieceIds.includes(pieceId),
)
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
: undefined,
left: `${(group.minCol / board.cols) * 100}%`,
top: `${(group.minRow / board.rows) * 100}%`,
width: `${(group.colSpan / board.cols) * 100}%`,
height: `${(group.rowSpan / board.rows) * 100}%`,
}}
>
{mergedGroups.map((group) => {
const outlinePath = buildMergedGroupOutlinePath(group);
const clipPath = buildMergedGroupClipPath(group);
return (
<div
className="pointer-events-none relative grid h-full w-full touch-none overflow-visible active:scale-[0.992]"
key={group.groupId}
ref={(node) => {
if (node) {
groupElementRefMap.current.set(group.groupId, node);
return;
}
groupElementRefMap.current.delete(group.groupId);
}}
data-merged-group-id={group.groupId}
className="pointer-events-none absolute z-10"
style={{
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
zIndex: resolveDraggedMergedGroupLayer(
group.groupId,
draggingGroupId,
),
transform: hintDemo?.pieceIds.some((pieceId) =>
group.pieceIds.includes(pieceId),
)
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.02)`
: undefined,
transition: hintDemo?.pieceIds.some((pieceId) =>
group.pieceIds.includes(pieceId),
)
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
: undefined,
left: `${(group.minCol / board.cols) * 100}%`,
top: `${(group.minRow / board.rows) * 100}%`,
width: `${(group.colSpan / board.cols) * 100}%`,
height: `${(group.rowSpan / board.rows) * 100}%`,
}}
>
{group.pieces.map((piece) => (
<div
key={piece.pieceId}
className={`pointer-events-auto relative touch-none overflow-hidden border-white/22 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
group,
piece,
)}`}
data-merged-piece-outline="true"
style={{
gridColumn: piece.localCol + 1,
gridRow: piece.localRow + 1,
}}
onPointerDown={(event) => {
handlePiecePointerDown(piece.pieceId, event);
}}
onPointerMove={(event) => {
handlePiecePointerMove(piece.pieceId, event);
}}
onPointerUp={(event) => {
handlePiecePointerUp(piece.pieceId, event);
}}
onPointerCancel={() => {
resetDragInteraction();
}}
onLostPointerCapture={() => {
resetDragInteraction();
}}
{outlinePath ? (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-20 h-full w-full overflow-visible"
preserveAspectRatio="none"
viewBox={`0 0 ${group.colSpan} ${group.rowSpan}`}
>
{resolvedCoverImage ? (
<div
className="absolute inset-0"
style={{
backgroundImage: `url("${resolvedCoverImage}")`,
backgroundSize: `${board.cols * 100}% ${board.rows * 100}%`,
backgroundPosition: `${
board.cols > 1
? (piece.correctCol / (board.cols - 1)) * 100
: 0
}% ${
board.rows > 1
? (piece.correctRow / (board.rows - 1)) * 100
: 0
}%`,
}}
/>
) : (
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(52,211,153,0.38),rgba(6,78,59,0.68))]" />
)}
<div className="absolute inset-0 bg-black/8" />
</div>
))}
<defs>
<clipPath
clipPathUnits="objectBoundingBox"
id={`${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
group.groupId,
)}`}
>
<path
clipRule="evenodd"
d={clipPath}
fillRule="evenodd"
/>
</clipPath>
</defs>
<path
d={outlinePath}
data-merged-group-outline="true"
fill="rgba(52, 211, 153, 0.08)"
fillRule="evenodd"
/>
<path
d={outlinePath}
data-merged-group-outline-stroke="true"
fill="none"
stroke="rgba(255, 255, 255, 0.22)"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
) : null}
<div
className="pointer-events-none relative z-10 grid h-full w-full touch-none overflow-hidden active:scale-[0.992]"
style={{
WebkitClipPath: outlinePath
? `url(#${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
group.groupId,
)})`
: undefined,
clipPath: outlinePath
? `url(#${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
group.groupId,
)})`
: undefined,
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
}}
>
{group.pieces.map((piece) => (
<div
key={piece.pieceId}
className="pointer-events-auto relative touch-none overflow-hidden bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)]"
data-merged-piece-outline="true"
style={{
gridColumn: piece.localCol + 1,
gridRow: piece.localRow + 1,
}}
onPointerDown={(event) => {
handlePiecePointerDown(piece.pieceId, event);
}}
onPointerMove={(event) => {
handlePiecePointerMove(piece.pieceId, event);
}}
onPointerUp={(event) => {
handlePiecePointerUp(piece.pieceId, event);
}}
onPointerCancel={() => {
resetDragInteraction();
}}
onLostPointerCapture={() => {
resetDragInteraction();
}}
>
{resolvedCoverImage ? (
<div
className="absolute inset-0"
style={{
backgroundImage: `url("${resolvedCoverImage}")`,
backgroundSize: `${board.cols * 100}% ${board.rows * 100}%`,
backgroundPosition: `${
board.cols > 1
? (piece.correctCol / (board.cols - 1)) * 100
: 0
}% ${
board.rows > 1
? (piece.correctRow / (board.rows - 1)) * 100
: 0
}%`,
}}
/>
) : (
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(52,211,153,0.38),rgba(6,78,59,0.68))]" />
)}
<div className="absolute inset-0 bg-black/8" />
</div>
))}
</div>
</div>
</div>
))}
);
})}
{isOriginalOverlayVisible && resolvedCoverImage ? (
<div
data-testid="puzzle-original-overlay"
@@ -1567,7 +1505,7 @@ export function PuzzleRuntimeShell({
</h2>
</header>
<div className="px-5 py-4 text-sm text-white/72">
1
1
{propConfirmError ? (
<div className="mt-3 rounded-[0.9rem] border border-red-300/20 bg-red-500/12 px-3 py-2 text-xs leading-5 text-red-100">
{propConfirmError}

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;
}