1
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user