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:
2026-05-15 08:49:59 +08:00
parent 0f36beee91
commit bb60ca91ef
23 changed files with 2127 additions and 593 deletions

View File

@@ -633,7 +633,7 @@ test('creation hub published work delete action is revealed without opening card
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
@@ -684,7 +684,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
expect(openedItems).toEqual([persistedDraft]);
});
test('creation hub published swipe share button copies share text without opening the card', async () => {
test('creation hub published share icon copies share text without opening the card', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
const onOpenPuzzleDetail = vi.fn();
@@ -727,9 +727,11 @@ test('creation hub published swipe share button copies share text without openin
/>,
);
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '分享' }));
const shareButton = screen.getByRole('button', { name: '分享' });
expect(shareButton).toBeTruthy();
expect(screen.queryByText('删除')).toBeNull();
await user.click(shareButton);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('邀请你来玩《沉钟拼图》'),
@@ -746,6 +748,45 @@ test('creation hub published swipe share button copies share text without openin
).toBeTruthy();
});
test('creation hub published share icon is shown directly on the card header', () => {
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-share-icon',
profileId: 'puzzle-profile-share-icon',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '沉钟拼图',
summary: '分享入口应直接露出在卡片右上角。',
themeTags: ['潮雾'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
remixCount: 2,
likeCount: 0,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
});
test('creation hub left swipe draft reveals delete without opening card', () => {
const onDeletePublished = vi.fn();
const onOpenDraft = vi.fn();

View File

@@ -248,7 +248,7 @@ export function CustomWorldWorkCard({
const isPublished = item.status === 'published';
const canUseShareAction =
isPublished && item.canShare && Boolean(item.sharePath);
const swipeActionCount = (canUseShareAction ? 1 : 0) + (onDelete ? 1 : 0);
const swipeActionCount = onDelete ? 1 : 0;
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
const canClaimPointIncentive =
Boolean(onClaimPointIncentive) &&
@@ -584,43 +584,6 @@ export function CustomWorldWorkCard({
className="creation-work-card__swipe-underlay"
>
<div className="creation-work-card__swipe-actions">
{canUseShareAction ? (
<button
type="button"
tabIndex={isSwipeActionRevealed ? 0 : -1}
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="creation-work-card__swipe-button creation-work-card__swipe-button--share"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-4 w-4" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
{onDelete ? (
<button
type="button"
@@ -710,6 +673,43 @@ export function CustomWorldWorkCard({
{displayTitle}
</span>
</div>
{canUseShareAction ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
onTouchStart={(event) => {
event.stopPropagation();
}}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="creation-work-card__share-button"
>
<Share2 aria-hidden="true" className="h-4 w-4" />
</button>
) : null}
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">

View File

@@ -1349,12 +1349,121 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy();
expect(screen.getByText('第 1 关')).toBeTruthy();
expect(screen.getByText('抓大鹅')).toBeTruthy();
expect(screen.getByText('1:30')).toBeTruthy();
const previewBoard = screen.getByTestId('match3d-ui-preview-board');
expect(previewBoard.className).toContain('bg-transparent');
expect(previewBoard.className).not.toContain('rounded-full');
const containerImage = document.querySelector(
'img[src="/match3d-background-references/pot-fused-reference.png"]',
);
expect(containerImage).toBeTruthy();
expect(containerImage?.className).toContain('w-[min(99vw,34rem)]');
expect(containerImage?.className).toContain('-translate-x-1/2');
expect(
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
).toBeNull();
expect(
document.querySelector(
'svg[class*="lucide-settings"], [data-lucide="settings"]',
),
).toBeTruthy();
});
test('素材配置 UI 子 Tab 从物品挂载资产展示生成背景和容器', async () => {
const onStartTestRun = vi.fn();
const profile = createProfile({
backgroundPrompt: null,
backgroundImageSrc: null,
backgroundImageObjectKey: null,
generatedBackgroundAsset: null,
generatedItemAssets: [
{
...createReadyGeneratedItemAsset(1),
itemName: '草莓',
backgroundAsset: {
prompt: '果园背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
containerPrompt: '果园容器',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
error: null,
},
},
],
});
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: createProfile({ generatedItemAssets: [] }),
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: profile,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
expect(screen.getByAltText('游戏背景图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/background/background.png',
);
expect(screen.getByLabelText('UI背景图画面描述提示词')).toHaveProperty(
'value',
'果园背景',
);
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
expect(
document.querySelector(
'img[src="/generated-match3d-assets/session/profile/background/background.png"]',
),
).toBeTruthy();
expect(
document.querySelector(
'img[src="/generated-match3d-assets/session/profile/ui-container/container.png"]',
),
).toBeTruthy();
expect(
document.querySelector(
'img[src="/match3d-background-references/pot-fused-reference.png"]',
),
).toBeTruthy();
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
backgroundPrompt: '果园背景',
backgroundImageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
generatedBackgroundAsset: expect.objectContaining({
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
}),
}),
{
itemTypeCountOverride: 1,
},
);
});
});
test('素材配置 UI 子 Tab 修改提示词后调用背景图生成接口并刷新素材', async () => {

View File

@@ -8,6 +8,7 @@ import {
Play,
Plus,
Send,
Settings,
Trash2,
Wand2,
X,
@@ -24,6 +25,7 @@ import { createPortal } from 'react-dom';
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkProfile,
PutMatch3DWorkRequest,
@@ -49,11 +51,19 @@ import {
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import {
MATCH3D_RUNTIME_BOARD_BASE_CLASS,
MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS,
MATCH3D_RUNTIME_BOARD_WIDTH,
MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS,
MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS,
MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS,
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_SPINNER_CLASS,
MATCH3D_RUNTIME_GLASS_TIMER_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
MATCH3D_RUNTIME_HEADER_CARD_CLASS,
MATCH3D_RUNTIME_LEVEL_BADGE_CLASS,
MATCH3D_RUNTIME_STAGE_CLASS,
MATCH3D_RUNTIME_TIMER_CLASS,
} from '../match3d-runtime/match3dRuntimeUiStyles';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -299,6 +309,44 @@ function resolveMatch3DBackgroundPreviewSource(
);
}
function findMatch3DGeneratedBackgroundAsset(
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
): Match3DGeneratedBackgroundAsset | null {
return (
generatedItemAssets.find((asset) => asset.backgroundAsset)?.backgroundAsset ??
null
);
}
function promoteMatch3DGeneratedBackgroundAsset(
profile: Match3DWorkProfile,
): Match3DWorkProfile {
const fallbackBackground =
profile.generatedBackgroundAsset ??
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets ?? []);
if (!fallbackBackground) {
return profile;
}
return {
...profile,
backgroundPrompt:
profile.backgroundPrompt ?? fallbackBackground.prompt ?? null,
backgroundImageSrc:
profile.backgroundImageSrc ??
fallbackBackground.imageSrc ??
fallbackBackground.imageObjectKey ??
null,
backgroundImageObjectKey:
profile.backgroundImageObjectKey ??
fallbackBackground.imageObjectKey ??
fallbackBackground.imageSrc ??
null,
generatedBackgroundAsset:
profile.generatedBackgroundAsset ?? fallbackBackground,
};
}
function resolveMatch3DBackgroundPrompt(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
@@ -1144,11 +1192,14 @@ function buildPlayableProfile(
) {
const payload = buildSavePayload(editState);
if (!payload) {
return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets);
return promoteMatch3DGeneratedBackgroundAsset(
attachMatch3DGeneratedItemAssets(profile, generatedItemAssets),
);
}
return attachMatch3DGeneratedItemAssets(
{
return promoteMatch3DGeneratedBackgroundAsset(
attachMatch3DGeneratedItemAssets(
{
...profile,
gameName: payload.gameName,
themeText: payload.themeText ?? profile.themeText,
@@ -1157,8 +1208,9 @@ function buildPlayableProfile(
coverImageSrc: payload.coverImageSrc,
clearCount: payload.clearCount,
difficulty: payload.difficulty,
},
generatedItemAssets,
},
generatedItemAssets,
),
);
}
@@ -1219,15 +1271,15 @@ function attachMatch3DGeneratedItemAssets(
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
if (generatedItemAssets.length <= 0) {
return profile;
return promoteMatch3DGeneratedBackgroundAsset(profile);
}
// 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。
return {
return promoteMatch3DGeneratedBackgroundAsset({
...profile,
generatedItemAssets:
normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
};
});
}
function attachMatch3DGeneratedBackgroundAsset(
@@ -2996,35 +3048,46 @@ function Match3DUIRuntimePreviewPanel({
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
<header className="relative z-10 flex items-center justify-between gap-2">
<header className="relative z-10 grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem]">
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<ArrowLeft size={20} />
</span>
<span className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>1:30</span>
<span className={`${MATCH3D_RUNTIME_HEADER_CARD_CLASS} mx-auto`}>
<span className="flex max-w-full items-center justify-center gap-1.5">
<span className={MATCH3D_RUNTIME_LEVEL_BADGE_CLASS}>
1
</span>
<span className="min-w-0 truncate text-sm font-black sm:text-base">
</span>
</span>
<span className={MATCH3D_RUNTIME_TIMER_CLASS}>1:30</span>
</span>
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<span className={MATCH3D_RUNTIME_GLASS_SPINNER_CLASS} />
<Settings size={18} />
</span>
</header>
<section className="relative z-10 mt-3 flex min-h-0 flex-1 items-center justify-center">
<section className={`z-10 ${MATCH3D_RUNTIME_STAGE_CLASS}`}>
<div
className={`relative aspect-square max-w-full ${
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} ${
containerPreviewSrc
? 'overflow-visible bg-transparent'
: 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'
? MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS
: MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS
}`}
style={{ width: 'min(96%, 60dvh, 100%)' }}
style={{ width: MATCH3D_RUNTIME_BOARD_WIDTH }}
aria-hidden="true"
data-testid="match3d-ui-preview-board"
>
{containerPreviewSrc ? (
<ResolvedAssetImage
src={containerPreviewSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-10%] h-[120%] w-[120%] object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]"
className={MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS}
/>
) : (
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
<div className={MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS} />
)}
</div>
</section>
@@ -3152,6 +3215,10 @@ export function Match3DResultView({
onPublished,
onStartTestRun,
}: Match3DResultViewProps) {
const promotedProfile = useMemo(
() => promoteMatch3DGeneratedBackgroundAsset(profile),
[profile],
);
const [editState, setEditState] = useState(() => createEditState(profile));
const [activeTab, setActiveTab] = useState<Match3DResultTab>('work');
const [activeAssetConfigTab, setActiveAssetConfigTab] =
@@ -3207,8 +3274,8 @@ export function Match3DResultView({
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
const generatedItemAssets = useMemo(
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
[draft, profile],
() => resolveMatch3DResultGeneratedItemAssets(promotedProfile, draft),
[draft, promotedProfile],
);
const blockers = useMemo(
() => buildPublishBlockers(editState, generatedItemAssets),
@@ -3221,34 +3288,44 @@ export function Match3DResultView({
const canStartTestRun = testRunBlockers.length === 0;
const canSubmit = blockers.length === 0;
const totalItemCount =
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
3;
(normalizePositiveInteger(editState.clearCountText) ??
promotedProfile.clearCount) * 3;
const backgroundPreviewSrc = useMemo(
() =>
resolveMatch3DBackgroundPreviewSource(
profile,
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, profile],
[draft, generatedItemAssets, promotedProfile],
);
const backgroundPrompt = useMemo(
() => resolveMatch3DBackgroundPrompt(profile, draft, generatedItemAssets),
[draft, generatedItemAssets, profile],
() =>
resolveMatch3DBackgroundPrompt(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const containerPrompt = useMemo(
() => resolveMatch3DContainerPrompt(profile, draft, generatedItemAssets),
[draft, generatedItemAssets, profile],
() =>
resolveMatch3DContainerPrompt(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const containerPreviewSrc = useMemo(
() =>
resolveMatch3DContainerPreviewSource(
profile,
promotedProfile,
draft,
generatedItemAssets,
) ||
MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
[draft, generatedItemAssets, profile],
[draft, generatedItemAssets, promotedProfile],
);
const coverSourceAssets = useMemo(
() =>

View File

@@ -1,6 +1,13 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { useEffect } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
@@ -200,6 +207,33 @@ test('顶部 HUD 对齐拼图样式展示关卡名和倒计时', () => {
expect(screen.getByText('第 1 关')).toBeTruthy();
expect(screen.getByText('水果抓大鹅')).toBeTruthy();
expect(screen.getByText('10:00')).toBeTruthy();
expect(screen.getByRole('button', { name: '打开抓大鹅设置' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '重新开始' })).toBeNull();
});
test('抓大鹅右上角设置面板内置重新开始', () => {
const run = startLocalMatch3DRun(4);
const onRestart = vi.fn();
render(
<Match3DRuntimeShell
run={run}
levelName="水果抓大鹅"
onBack={vi.fn()}
onRestart={onRestart}
onOptimisticRunChange={vi.fn()}
onClickItem={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开抓大鹅设置' }));
const dialog = screen.getByRole('dialog', { name: '抓大鹅设置' });
expect(within(dialog).getByText('水果抓大鹅')).toBeTruthy();
expect(within(dialog).getByText('已清除 0/12')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '重新开始' }));
expect(onRestart).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('dialog', { name: '抓大鹅设置' })).toBeNull();
});
test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => {
@@ -991,7 +1025,7 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
const containerImage = screen.getByTestId(
'match3d-container-image',
) as HTMLImageElement;
expect(containerImage.className).toContain('w-[min(96vw,28rem)]');
expect(containerImage.className).toContain('w-[min(99vw,34rem)]');
expect(containerImage.className).toContain('h-auto');
expect(containerImage.className).toContain('left-1/2');
expect(containerImage.className).toContain('-translate-x-1/2');

View File

@@ -3,6 +3,7 @@ import {
CheckCircle2,
Clock3,
RotateCcw,
Settings,
Sparkles,
XCircle,
} from 'lucide-react';
@@ -49,11 +50,18 @@ import {
resolveRenderableItemFrame,
} from './match3dRuntimePresentation';
import {
MATCH3D_RUNTIME_BOARD_BASE_CLASS,
MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS,
MATCH3D_RUNTIME_BOARD_WIDTH,
MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS,
MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS,
MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS,
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
MATCH3D_RUNTIME_HEADER_CARD_CLASS,
MATCH3D_RUNTIME_LEVEL_BADGE_CLASS,
MATCH3D_RUNTIME_STAGE_CLASS,
MATCH3D_RUNTIME_TIMER_CLASS,
MATCH3D_RUNTIME_TIMER_URGENT_CLASS,
} from './match3dRuntimeUiStyles';
@@ -697,6 +705,7 @@ export function Match3DRuntimeShell({
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] =
useState('');
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const runtimeGeneratedItemAssets = useMemo(
@@ -1251,6 +1260,7 @@ export function Match3DRuntimeShell({
isRunState(run.status, 'running')
? MATCH3D_RUNTIME_TIMER_URGENT_CLASS
: MATCH3D_RUNTIME_TIMER_CLASS;
const canRestartRun = Boolean(run?.runId) && !isBusy;
return (
<main
@@ -1311,23 +1321,23 @@ export function Match3DRuntimeShell({
<button
type="button"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
onClick={onRestart}
aria-label="重新开始"
onClick={() => setIsSettingsPanelOpen(true)}
aria-label="打开抓大鹅设置"
>
<RotateCcw size={18} />
<Settings size={18} />
</button>
</header>
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
<section className={MATCH3D_RUNTIME_STAGE_CLASS}>
<div
ref={stageRef}
className={`relative aspect-square max-w-full ${
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} ${
hasRenderedContainerAsset
? 'overflow-visible bg-transparent'
: 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'
? MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS
: MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS
}`}
style={{
width: 'min(96vw, 60dvh, 100%)',
width: MATCH3D_RUNTIME_BOARD_WIDTH,
}}
onPointerDown={handleBoardPointerDown}
onPointerMove={handleBoardPointerMove}
@@ -1340,7 +1350,7 @@ export function Match3DRuntimeShell({
src={resolvedContainerImageSrc}
alt=""
aria-hidden="true"
className={`pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(96vw,28rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)] ${
className={`${MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS} ${
isContainerImageLoaded ? 'opacity-100' : 'opacity-0'
}`}
data-testid="match3d-container-image"
@@ -1355,7 +1365,7 @@ export function Match3DRuntimeShell({
}}
/>
) : (
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
<div className={MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS} />
)}
{run.items.map((item) =>
hasPendingMatch3DGeneratedImageForItem(
@@ -1462,6 +1472,84 @@ export function Match3DRuntimeShell({
onBack={onBack}
onRestart={onRestart}
/>
{isSettingsPanelOpen ? (
<div
className="absolute inset-0 z-[85] flex items-center justify-center bg-slate-950/42 px-4 py-6 backdrop-blur-sm"
onClick={() => setIsSettingsPanelOpen(false)}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="match3d-settings-title"
className="w-full max-w-[20.5rem] overflow-hidden rounded-[1.35rem] border border-white/18 bg-white/95 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
onClick={(event) => event.stopPropagation()}
>
<header className="border-b border-slate-200 px-5 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2
id="match3d-settings-title"
className="text-base font-black"
>
</h2>
</div>
<button
type="button"
aria-label="关闭设置"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:bg-slate-50 hover:text-slate-900"
onClick={() => setIsSettingsPanelOpen(false)}
>
<XCircle size={18} />
</button>
</div>
</header>
<div className="space-y-3 px-5 py-4">
<div className="rounded-[1rem] border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">
</div>
<div className="mt-2 text-sm font-black text-slate-900">
{displayLevelName}
</div>
<div className="mt-1 text-xs text-slate-500">
{run.clearedItemCount}/{run.totalItemCount}
</div>
</div>
<div className="rounded-[1rem] border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">
</div>
<div className="mt-2 font-mono text-xl font-black text-slate-900">
{formatTimer(timeLeftMs)}
</div>
</div>
</div>
<footer className="grid gap-3 border-t border-slate-200 px-5 py-4">
<button
type="button"
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-slate-950 px-4 py-3 text-sm font-black text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-45"
disabled={!canRestartRun}
onClick={() => {
setIsSettingsPanelOpen(false);
onRestart();
}}
>
<RotateCcw size={16} />
</button>
<button
type="button"
className="inline-flex min-h-12 items-center justify-center rounded-[1rem] border border-slate-200 bg-white px-4 py-3 text-sm font-bold text-slate-700 transition hover:bg-slate-50"
onClick={() => setIsSettingsPanelOpen(false)}
>
</button>
</footer>
</section>
</div>
) : null}
</main>
);
}

View File

@@ -25,3 +25,23 @@ export const MATCH3D_RUNTIME_GLASS_TRAY_CLASS =
export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS =
'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16';
export const MATCH3D_RUNTIME_STAGE_CLASS =
'relative mt-3 flex min-h-0 flex-1 items-center justify-center';
export const MATCH3D_RUNTIME_BOARD_BASE_CLASS =
'relative aspect-square max-w-full';
export const MATCH3D_RUNTIME_BOARD_WIDTH = 'min(96vw, 60dvh, 100%)';
export const MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS =
'overflow-visible bg-transparent';
export const MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS =
'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]';
export const MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS =
'pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(99vw,34rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]';
export const MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS =
'pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]';

View File

@@ -46,6 +46,7 @@ import type {
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkProfile,
Match3DWorkSummary,
@@ -433,6 +434,16 @@ type BabyObjectMatchRuntimeReturnStage =
type VisualNovelEntryGenerationPhase = 'generating' | 'ready' | 'failed';
type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed';
type RecommendRuntimeState = {
activeKind: RecommendRuntimeKind | null;
babyObjectMatchDraft: BabyObjectMatchDraft | null;
bigFishRun: BigFishRuntimeSnapshotResponse | null;
match3dRun: Match3DRunSnapshot | null;
puzzleRun: PuzzleRunSnapshot | null;
squareHoleRun: SquareHoleRunSnapshot | null;
visualNovelRun: VisualNovelRunSnapshot | null;
};
type PuzzleSaveArchiveState = {
runtimeKind?: unknown;
entryProfileId?: unknown;
@@ -529,6 +540,37 @@ function getPlatformRecommendRuntimeKind(
return 'rpg';
}
function isRecommendRuntimeReadyForEntry(
entry: PlatformPublicGalleryCard,
state: RecommendRuntimeState,
) {
const expectedKind = getPlatformRecommendRuntimeKind(entry);
if (state.activeKind !== expectedKind) {
return false;
}
if (expectedKind === 'big-fish') {
return Boolean(state.bigFishRun);
}
if (expectedKind === 'match3d') {
return Boolean(state.match3dRun);
}
if (expectedKind === 'puzzle') {
return Boolean(state.puzzleRun);
}
if (expectedKind === 'square-hole') {
return Boolean(state.squareHoleRun);
}
if (expectedKind === 'visual-novel') {
return Boolean(state.visualNovelRun);
}
if (expectedKind === 'edutainment') {
return Boolean(state.babyObjectMatchDraft);
}
return true;
}
function isSamePlatformPublicGalleryEntry(
left: PlatformPublicGalleryCard,
right: PlatformPublicGalleryCard,
@@ -631,7 +673,7 @@ function mapPublicWorkDetailToMatch3DWork(
return null;
}
return {
return promoteMatch3DGeneratedBackgroundAsset({
workId: entry.workId,
profileId: entry.profileId,
ownerUserId: entry.ownerUserId,
@@ -663,6 +705,51 @@ function mapPublicWorkDetailToMatch3DWork(
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
entry.generatedItemAssets ?? [],
),
});
}
function findMatch3DGeneratedBackgroundAsset(
generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
): Match3DGeneratedBackgroundAsset | null {
return (
generatedItemAssets
?.map((asset) => asset.backgroundAsset ?? null)
.find(Boolean) ?? null
);
}
function promoteMatch3DGeneratedBackgroundAsset<
T extends Pick<
Match3DWorkSummary,
| 'backgroundPrompt'
| 'backgroundImageSrc'
| 'backgroundImageObjectKey'
| 'generatedBackgroundAsset'
| 'generatedItemAssets'
>,
>(profile: T): T {
const backgroundAsset =
profile.generatedBackgroundAsset ??
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets);
if (!backgroundAsset) {
return profile;
}
return {
...profile,
backgroundPrompt: profile.backgroundPrompt ?? backgroundAsset.prompt ?? null,
backgroundImageSrc:
profile.backgroundImageSrc ??
backgroundAsset.imageSrc ??
backgroundAsset.imageObjectKey ??
null,
backgroundImageObjectKey:
profile.backgroundImageObjectKey ??
backgroundAsset.imageObjectKey ??
backgroundAsset.imageSrc ??
null,
generatedBackgroundAsset:
profile.generatedBackgroundAsset ?? backgroundAsset,
};
}
@@ -675,7 +762,10 @@ function buildMatch3DProfileFromSession(
}
const now = session.updatedAt || new Date().toISOString();
return {
const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime(
draft.generatedItemAssets,
);
return promoteMatch3DGeneratedBackgroundAsset({
workId: draft.profileId,
profileId: draft.profileId,
ownerUserId: 'current-user',
@@ -697,10 +787,8 @@ function buildMatch3DProfileFromSession(
backgroundImageSrc: draft.backgroundImageSrc ?? null,
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
draft.generatedItemAssets,
),
};
generatedItemAssets,
});
}
function hasMatch3DRuntimeAsset(
@@ -791,10 +879,14 @@ function resolveMatch3DRuntimeGeneratedBackgroundAsset(
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const profileBackground = profile?.generatedBackgroundAsset ?? null;
const profileBackground = profile
? promoteMatch3DGeneratedBackgroundAsset(profile).generatedBackgroundAsset ??
null
: null;
const publicBackground =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? (publicWorkDetail.generatedBackgroundAsset ?? null)
? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
.generatedBackgroundAsset ?? null)
: null;
if (runProfileId && profile?.profileId === runProfileId) {
@@ -832,18 +924,25 @@ function resolveMatch3DRuntimeBackgroundImageSrc(
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const resolvedProfile = profile
? promoteMatch3DGeneratedBackgroundAsset(profile)
: null;
const resolvedPublicWork =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
: null;
const profileBackground =
profile?.backgroundImageSrc?.trim() ||
profile?.generatedBackgroundAsset?.imageSrc?.trim() ||
profile?.backgroundImageObjectKey?.trim() ||
profile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
resolvedProfile?.backgroundImageSrc?.trim() ||
resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() ||
resolvedProfile?.backgroundImageObjectKey?.trim() ||
resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
'';
const publicBackground =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? publicWorkDetail.backgroundImageSrc?.trim() ||
publicWorkDetail.backgroundImageObjectKey?.trim() ||
''
: '';
resolvedPublicWork?.backgroundImageSrc?.trim() ||
resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() ||
resolvedPublicWork?.backgroundImageObjectKey?.trim() ||
resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
'';
if (runProfileId && profile?.profileId === runProfileId) {
return profileBackground || publicBackground || null;
@@ -2438,6 +2537,16 @@ export function PlatformEntryFlowShellImpl({
selectionStageRef.current = selectionStage;
}, [selectionStage]);
const resetRecommendRuntimeSelection = useCallback(() => {
// 中文注释:推荐页嵌入运行态进入改造/创作后会清掉玩法 run
// 同步清空推荐选择,避免返回推荐页时复用已失效的运行态占位。
recommendRuntimeStartRequestRef.current += 1;
setActiveRecommendEntryKey(null);
setActiveRecommendRuntimeKind(null);
setActiveRecommendRuntimeError(null);
setIsStartingRecommendEntry(false);
}, []);
const updatePendingDraftShelfItem = useCallback(
(
kind: Exclude<CreationWorkShelfKind, 'rpg'>,
@@ -3776,13 +3885,13 @@ export function PlatformEntryFlowShellImpl({
let runtimeProfile: Match3DWorkProfile | null = null;
try {
const { item } = await getMatch3DWorkDetail(profileId);
runtimeProfile = {
runtimeProfile = promoteMatch3DGeneratedBackgroundAsset({
...item,
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
response.session.draft?.generatedItemAssets,
item.generatedItemAssets,
),
};
});
setMatch3DProfile(runtimeProfile);
await refreshMatch3DShelf().catch(() => undefined);
} catch {
@@ -4875,13 +4984,13 @@ export function PlatformEntryFlowShellImpl({
let runtimeProfile: Match3DWorkProfile | null = null;
try {
const { item } = await getMatch3DWorkDetail(profileId);
runtimeProfile = {
runtimeProfile = promoteMatch3DGeneratedBackgroundAsset({
...item,
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
response.session.draft?.generatedItemAssets,
item.generatedItemAssets,
),
};
});
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DProfile(runtimeProfile);
}
@@ -5464,14 +5573,16 @@ export function PlatformEntryFlowShellImpl({
const leavePuzzleFlow = useCallback(() => {
setPuzzleOperation(null);
puzzleRunRef.current = null;
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleGenerationState(null);
setIsPuzzleNextLevelGenerating(false);
setActiveCreativeAgentSessionId(null);
setCreativeDraftEditError(null);
resetRecommendRuntimeSelection();
puzzleFlow.leaveFlow();
}, [puzzleFlow]);
}, [puzzleFlow, resetRecommendRuntimeSelection]);
const leaveVisualNovelFlow = useCallback(() => {
setVisualNovelWork(null);
@@ -6602,12 +6713,12 @@ export function PlatformEntryFlowShellImpl({
// 中文注释:详情补读只为拿完整生成素材;失败时继续按摘要开局,避免推荐流卡死。
}
}
runtimeProfile = {
runtimeProfile = promoteMatch3DGeneratedBackgroundAsset({
...runtimeProfile,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
runtimeProfile.generatedItemAssets,
),
};
});
await preloadMatch3DGeneratedRuntimeAssets(
runtimeProfile.generatedItemAssets,
runtimeProfile.generatedBackgroundAsset,
@@ -7314,6 +7425,7 @@ export function PlatformEntryFlowShellImpl({
void remixPuzzleGalleryWork(targetProfileId)
.then((response) => {
resetRecommendRuntimeSelection();
puzzleFlow.setSession(response.session);
setPuzzleOperation(null);
setPuzzleRun(null);
@@ -7338,6 +7450,7 @@ export function PlatformEntryFlowShellImpl({
isPuzzleBusy,
puzzleFlow,
resolvePuzzleErrorMessage,
resetRecommendRuntimeSelection,
runProtectedAction,
setIsPuzzleBusy,
setPuzzleError,
@@ -9639,30 +9752,51 @@ export function PlatformEntryFlowShellImpl({
return;
}
const hasActiveEntry =
activeRecommendEntryKey &&
recommendRuntimeEntries.some(
(entry) =>
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
);
if (hasActiveEntry || isStartingRecommendEntry) {
const activeRecommendEntry = activeRecommendEntryKey
? recommendRuntimeEntries.find(
(entry) =>
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
) ?? null
: null;
const isActiveRecommendRuntimeReady =
activeRecommendEntry !== null &&
isRecommendRuntimeReadyForEntry(activeRecommendEntry, {
activeKind: activeRecommendRuntimeKind,
babyObjectMatchDraft,
bigFishRun,
match3dRun,
puzzleRun,
squareHoleRun,
visualNovelRun,
});
if (
(activeRecommendEntry !== null && isActiveRecommendRuntimeReady) ||
isStartingRecommendEntry
) {
return;
}
const firstRecommendEntry = recommendRuntimeEntries[0];
if (firstRecommendEntry) {
void selectRecommendRuntimeEntry(firstRecommendEntry);
const nextRecommendEntry = activeRecommendEntry ?? recommendRuntimeEntries[0];
if (nextRecommendEntry) {
void selectRecommendRuntimeEntry(nextRecommendEntry);
}
}, [
activeRecommendEntryKey,
activeRecommendRuntimeKind,
babyObjectMatchDraft,
bigFishRun,
isStartingRecommendEntry,
match3dRun,
platformBootstrap.canReadProtectedData,
platformBootstrap.isLoadingPlatform,
platformBootstrap.isAuthenticated,
platformBootstrap.platformTab,
puzzleRun,
recommendRuntimeEntries,
selectRecommendRuntimeEntry,
selectionStage,
squareHoleRun,
visualNovelRun,
]);
const remixPublicWork = useCallback(
@@ -9696,6 +9830,7 @@ export function PlatformEntryFlowShellImpl({
if (isPuzzleGalleryEntry(entry)) {
void remixPuzzleGalleryWork(entry.profileId)
.then((response) => {
resetRecommendRuntimeSelection();
puzzleFlow.setSession(response.session);
setPuzzleOperation(null);
enterCreateTab();
@@ -9765,6 +9900,7 @@ export function PlatformEntryFlowShellImpl({
isPublicWorkDetailBusy,
platformBootstrap,
puzzleFlow,
resetRecommendRuntimeSelection,
resolveBigFishErrorMessage,
resolvePuzzleErrorMessage,
runProtectedAction,

View File

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

View File

@@ -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>

View File

@@ -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(' ');

View File

@@ -6682,6 +6682,97 @@ test('first puzzle runtime back click can open remix result page', async () => {
expect(screen.getByDisplayValue('改造后的雨夜猫塔')).toBeTruthy();
});
test('recommend puzzle remix return restarts recommendation instead of stale loading run', async () => {
const user = userEvent.setup();
const puzzleWork: PuzzleWorkSummary = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
remixCount: 0,
likeCount: 0,
publishReady: true,
};
const anchorPack = buildPuzzleAnchorPack();
const remixDraft: PuzzleResultDraft = {
workTitle: '改造后的雨夜猫塔',
workDescription: '准备改造的拼图草稿。',
levelName: '改造后的雨夜猫塔',
summary: '一只猫站在雨夜塔顶。',
themeTags: ['雨夜', '猫咪', '塔'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
levels: [],
metadata: null,
};
const remixSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-remix-1',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack,
draft: remixDraft,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-25T12:12:00.000Z',
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [puzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: puzzleWork,
});
vi.mocked(remixPuzzleGalleryWork).mockResolvedValue({
session: remixSession,
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '改造 0' }));
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('改造后的雨夜猫塔')).toBeTruthy();
vi.mocked(startPuzzleRun).mockClear();
await user.click(screen.getByRole('button', { name: '返回' }));
await clickFirstButtonByName(user, '推荐');
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(screen.queryByText('正在进入拼图关卡')).toBeNull();
});
test('public code search opens a published puzzle by PZ code', async () => {
const user = userEvent.setup();
const puzzleWork: PuzzleWorkSummary = {

View File

@@ -1526,6 +1526,23 @@ test('profile played works card shows count unit', () => {
expect(within(playedCard).getByText('1个')).toBeTruthy();
});
test('profile stats cards are centered without update timestamp', () => {
renderProfileView(vi.fn(), {
updatedAt: '2026-05-03T08:01:00Z',
});
const walletCard = screen.getByRole('button', { name: /\s*0/u });
const playTimeCard = screen.getByRole('button', { name: //u });
const playedCard = screen.getByRole('button', { name: /\s*0/u });
for (const card of [walletCard, playTimeCard, playedCard]) {
expect(card.className).toContain('items-center');
expect(card.className).toContain('justify-center');
expect(card.className).toContain('text-center');
}
expect(screen.queryByText(//u)).toBeNull();
});
test('desktop account entry uses saved avatar image when available', () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';
@@ -2815,9 +2832,7 @@ test('mobile game category filter dialog filters by play type', async () => {
await user.click(within(filterDialog).getByRole('button', { name: '抓鹅' }));
expect(
screen.queryByRole('button', { name: //u }),
).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});

View File

@@ -2118,24 +2118,6 @@ function formatDashboardCount(value: number) {
return normalizedValue.toLocaleString('zh-CN');
}
function formatDashboardUpdatedAt(value: string | null | undefined) {
if (!value) {
return '暂无更新记录';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function isWithinProfileInviteRedeemWindow(
createdAt: string | null | undefined,
) {
@@ -2309,13 +2291,15 @@ function ProfileStatCard({
<button
type="button"
onClick={onClick ? () => onClick(cardKey) : undefined}
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
>
<div className="flex items-center gap-2 text-[var(--platform-text-soft)]">
<div className="flex w-full items-center justify-center gap-2 text-[var(--platform-text-soft)]">
<Icon className="h-4 w-4" />
<span className="text-[11px] tracking-[0.16em]">{label}</span>
<span className="whitespace-nowrap text-[11px] tracking-[0.16em]">
{label}
</span>
</div>
<div className="mt-3 text-lg font-black text-[var(--platform-text-strong)]">
<div className="mt-2 whitespace-nowrap text-lg font-black leading-none text-[var(--platform-text-strong)]">
{value}
</div>
</button>
@@ -2324,9 +2308,9 @@ function ProfileStatCard({
function ProfileStatCardSkeleton() {
return (
<div className="platform-subpanel rounded-[1.35rem] px-4 py-3">
<div className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center">
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
<div className="mt-2 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
</div>
);
}
@@ -5656,11 +5640,6 @@ export function RpgEntryHomeView({
</>
)}
</div>
<div className="mt-3 text-[11px] text-[var(--platform-text-soft)]">
{dashboardError
? dashboardError
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
</div>
</section>
<section