Match3D & Puzzle: runtime UI, assets, drag fix
Backend: stop treating background music as a required draft asset and remove auto-submit/plan for background music; load persisted generated UI/assets into Match3D agent session responses (added helpers to resolve profile id and fetch existing generated assets). Frontend: make Match3D result preview reuse runtime UI styles, unify runtime settings entry, update PuzzleRuntime to apply immediate pointermove transforms (disable drag transition), use SVG clipPath for merged piece rounding, ensure PuzzleRuntimeShell supplies platform theme classes, and adjust related tests. Docs & logs: update decision log, pitfalls and product docs to reflect these changes.
This commit is contained in:
@@ -168,6 +168,7 @@ test('拼图界面不调用 mocap,也不渲染 mocap 光标或调试面板', (
|
||||
});
|
||||
|
||||
test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const onDragPiece = vi.fn();
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -207,6 +208,11 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
const requestAnimationFrame = vi.fn(() => 1);
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: requestAnimationFrame,
|
||||
});
|
||||
board.getBoundingClientRect = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -233,6 +239,9 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
clientY: 70,
|
||||
});
|
||||
});
|
||||
expect(piece.style.transform).toBe('translate3d(30px, 30px, 0) scale(1.03)');
|
||||
expect(piece.style.transition).toBe('none');
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 11,
|
||||
@@ -252,11 +261,14 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
expect(onDragPiece).toHaveBeenCalledWith(
|
||||
expect.objectContaining({pieceId: 'piece-0'}),
|
||||
);
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalRequestAnimationFrame,
|
||||
});
|
||||
});
|
||||
|
||||
test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
const onDragPiece = vi.fn();
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -289,16 +301,7 @@ test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 1),
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
const { container, unmount } = renderPuzzleRuntime(
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
@@ -346,6 +349,13 @@ test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
clientY: 210,
|
||||
});
|
||||
});
|
||||
const mergedGroup = container.querySelector(
|
||||
'[data-merged-group-id="group-large"]',
|
||||
) as HTMLElement | null;
|
||||
expect(mergedGroup?.style.transform).toBe(
|
||||
'translate3d(150px, 150px, 0) scale(1.02)',
|
||||
);
|
||||
expect(mergedGroup?.style.transition).toBe('none');
|
||||
act(() => {
|
||||
dispatchPointerEvent(mergedPiece, 'pointerup', {
|
||||
pointerId: 12,
|
||||
@@ -360,16 +370,6 @@ test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
targetRow: 2,
|
||||
targetCol: 2,
|
||||
});
|
||||
|
||||
unmount();
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalRequestAnimationFrame,
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalCancelAnimationFrame,
|
||||
});
|
||||
});
|
||||
|
||||
test('拖拽合并大块时底层单格不显示选中色块', () => {
|
||||
@@ -483,6 +483,78 @@ test('拖拽合并大块时底层单格不显示选中色块', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('拖动拼图片时不显示已选择状态', () => {
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const board = container.querySelector(
|
||||
'[data-testid="puzzle-board"]',
|
||||
) as HTMLElement | null;
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
board.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
const piece = container.querySelector(
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
if (!piece) {
|
||||
throw new Error('缺少测试拼图片');
|
||||
}
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerdown', {
|
||||
pointerId: 14,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText('已选择')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 14,
|
||||
clientX: 70,
|
||||
clientY: 70,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByText('已选择')).toBeNull();
|
||||
expect(piece.className).not.toContain('puzzle-runtime-piece--selected');
|
||||
});
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
@@ -842,7 +914,11 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
||||
authValue,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));
|
||||
const settingsButton = screen.getByRole('button', { name: '打开拼图设置' });
|
||||
expect(settingsButton.querySelector('img')).toBeNull();
|
||||
expect(settingsButton.querySelector('svg')).toBeTruthy();
|
||||
|
||||
fireEvent.click(settingsButton);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '拼图设置' });
|
||||
const slider = within(dialog).getByRole('slider', { name: '拼图音乐音量' });
|
||||
@@ -852,6 +928,44 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
||||
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
|
||||
});
|
||||
|
||||
test('拼图设置面板展示进行中关卡的实时当前用时', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-15T08:00:10.000Z'));
|
||||
const startedAtMs = Date.now() - 8_500;
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
clearedLevelCount: 0,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
remainingMs: 291_500,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '拼图设置' });
|
||||
expect(within(dialog).getByText('0:08.50')).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('推荐页嵌入拼图时隐藏返回和设置里的退出入口', () => {
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
@@ -926,17 +1040,37 @@ test('拼图运行态主体使用主题语义类承接明暗主题', () => {
|
||||
expect(container.firstElementChild?.className).toContain(
|
||||
'puzzle-runtime-shell',
|
||||
);
|
||||
expect(container.firstElementChild?.className).toContain(
|
||||
'platform-theme--light',
|
||||
);
|
||||
expect(container.querySelector('.puzzle-runtime-pill')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('拼图直达页在没有外层主题壳时也会自行补齐平台主题类', () => {
|
||||
const { container } = render(
|
||||
<PuzzleRuntimeShell
|
||||
run={null}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstElementChild?.className).toContain(
|
||||
'platform-theme--light',
|
||||
);
|
||||
});
|
||||
|
||||
test('合并块不叠加可见轮廓和单块阴影', () => {
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
board: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
coverImageSrc: '/puzzle.png',
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [
|
||||
@@ -974,26 +1108,33 @@ test('合并块不叠加可见轮廓和单块阴影', () => {
|
||||
|
||||
expect(outlinedPieces).toHaveLength(3);
|
||||
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-merged-group-outline="true"]'),
|
||||
).toBeNull();
|
||||
const outlineStroke = container.querySelector(
|
||||
'[data-merged-group-outline-stroke="true"]',
|
||||
const mergedGroupClipLayer = container.querySelector(
|
||||
'[data-merged-group-id="group-l"] [data-merged-group-clip="true"]',
|
||||
) as SVGSVGElement | null;
|
||||
const mergedGroupClipPath = mergedGroupClipLayer?.querySelector('path');
|
||||
const mergedPieceVisuals = container.querySelectorAll(
|
||||
'[data-merged-piece-visual="true"]',
|
||||
);
|
||||
expect(outlineStroke).toBeNull();
|
||||
expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe('');
|
||||
expect(mergedGroupClipLayer?.tagName.toLowerCase()).toBe('svg');
|
||||
expect(mergedGroupClipLayer?.getAttribute('viewBox')).toBe('0 0 2 2');
|
||||
expect(mergedGroupClipPath?.getAttribute('d')).toContain('Q 2 1 1.84 1');
|
||||
expect(mergedGroupClipPath?.getAttribute('d')).toContain('Q 1 1 1 1.192');
|
||||
expect(mergedPieceVisuals).toHaveLength(3);
|
||||
const mergedImageSlices = mergedGroupClipLayer?.querySelectorAll('image');
|
||||
expect(mergedImageSlices).toHaveLength(3);
|
||||
expect(mergedImageSlices?.[0]?.getAttribute('href')).toBe('/puzzle.png');
|
||||
expect(mergedImageSlices?.[0]?.getAttribute('width')).toBe('3');
|
||||
expect(mergedImageSlices?.[0]?.getAttribute('height')).toBe('3');
|
||||
for (const outlinedPiece of outlinedPieces) {
|
||||
const outlinedPieceElement = outlinedPiece as HTMLElement;
|
||||
expect(outlinedPieceElement.style.clipPath).toBe('');
|
||||
expect(outlinedPieceElement.querySelector('.absolute.inset-0')).toBeNull();
|
||||
expect(outlinedPieceElement.className).not.toContain('bg-emerald-300/10');
|
||||
expect(outlinedPieceElement.className).not.toContain('shadow-[');
|
||||
expect(
|
||||
outlinedPieceElement.querySelector('.absolute.inset-0.bg-black\\/8'),
|
||||
).toBeNull();
|
||||
}
|
||||
const clippedLayer = container.querySelector(
|
||||
'[style*="clip-path"]',
|
||||
) as HTMLElement | null;
|
||||
expect(clippedLayer).toBeNull();
|
||||
});
|
||||
|
||||
test('合并块轮廓路径为内凹角生成圆角曲线', () => {
|
||||
@@ -1017,10 +1158,10 @@ test('合并块轮廓路径为内凹角生成圆角曲线', () => {
|
||||
});
|
||||
|
||||
expect(outlinePath).toContain('Q 2 1 1.84 1');
|
||||
expect(outlinePath).toContain('Q 1 1 1 1.16');
|
||||
expect(outlinePath).toContain('Q 1 1 1 1.192');
|
||||
});
|
||||
|
||||
test('基础单块不叠加边框圆角或图片蒙版', () => {
|
||||
test('基础单块使用圆角裁切且不叠加图片蒙版', () => {
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
@@ -1050,7 +1191,7 @@ test('基础单块不叠加边框圆角或图片蒙版', () => {
|
||||
) as HTMLElement | null;
|
||||
expect(basePiece?.className).toContain('overflow-hidden');
|
||||
expect(basePiece?.className).toContain('border-0');
|
||||
expect(basePiece?.className).not.toContain('rounded-[0.85rem]');
|
||||
expect(basePiece?.style.clipPath).toContain('url(#');
|
||||
expect(basePiece?.className).not.toContain('border-2');
|
||||
expect(basePiece?.querySelector('.puzzle-runtime-piece-overlay')).toBeNull();
|
||||
});
|
||||
@@ -1442,7 +1583,7 @@ test('失败续时扣费失败时保留确认弹窗', async () => {
|
||||
expect(screen.getByText('泥点余额不足')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {
|
||||
test('查看原图显示独立原图层并在关闭后恢复计时', async () => {
|
||||
const onPauseChange = vi.fn();
|
||||
const onUseProp = vi.fn().mockResolvedValue(clearedRun);
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
@@ -1478,12 +1619,21 @@ test('查看原图开关打开覆盖层并在关闭后恢复计时', async () =>
|
||||
});
|
||||
|
||||
expect(onUseProp).toHaveBeenCalledWith('reference');
|
||||
expect(screen.getByTestId('puzzle-original-overlay')).toBeTruthy();
|
||||
const originalViewer = screen.getByTestId('puzzle-original-viewer');
|
||||
const board = screen.getByTestId('puzzle-board');
|
||||
expect(originalViewer).toBeTruthy();
|
||||
expect(originalViewer.parentElement).not.toBe(board);
|
||||
expect(
|
||||
within(originalViewer)
|
||||
.getByRole('img', { name: '潮雾拼图 原图' })
|
||||
.getAttribute('src'),
|
||||
).toBe('/puzzle.png');
|
||||
expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '原图' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭原图' }));
|
||||
|
||||
expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull();
|
||||
expect(screen.queryByTestId('puzzle-original-viewer')).toBeNull();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
Eye,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Settings,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
@@ -39,14 +41,15 @@ import {
|
||||
playRuntimeLevelClearSound,
|
||||
resolveRuntimeCountdownSecondBucket,
|
||||
} from '../../services/runtimeAudioFeedback';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildMergedGroupOutlinePath,
|
||||
buildRoundedGridCellClipPath,
|
||||
resolveDraggedMergedGroupLayer,
|
||||
resolveDraggedPieceCellLayer,
|
||||
resolveDraggedPieceLayer,
|
||||
sanitizeSvgId,
|
||||
} from './puzzleRuntimeShape';
|
||||
|
||||
type PuzzleRuntimeShellProps = {
|
||||
@@ -215,6 +218,25 @@ function resolveRuntimeRemainingMs(
|
||||
return Math.max(0, timeLimitMs - effectiveElapsedMs);
|
||||
}
|
||||
|
||||
function resolveRuntimeElapsedMs(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
uiPauseStartedAtMs: number | null,
|
||||
) {
|
||||
// 进行中关卡的 elapsedMs 只在通关结算后写入,设置面板需要实时派生。
|
||||
if (level.status !== 'playing') {
|
||||
return level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs);
|
||||
}
|
||||
|
||||
const timeLimitMs = level.timeLimitMs || level.remainingMs;
|
||||
const remainingMs = resolveRuntimeRemainingMs(
|
||||
level,
|
||||
nowMs,
|
||||
uiPauseStartedAtMs,
|
||||
);
|
||||
return Math.max(0, timeLimitMs - remainingMs);
|
||||
}
|
||||
|
||||
const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6;
|
||||
const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
|
||||
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
|
||||
@@ -341,6 +363,7 @@ export function PuzzleRuntimeShell({
|
||||
onUseProp,
|
||||
onTimeExpired,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const runtimeSvgClipId = useId();
|
||||
const authUi = useAuthUi();
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const selectedPieceIdRef = useRef<string | null>(null);
|
||||
@@ -351,7 +374,7 @@ export function PuzzleRuntimeShell({
|
||||
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
|
||||
null,
|
||||
);
|
||||
const [isOriginalOverlayVisible, setIsOriginalOverlayVisible] =
|
||||
const [isOriginalImageViewerVisible, setIsOriginalImageViewerVisible] =
|
||||
useState(false);
|
||||
const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false);
|
||||
const [isPropConfirming, setIsPropConfirming] = useState(false);
|
||||
@@ -384,8 +407,6 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
} | null>(null);
|
||||
const dragVisualFrameRef = useRef<number | null>(null);
|
||||
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pieceCellElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
@@ -415,13 +436,23 @@ export function PuzzleRuntimeShell({
|
||||
const displayRemainingMs = currentLevel
|
||||
? resolveRuntimeRemainingMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
|
||||
: 0;
|
||||
const displayElapsedMs = currentLevel
|
||||
? resolveRuntimeElapsedMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
|
||||
: 0;
|
||||
const runtimeStatus = currentLevel
|
||||
? currentLevel.status === 'playing' && displayRemainingMs <= 0
|
||||
? 'failed'
|
||||
: currentLevel.status
|
||||
: 'playing';
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
const isInteractionLocked =
|
||||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||||
isBusy ||
|
||||
runtimeStatus !== 'playing' ||
|
||||
Boolean(propDialog) ||
|
||||
isOriginalImageViewerVisible;
|
||||
const clearResultKey = currentLevel
|
||||
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
||||
: null;
|
||||
@@ -524,6 +555,10 @@ export function PuzzleRuntimeShell({
|
||||
() => new Map(pieces.map((piece) => [piece.pieceId, piece])),
|
||||
[pieces],
|
||||
);
|
||||
const singlePieceClipId = sanitizeSvgId(
|
||||
`puzzle-single-piece-${runtimeSvgClipId}`,
|
||||
);
|
||||
const singlePieceClipUrl = `url(#${singlePieceClipId})`;
|
||||
|
||||
useEffect(() => {
|
||||
const signature =
|
||||
@@ -601,6 +636,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceElement.style.willChange = '';
|
||||
pieceElement.style.zIndex = '';
|
||||
pieceElement.style.opacity = '';
|
||||
pieceElement.style.transition = '';
|
||||
}
|
||||
|
||||
if (dragVisualTarget.groupId) {
|
||||
@@ -612,23 +648,14 @@ export function PuzzleRuntimeShell({
|
||||
groupElement.style.willChange = '';
|
||||
groupElement.style.zIndex = '';
|
||||
groupElement.style.opacity = '';
|
||||
groupElement.style.transition = '';
|
||||
}
|
||||
}
|
||||
|
||||
dragVisualTargetRef.current = null;
|
||||
};
|
||||
|
||||
const cancelDragVisualFrame = () => {
|
||||
if (dragVisualFrameRef.current === null) {
|
||||
return;
|
||||
}
|
||||
window.cancelAnimationFrame(dragVisualFrameRef.current);
|
||||
dragVisualFrameRef.current = null;
|
||||
};
|
||||
|
||||
const resetDragInteractionState = () => {
|
||||
cancelDragVisualFrame();
|
||||
dragOffsetRef.current = null;
|
||||
dragSessionRef.current = null;
|
||||
draggingTargetRef.current = null;
|
||||
resetDragVisualTarget();
|
||||
@@ -639,7 +666,6 @@ export function PuzzleRuntimeShell({
|
||||
};
|
||||
|
||||
const flushDragVisual = () => {
|
||||
dragVisualFrameRef.current = null;
|
||||
const dragSession = dragSessionRef.current;
|
||||
if (!dragSession || !dragSession.dragging) {
|
||||
resetDragVisualTarget();
|
||||
@@ -653,28 +679,10 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: dragSession.pieceId,
|
||||
groupId,
|
||||
};
|
||||
const previousTarget = dragVisualTargetRef.current;
|
||||
if (
|
||||
previousTarget &&
|
||||
(previousTarget.pieceId !== nextTarget.pieceId ||
|
||||
previousTarget.groupId !== nextTarget.groupId)
|
||||
) {
|
||||
resetDragVisualTarget();
|
||||
}
|
||||
dragVisualTargetRef.current = nextTarget;
|
||||
setDragRenderTarget((currentTarget) => {
|
||||
if (
|
||||
currentTarget?.pieceId === nextTarget.pieceId &&
|
||||
currentTarget.groupId === nextTarget.groupId
|
||||
) {
|
||||
return currentTarget;
|
||||
}
|
||||
return nextTarget;
|
||||
});
|
||||
|
||||
const offsetX = dragSession.currentX - dragSession.startX;
|
||||
const offsetY = dragSession.currentY - dragSession.startY;
|
||||
dragOffsetRef.current = { x: offsetX, y: offsetY };
|
||||
|
||||
if (groupId) {
|
||||
const groupElement = groupElementRefMap.current.get(groupId);
|
||||
@@ -684,6 +692,7 @@ export function PuzzleRuntimeShell({
|
||||
groupElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.02)`;
|
||||
groupElement.style.zIndex = '90';
|
||||
groupElement.style.opacity = '0.95';
|
||||
groupElement.style.transition = 'none';
|
||||
}
|
||||
const pieceCellElement = resolvePieceCellElement(dragSession.pieceId);
|
||||
if (pieceCellElement) {
|
||||
@@ -695,6 +704,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceElement.style.willChange = '';
|
||||
pieceElement.style.zIndex = '';
|
||||
pieceElement.style.opacity = '';
|
||||
pieceElement.style.transition = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -710,19 +720,12 @@ export function PuzzleRuntimeShell({
|
||||
pieceElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.03)`;
|
||||
pieceElement.style.zIndex = '81';
|
||||
pieceElement.style.opacity = '0.95';
|
||||
pieceElement.style.transition = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDragVisual = () => {
|
||||
if (dragVisualFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelDragVisualFrame();
|
||||
resetDragVisualTarget();
|
||||
},
|
||||
[],
|
||||
@@ -754,7 +757,7 @@ export function PuzzleRuntimeShell({
|
||||
isSettingsPanelOpen ||
|
||||
isExitRemodelPromptOpen ||
|
||||
Boolean(propDialog) ||
|
||||
isOriginalOverlayVisible;
|
||||
isOriginalImageViewerVisible;
|
||||
|
||||
useEffect(() => {
|
||||
if (previousUiPauseActiveRef.current === isUiPauseActive) {
|
||||
@@ -986,7 +989,6 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
if (session.dragging) {
|
||||
flushDragVisual();
|
||||
scheduleDragVisual();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1002,6 +1004,11 @@ export function PuzzleRuntimeShell({
|
||||
onDragStart: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
syncRuntimeDragFromController(session);
|
||||
setDragRenderTarget({
|
||||
pieceId: session.targetId,
|
||||
groupId: draggingTargetRef.current?.groupId ?? null,
|
||||
});
|
||||
flushDragVisual();
|
||||
},
|
||||
onDragMove: (session) => {
|
||||
syncRuntimeDragFromController(session);
|
||||
@@ -1029,7 +1036,7 @@ export function PuzzleRuntimeShell({
|
||||
if (!run || !currentLevel || !board) {
|
||||
return (
|
||||
<div
|
||||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex items-center justify-center`}
|
||||
className={`platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex items-center justify-center`}
|
||||
>
|
||||
<div className="puzzle-runtime-pill flex items-center gap-2 rounded-full px-5 py-3 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -1076,6 +1083,7 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
|
||||
const draggingGroupId = dragRenderTarget?.groupId ?? null;
|
||||
const shouldDisplaySelectedState = !dragRenderTarget;
|
||||
const freezeRemainingMs =
|
||||
currentLevel.freezeUntilMs && currentLevel.status === 'playing'
|
||||
? Math.max(0, currentLevel.freezeUntilMs - timerNowMs)
|
||||
@@ -1201,7 +1209,7 @@ export function PuzzleRuntimeShell({
|
||||
playHintDemo();
|
||||
}
|
||||
if (propKind === 'reference') {
|
||||
setIsOriginalOverlayVisible(true);
|
||||
setIsOriginalImageViewerVisible(true);
|
||||
}
|
||||
if (propKind === 'freezeTime') {
|
||||
// 中文注释:正式 run 可能在冻结确认期间已被服务端结算为失败态;
|
||||
@@ -1224,7 +1232,7 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||
className={`platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||
>
|
||||
{resolvedBackgroundMusicSrc ? (
|
||||
<audio
|
||||
@@ -1303,10 +1311,7 @@ export function PuzzleRuntimeShell({
|
||||
title="打开拼图设置"
|
||||
className="puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center rounded-full transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11"
|
||||
>
|
||||
<PixelIcon
|
||||
src={CHROME_ICONS.settings}
|
||||
className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)] sm:h-[1.4rem] sm:w-[1.4rem]"
|
||||
/>
|
||||
<Settings className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.25)] sm:h-[1.4rem] sm:w-[1.4rem]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1321,12 +1326,28 @@ export function PuzzleRuntimeShell({
|
||||
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute h-0 w-0 overflow-hidden"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<clipPath
|
||||
id={singlePieceClipId}
|
||||
clipPathUnits="objectBoundingBox"
|
||||
>
|
||||
<path d={buildRoundedGridCellClipPath()} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
{buildBoardCells(board).map((cell) => {
|
||||
const piece = pieceByCell.get(`${cell.row}:${cell.col}`) ?? null;
|
||||
const occupied = Boolean(piece);
|
||||
const isMerged = mergedCellKeys.has(boardCellKey(cell));
|
||||
const isSelected =
|
||||
!isMerged && piece?.pieceId === selectedPieceId;
|
||||
shouldDisplaySelectedState &&
|
||||
!isMerged &&
|
||||
piece?.pieceId === selectedPieceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1372,7 +1393,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceElementRefMap.current.delete(piece.pieceId);
|
||||
}}
|
||||
data-piece-id={piece?.pieceId ?? undefined}
|
||||
className={`puzzle-runtime-piece relative flex h-full items-center justify-center overflow-hidden border-0 text-sm font-black transition ${
|
||||
className={`puzzle-runtime-piece relative flex h-full items-center justify-center overflow-hidden border-0 text-sm font-black ${
|
||||
occupied
|
||||
? isSelected
|
||||
? 'puzzle-runtime-piece--selected'
|
||||
@@ -1386,6 +1407,10 @@ export function PuzzleRuntimeShell({
|
||||
: 'transition-[opacity,transform]'
|
||||
}`}
|
||||
style={{
|
||||
clipPath: isMerged ? undefined : singlePieceClipUrl,
|
||||
WebkitClipPath: isMerged
|
||||
? undefined
|
||||
: singlePieceClipUrl,
|
||||
zIndex: resolveDraggedPieceLayer(
|
||||
piece?.pieceId,
|
||||
draggingPieceId,
|
||||
@@ -1447,6 +1472,10 @@ export function PuzzleRuntimeShell({
|
||||
);
|
||||
})}
|
||||
{mergedGroups.map((group) => {
|
||||
const mergedGroupClipId = sanitizeSvgId(
|
||||
`${runtimeSvgClipId}-${group.groupId}`,
|
||||
);
|
||||
const mergedGroupClipPath = buildMergedGroupOutlinePath(group);
|
||||
return (
|
||||
<div
|
||||
key={group.groupId}
|
||||
@@ -1480,8 +1509,71 @@ export function PuzzleRuntimeShell({
|
||||
height: `${(group.rowSpan / board.rows) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
|
||||
data-merged-group-clip="true"
|
||||
viewBox={`0 0 ${group.colSpan} ${group.rowSpan}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<clipPath
|
||||
id={mergedGroupClipId}
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d={mergedGroupClipPath} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath={`url(#${mergedGroupClipId})`}>
|
||||
{group.pieces.map((piece) => (
|
||||
<g
|
||||
key={piece.pieceId}
|
||||
data-merged-piece-visual="true"
|
||||
>
|
||||
<clipPath
|
||||
id={sanitizeSvgId(
|
||||
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
|
||||
)}
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
>
|
||||
<rect
|
||||
x={piece.localCol}
|
||||
y={piece.localRow}
|
||||
width={1}
|
||||
height={1}
|
||||
/>
|
||||
</clipPath>
|
||||
<g
|
||||
clipPath={`url(#${sanitizeSvgId(
|
||||
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
|
||||
)})`}
|
||||
>
|
||||
{resolvedCoverImage ? (
|
||||
<image
|
||||
href={resolvedCoverImage}
|
||||
xlinkHref={resolvedCoverImage}
|
||||
x={piece.localCol - piece.correctCol}
|
||||
y={piece.localRow - piece.correctRow}
|
||||
width={board.cols}
|
||||
height={board.rows}
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={piece.localCol}
|
||||
y={piece.localRow}
|
||||
width={1}
|
||||
height={1}
|
||||
fill="rgba(16,185,129,0.42)"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
<div
|
||||
className="pointer-events-none relative z-10 grid h-full w-full touch-none overflow-hidden active:scale-[0.992]"
|
||||
className="pointer-events-none relative z-10 grid h-full w-full touch-none active:scale-[0.992]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
|
||||
@@ -1490,7 +1582,7 @@ export function PuzzleRuntimeShell({
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className="pointer-events-auto relative touch-none overflow-hidden"
|
||||
className="pointer-events-auto relative touch-none"
|
||||
data-merged-piece-outline="true"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
@@ -1511,48 +1603,12 @@ export function PuzzleRuntimeShell({
|
||||
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>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isOriginalOverlayVisible && resolvedCoverImage ? (
|
||||
<div
|
||||
data-testid="puzzle-original-overlay"
|
||||
className="pointer-events-none absolute inset-0 z-40 bg-black/10"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-70"
|
||||
style={{
|
||||
backgroundImage: `url("${resolvedCoverImage}")`,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{mergeFlash ? (
|
||||
<div
|
||||
key={mergeFlash.key}
|
||||
@@ -1575,7 +1631,9 @@ export function PuzzleRuntimeShell({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedPieceId && runtimeStatus === 'playing' ? (
|
||||
{selectedPieceId &&
|
||||
shouldDisplaySelectedState &&
|
||||
runtimeStatus === 'playing' ? (
|
||||
<div className="puzzle-runtime-status-chip rounded-full px-3 py-1 text-xs">
|
||||
已选择
|
||||
</div>
|
||||
@@ -1614,17 +1672,17 @@ export function PuzzleRuntimeShell({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={runtimeStatus !== 'playing'}
|
||||
aria-pressed={isOriginalOverlayVisible}
|
||||
disabled={runtimeStatus !== 'playing' || !resolvedCoverImage}
|
||||
aria-pressed={isOriginalImageViewerVisible}
|
||||
onClick={() => {
|
||||
if (isOriginalOverlayVisible) {
|
||||
setIsOriginalOverlayVisible(false);
|
||||
if (isOriginalImageViewerVisible) {
|
||||
setIsOriginalImageViewerVisible(false);
|
||||
return;
|
||||
}
|
||||
openPropDialog('reference', '查看原图');
|
||||
}}
|
||||
className={`inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45 ${
|
||||
isOriginalOverlayVisible
|
||||
isOriginalImageViewerVisible
|
||||
? 'puzzle-runtime-tool-button--active'
|
||||
: 'puzzle-runtime-tool-button'
|
||||
}`}
|
||||
@@ -1667,6 +1725,39 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isOriginalImageViewerVisible && resolvedCoverImage ? (
|
||||
<div
|
||||
data-testid="puzzle-original-viewer"
|
||||
className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-4 backdrop-blur-sm"
|
||||
style={{ background: 'rgba(2, 6, 23, 0.94)' }}
|
||||
onClick={() => {
|
||||
setIsOriginalImageViewerVisible(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭原图"
|
||||
className="puzzle-runtime-secondary-button absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center rounded-full transition hover:brightness-105"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setIsOriginalImageViewerVisible(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={resolvedCoverImage}
|
||||
alt={`${currentLevel.levelName} 原图`}
|
||||
className="max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{propDialog ? (
|
||||
<div
|
||||
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@@ -1680,8 +1771,7 @@ export function PuzzleRuntimeShell({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-prop-confirm-title"
|
||||
className="puzzle-runtime-dialog pixel-nine-slice pixel-modal-shell w-full max-w-[22rem] overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
className="puzzle-runtime-dialog w-full max-w-[22rem] overflow-hidden rounded-[1.35rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="puzzle-runtime-dialog__line flex items-center gap-3 border-b px-5 py-4">
|
||||
@@ -1739,8 +1829,7 @@ export function PuzzleRuntimeShell({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-settings-title"
|
||||
className="puzzle-runtime-dialog pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
className="puzzle-runtime-dialog flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="puzzle-runtime-dialog__line relative border-b px-4 py-3 sm:px-5 sm:py-4">
|
||||
@@ -1763,7 +1852,7 @@ export function PuzzleRuntimeShell({
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
className="puzzle-runtime-dialog__soft absolute right-4 top-3 p-1 transition-colors hover:brightness-75 sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -1827,7 +1916,7 @@ export function PuzzleRuntimeShell({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="puzzle-runtime-dialog__soft">当前用时</span>
|
||||
<span className="font-mono font-semibold">
|
||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||
{formatElapsedMs(displayElapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1987,7 +2076,7 @@ export function PuzzleRuntimeShell({
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ type GridEdge = {
|
||||
};
|
||||
|
||||
const MERGED_GROUP_OUTLINE_CORNER_RADIUS = 0.16;
|
||||
const MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR = 1.2;
|
||||
|
||||
function buildLocalCellKey(row: number, col: number) {
|
||||
return `${row}:${col}`;
|
||||
@@ -78,15 +79,38 @@ function removeCollinearGridPoints(points: GridPoint[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function computePolygonSignedArea(points: GridPoint[]) {
|
||||
let area = 0;
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const current = points[index];
|
||||
const next = points[(index + 1) % points.length];
|
||||
if (!current || !next) {
|
||||
continue;
|
||||
}
|
||||
area += current.x * next.y - next.x * current.y;
|
||||
}
|
||||
return area / 2;
|
||||
}
|
||||
|
||||
type CornerRadiusResolver = (corner: {
|
||||
point: GridPoint;
|
||||
previous: GridPoint;
|
||||
next: GridPoint;
|
||||
isConvex: boolean;
|
||||
radius: number;
|
||||
}) => number;
|
||||
|
||||
function buildRoundedGridCyclePath(
|
||||
points: GridPoint[],
|
||||
radius: number,
|
||||
transformPoint: (point: GridPoint) => GridPoint = (point) => point,
|
||||
resolveCornerRadius?: CornerRadiusResolver,
|
||||
) {
|
||||
const cyclePoints = removeCollinearGridPoints(points);
|
||||
if (cyclePoints.length < 3) {
|
||||
return '';
|
||||
}
|
||||
const polygonOrientation = computePolygonSignedArea(cyclePoints) >= 0 ? 1 : -1;
|
||||
const resolveCorner = (index: number) => {
|
||||
const point = cyclePoints[index];
|
||||
const previous = cyclePoints[
|
||||
@@ -96,8 +120,25 @@ function buildRoundedGridCyclePath(
|
||||
if (!point || !previous || !next) {
|
||||
return null;
|
||||
}
|
||||
const previousVectorX = point.x - previous.x;
|
||||
const previousVectorY = point.y - previous.y;
|
||||
const nextVectorX = next.x - point.x;
|
||||
const nextVectorY = next.y - point.y;
|
||||
const turnCross =
|
||||
previousVectorX * nextVectorY - previousVectorY * nextVectorX;
|
||||
const isConvex = turnCross * polygonOrientation > 0;
|
||||
const resolvedRadius = Math.max(
|
||||
0,
|
||||
resolveCornerRadius?.({
|
||||
point,
|
||||
previous,
|
||||
next,
|
||||
isConvex,
|
||||
radius,
|
||||
}) ?? radius,
|
||||
);
|
||||
const safeRadius = Math.min(
|
||||
radius,
|
||||
resolvedRadius,
|
||||
distanceBetweenGridPoints(point, previous) / 2,
|
||||
distanceBetweenGridPoints(point, next) / 2,
|
||||
);
|
||||
@@ -216,13 +257,34 @@ function buildMergedGroupBoundaryCycles(group: PuzzleMergedGroupShape) {
|
||||
return cycles;
|
||||
}
|
||||
|
||||
export function buildRoundedGridCellClipPath(
|
||||
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
|
||||
) {
|
||||
return buildRoundedGridCyclePath([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 0, y: 1 },
|
||||
], radius);
|
||||
}
|
||||
|
||||
export function buildMergedGroupOutlinePath(
|
||||
group: PuzzleMergedGroupShape,
|
||||
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
|
||||
) {
|
||||
// 合并块的凹入角不能靠单格 border-radius 稳定拼出来,必须先生成整体外轮廓。
|
||||
return buildMergedGroupBoundaryCycles(group)
|
||||
.map((cycle) => buildRoundedGridCyclePath(cycle, radius))
|
||||
.map((cycle) =>
|
||||
buildRoundedGridCyclePath(
|
||||
cycle,
|
||||
radius,
|
||||
(corner) => corner,
|
||||
({ isConvex }) =>
|
||||
isConvex
|
||||
? radius
|
||||
: radius * MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR,
|
||||
),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
@@ -233,10 +295,18 @@ export function buildMergedGroupClipPath(
|
||||
) {
|
||||
return buildMergedGroupBoundaryCycles(group)
|
||||
.map((cycle) =>
|
||||
buildRoundedGridCyclePath(cycle, radius, (point) => ({
|
||||
x: point.x / group.colSpan,
|
||||
y: point.y / group.rowSpan,
|
||||
})),
|
||||
buildRoundedGridCyclePath(
|
||||
cycle,
|
||||
radius,
|
||||
(point) => ({
|
||||
x: point.x / group.colSpan,
|
||||
y: point.y / group.rowSpan,
|
||||
}),
|
||||
({ isConvex }) =>
|
||||
isConvex
|
||||
? radius
|
||||
: radius * MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR,
|
||||
),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
Reference in New Issue
Block a user