1
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
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