1
This commit is contained in:
@@ -53,6 +53,16 @@ function renderPuzzleRuntime(
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchPointerEvent(
|
||||
target: HTMLElement,
|
||||
type: string,
|
||||
options: { pointerId: number; clientX: number; clientY: number },
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.assign(event, options);
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
const clearedRun: PuzzleRunSnapshot = {
|
||||
runId: 'run-1',
|
||||
entryProfileId: 'profile-1',
|
||||
@@ -159,12 +169,15 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
vi.useFakeTimers();
|
||||
test('顶部作者显示头像昵称,底部功能居中放大且不显示等待候选', () => {
|
||||
const runWithoutNext: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: null,
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
run={runWithoutNext}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
@@ -172,13 +185,125 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const avatar = screen.getByText('测');
|
||||
const timer = screen.getByText('4:48');
|
||||
const hintButton = screen.getByRole('button', { name: '提示' });
|
||||
const referenceButton = screen.getByRole('button', { name: '原图' });
|
||||
const freezeButton = screen.getByRole('button', { name: '冻结' });
|
||||
|
||||
expect(avatar.className).toContain('rounded-full');
|
||||
expect(screen.getByText('测试作者')).toBeTruthy();
|
||||
expect(timer.className).toContain('text-2xl');
|
||||
expect(hintButton.className).toContain('h-16');
|
||||
expect(referenceButton.className).toContain('h-16');
|
||||
expect(freezeButton.className).toContain('h-16');
|
||||
expect(screen.queryByText('等待下一关候选')).toBeNull();
|
||||
});
|
||||
|
||||
test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: null,
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: 'profile-1',
|
||||
nextLevelId: 'puzzle-level-2',
|
||||
recommendedNextWorks: [],
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={runWithoutRecommendedNextProfile}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={onAdvanceNextLevel}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /下一关/u })).toBeTruthy();
|
||||
const nextButton = screen.getByRole('button', { name: /下一关/u });
|
||||
expect(nextButton).toBeTruthy();
|
||||
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
|
||||
expect(onAdvanceNextLevel).toHaveBeenCalledWith({
|
||||
profileId: 'profile-1',
|
||||
levelId: 'puzzle-level-2',
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('当前作品没有下一关时展示三个相似作品并可选择进入', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
const similarWorksRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: 'profile-similar-1',
|
||||
nextLevelMode: 'similarWorks',
|
||||
nextLevelProfileId: 'profile-similar-1',
|
||||
nextLevelId: null,
|
||||
recommendedNextWorks: [
|
||||
{
|
||||
profileId: 'profile-similar-1',
|
||||
levelName: '雾海遗迹',
|
||||
authorDisplayName: '星桥旅人',
|
||||
themeTags: ['奇幻', '遗迹'],
|
||||
coverImageSrc: null,
|
||||
similarityScore: 0.91,
|
||||
},
|
||||
{
|
||||
profileId: 'profile-similar-2',
|
||||
levelName: '风塔试炼',
|
||||
authorDisplayName: '晨风',
|
||||
themeTags: ['奇幻', '机关'],
|
||||
coverImageSrc: null,
|
||||
similarityScore: 0.84,
|
||||
},
|
||||
{
|
||||
profileId: 'profile-similar-3',
|
||||
levelName: '月井秘路',
|
||||
authorDisplayName: '月井守望',
|
||||
themeTags: ['秘境', '魔法'],
|
||||
coverImageSrc: null,
|
||||
similarityScore: 0.79,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={similarWorksRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={onAdvanceNextLevel}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '通关完成' });
|
||||
expect(within(dialog).getByText('雾海遗迹')).toBeTruthy();
|
||||
expect(within(dialog).getByText('风塔试炼')).toBeTruthy();
|
||||
expect(within(dialog).getByText('月井秘路')).toBeTruthy();
|
||||
expect(within(dialog).queryByRole('button', { name: '下一关' })).toBeNull();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /风塔试炼/u }));
|
||||
|
||||
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
|
||||
expect(onAdvanceNextLevel).toHaveBeenCalledWith({
|
||||
profileId: 'profile-similar-2',
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -326,6 +451,106 @@ test('基础单块使用圆角裁剪图片', () => {
|
||||
expect(basePiece?.className).toContain('rounded-[0.85rem]');
|
||||
});
|
||||
|
||||
test('移动端点击拼图片时立即触发一次震动反馈', () => {
|
||||
const originalVibrate = navigator.vibrate;
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
const vibrate = vi.fn();
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, 'vibrate', {
|
||||
configurable: true,
|
||||
value: vibrate,
|
||||
});
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 1),
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
const { container, unmount } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const piece = container.querySelector(
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
if (!piece) {
|
||||
throw new Error('缺少测试拼图片');
|
||||
}
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
});
|
||||
|
||||
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||
expect(vibrate).toHaveBeenCalledWith([12]);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 104,
|
||||
clientY: 104,
|
||||
});
|
||||
});
|
||||
|
||||
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 112,
|
||||
clientY: 100,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 132,
|
||||
clientY: 100,
|
||||
});
|
||||
});
|
||||
|
||||
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
Object.defineProperty(navigator, 'vibrate', {
|
||||
configurable: true,
|
||||
value: originalVibrate,
|
||||
});
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalRequestAnimationFrame,
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalCancelAnimationFrame,
|
||||
});
|
||||
});
|
||||
|
||||
test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', async () => {
|
||||
const onPauseChange = vi.fn();
|
||||
const onUseProp = vi.fn().mockResolvedValue(clearedRun);
|
||||
|
||||
@@ -16,13 +16,14 @@ import type {
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleMergedGroupState,
|
||||
PuzzleRecommendedNextWork,
|
||||
PuzzleRunSnapshot,
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
PuzzleRuntimePropKind,
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
@@ -34,7 +35,8 @@ type PuzzleRuntimeShellProps = {
|
||||
onBack: () => void;
|
||||
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
|
||||
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
|
||||
onAdvanceNextLevel: () => void;
|
||||
onAdvanceNextLevel: (target?: PuzzleNextLevelTarget) => void;
|
||||
onRestartLevel?: () => void | Promise<void>;
|
||||
onPauseChange?: (paused: boolean) => void | Promise<void>;
|
||||
onUseProp?: (
|
||||
propKind: PuzzleRuntimePropKind,
|
||||
@@ -42,6 +44,11 @@ type PuzzleRuntimeShellProps = {
|
||||
onTimeExpired?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type PuzzleNextLevelTarget = {
|
||||
profileId?: string;
|
||||
levelId?: string | null;
|
||||
};
|
||||
|
||||
type PuzzleBoardPieceViewModel = {
|
||||
pieceId: string;
|
||||
row: number;
|
||||
@@ -260,6 +267,10 @@ function formatTimerMs(value: number | null | undefined) {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function resolveAuthorAvatarLabel(authorDisplayName: string) {
|
||||
return authorDisplayName.trim().slice(0, 1) || '玩';
|
||||
}
|
||||
|
||||
function resolveActiveFreezeElapsedMs(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
@@ -309,6 +320,7 @@ const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
|
||||
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
|
||||
const PUZZLE_MERGE_FLASH_DURATION_MS = 720;
|
||||
const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
|
||||
const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
|
||||
|
||||
type PuzzlePropDialogState = {
|
||||
propKind: PuzzleRuntimePropKind;
|
||||
@@ -329,6 +341,19 @@ type PuzzleHintDemoState = {
|
||||
offsetYPercent: number;
|
||||
};
|
||||
|
||||
function triggerPuzzlePiecePressHapticFeedback() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const vibrate = navigator.vibrate;
|
||||
if (typeof vibrate !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
vibrate.call(navigator, [PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图运行时壳层。
|
||||
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
|
||||
@@ -342,6 +367,7 @@ export function PuzzleRuntimeShell({
|
||||
onSwapPieces,
|
||||
onDragPiece,
|
||||
onAdvanceNextLevel,
|
||||
onRestartLevel,
|
||||
onPauseChange,
|
||||
onUseProp,
|
||||
onTimeExpired,
|
||||
@@ -907,6 +933,8 @@ export function PuzzleRuntimeShell({
|
||||
event.preventDefault();
|
||||
resetDragInteraction();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
// 按下可交互拼图片时立即给移动端短震反馈,点击选择与拖起都会有同一套手感。
|
||||
triggerPuzzlePiecePressHapticFeedback();
|
||||
dragSessionRef.current = {
|
||||
pieceId,
|
||||
pointerId: event.pointerId,
|
||||
@@ -959,9 +987,24 @@ export function PuzzleRuntimeShell({
|
||||
: runtimeStatus === 'failed'
|
||||
? '失败'
|
||||
: '进行中';
|
||||
const nextAvailable =
|
||||
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
||||
const nextLevelMode =
|
||||
run.nextLevelMode ?? 'none';
|
||||
const recommendedNextWorks = run.recommendedNextWorks ?? [];
|
||||
const hasSimilarWorkChoices =
|
||||
nextLevelMode === 'similarWorks' && recommendedNextWorks.length > 0;
|
||||
const canAdvanceDefaultNextLevel =
|
||||
currentLevel.status === 'cleared' &&
|
||||
(nextLevelMode === 'sameWork' ||
|
||||
(nextLevelMode === 'similarWorks'
|
||||
? Boolean(run.nextLevelProfileId ?? run.recommendedNextProfileId) &&
|
||||
!hasSimilarWorkChoices
|
||||
: Boolean(run.recommendedNextProfileId)));
|
||||
const canShowNextAction =
|
||||
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
|
||||
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
||||
const authorAvatarLabel = resolveAuthorAvatarLabel(
|
||||
currentLevel.authorDisplayName,
|
||||
);
|
||||
const leaderboardEntries =
|
||||
(currentLevel.leaderboardEntries ?? []).length > 0
|
||||
? currentLevel.leaderboardEntries
|
||||
@@ -974,7 +1017,11 @@ export function PuzzleRuntimeShell({
|
||||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||||
|
||||
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
|
||||
if (runtimeStatus !== 'playing') {
|
||||
const canOpen =
|
||||
propKind === 'extendTime'
|
||||
? runtimeStatus === 'failed'
|
||||
: runtimeStatus === 'playing';
|
||||
if (!canOpen) {
|
||||
return;
|
||||
}
|
||||
setPropConfirmError(null);
|
||||
@@ -1048,6 +1095,9 @@ export function PuzzleRuntimeShell({
|
||||
setIsFreezeEffectVisible(false);
|
||||
}, 900);
|
||||
}
|
||||
if (propKind === 'extendTime') {
|
||||
setTimerNowMs(Date.now());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1064,7 +1114,7 @@ export function PuzzleRuntimeShell({
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:34px_34px] opacity-20" />
|
||||
|
||||
<div className="absolute left-0 top-0 z-20 w-full px-4 py-4">
|
||||
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-3">
|
||||
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -1074,26 +1124,34 @@ export function PuzzleRuntimeShell({
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex min-w-0 flex-col items-center gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-center backdrop-blur">
|
||||
<div className="line-clamp-1 text-sm font-bold text-white sm:text-base">
|
||||
<div className="flex min-w-0 flex-col items-center gap-2 rounded-[1.35rem] bg-black/30 px-3 py-3 text-center backdrop-blur sm:px-5">
|
||||
<div className="line-clamp-1 max-w-full text-sm font-black text-white sm:text-base">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
<div className="line-clamp-1 text-xs text-white/78">
|
||||
{currentLevel.authorDisplayName}
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
|
||||
{levelLabel}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-mono text-xs font-black ${
|
||||
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 font-mono text-2xl font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.24)] sm:text-3xl ${
|
||||
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
|
||||
? 'bg-red-500/22 text-red-100'
|
||||
: 'bg-white/10 text-white/86'
|
||||
? 'bg-red-500/24 text-red-100'
|
||||
: 'bg-white/12 text-white'
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<Clock className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
{formatTimerMs(displayRemainingMs)}
|
||||
</div>
|
||||
<div className="flex min-w-0 max-w-full items-center justify-center gap-2 text-white/82">
|
||||
<span
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/16 bg-amber-200 text-xs font-black text-slate-950 shadow-[0_8px_20px_rgba(0,0,0,0.2)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{authorAvatarLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-xs font-semibold sm:text-sm">
|
||||
{currentLevel.authorDisplayName}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold tracking-[0.12em] text-amber-100/90 sm:text-[11px]">
|
||||
{levelLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -1371,15 +1429,47 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-3 py-3 sm:px-4 sm:py-4">
|
||||
<div className="flex items-center gap-2 rounded-full bg-black/32 p-1.5 backdrop-blur">
|
||||
<div className="absolute bottom-0 left-0 z-20 flex w-full flex-col items-center gap-2 px-3 py-3 sm:px-4 sm:py-4">
|
||||
{error ? (
|
||||
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedPieceId && runtimeStatus === 'playing' ? (
|
||||
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
|
||||
已选择
|
||||
</div>
|
||||
) : null}
|
||||
{canShowNextAction ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (hasSimilarWorkChoices) {
|
||||
setDismissedClearKey(null);
|
||||
setIsClearResultReady(true);
|
||||
return;
|
||||
}
|
||||
onAdvanceNextLevel({
|
||||
profileId: run.nextLevelProfileId ?? undefined,
|
||||
levelId: run.nextLevelId ?? null,
|
||||
});
|
||||
}}
|
||||
className="inline-flex min-h-11 items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-bold text-slate-950 shadow-[0_14px_36px_rgba(251,191,36,0.26)] transition hover:bg-amber-100 disabled:opacity-45"
|
||||
>
|
||||
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-center gap-2 rounded-full bg-black/36 p-2 backdrop-blur sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isInteractionLocked}
|
||||
onClick={() => openPropDialog('hint', '使用提示')}
|
||||
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
|
||||
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 text-white/88 transition hover:bg-white/10 disabled:opacity-45"
|
||||
>
|
||||
<Lightbulb className="h-4 w-4 text-amber-100" />
|
||||
<Lightbulb className="h-6 w-6 text-amber-100" />
|
||||
提示
|
||||
</button>
|
||||
<button
|
||||
@@ -1393,59 +1483,25 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
openPropDialog('reference', '查看原图');
|
||||
}}
|
||||
className={`inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold transition hover:bg-white/10 disabled:opacity-45 ${
|
||||
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 hover:bg-white/10 disabled:opacity-45 ${
|
||||
isOriginalOverlayVisible
|
||||
? 'bg-sky-200 text-slate-950'
|
||||
: 'text-white/86'
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<Eye className="h-6 w-6" />
|
||||
原图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isInteractionLocked}
|
||||
onClick={() => openPropDialog('freezeTime', '冻结时间')}
|
||||
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
|
||||
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 text-white/88 transition hover:bg-white/10 disabled:opacity-45"
|
||||
>
|
||||
<Snowflake className="h-4 w-4 text-cyan-100" />
|
||||
<Snowflake className="h-6 w-6 text-cyan-100" />
|
||||
冻结
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{error ? (
|
||||
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedPieceId && runtimeStatus === 'playing' ? (
|
||||
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
|
||||
已选择
|
||||
</div>
|
||||
) : null}
|
||||
{nextAvailable ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onAdvanceNextLevel}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
下一关
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="rounded-full bg-black/28 px-4 py-2 text-xs text-white/72 backdrop-blur">
|
||||
{isBusy
|
||||
? '同步中...'
|
||||
: runtimeStatus === 'cleared'
|
||||
? '等待下一关候选'
|
||||
: runtimeStatus === 'failed'
|
||||
? '本关失败'
|
||||
: '完成整张图即可通关'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isClearFlashVisible ? (
|
||||
@@ -1678,13 +1734,24 @@ export function PuzzleRuntimeShell({
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
</header>
|
||||
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
|
||||
<footer className="grid grid-cols-2 gap-3 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
void onRestartLevel?.();
|
||||
}}
|
||||
className="rounded-full border border-white/14 bg-black/24 px-4 py-2.5 text-sm font-black text-white transition hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
返回
|
||||
重新开始
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => openPropDialog('extendTime', '继续1分钟')}
|
||||
className="rounded-full bg-amber-200 px-4 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-50"
|
||||
>
|
||||
继续1分钟
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -1783,23 +1850,47 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSimilarWorkChoices ? (
|
||||
<div className="mt-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{recommendedNextWorks.slice(0, 3).map((item) => (
|
||||
<PuzzleNextWorkCard
|
||||
key={item.profileId}
|
||||
item={item}
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onAdvanceNextLevel({ profileId: item.profileId });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !nextAvailable}
|
||||
onClick={onAdvanceNextLevel}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
下一关
|
||||
</button>
|
||||
</footer>
|
||||
{canAdvanceDefaultNextLevel ? (
|
||||
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onAdvanceNextLevel({
|
||||
profileId: run.nextLevelProfileId ?? undefined,
|
||||
levelId: run.nextLevelId ?? null,
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
下一关
|
||||
</button>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1808,4 +1899,54 @@ export function PuzzleRuntimeShell({
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleNextWorkCard({
|
||||
item,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
item: PuzzleRecommendedNextWork;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="group grid min-h-[5.75rem] grid-cols-[4.5rem_minmax(0,1fr)] overflow-hidden rounded-[1rem] border border-white/10 bg-white/6 text-left transition hover:border-amber-200/40 hover:bg-amber-200/10 disabled:cursor-not-allowed disabled:opacity-45 sm:grid-cols-1"
|
||||
>
|
||||
<div className="relative min-h-full bg-white/8 sm:aspect-[1.35]">
|
||||
{item.coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={item.coverImageSrc}
|
||||
alt={item.levelName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/10 transition group-hover:bg-black/0" />
|
||||
</div>
|
||||
<div className="min-w-0 px-3 py-2.5">
|
||||
<div className="truncate text-sm font-black text-white">
|
||||
{item.levelName}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs font-semibold text-white/58">
|
||||
{item.authorDisplayName}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{item.themeTags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="max-w-full truncate rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold text-white/64"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleRuntimeShell;
|
||||
|
||||
Reference in New Issue
Block a user