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
@@ -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();
@@ -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">
@@ -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 () => {
@@ -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(
() =>
@@ -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');
@@ -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>
);
}
@@ -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%)]';
@@ -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,
@@ -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);
});
File diff suppressed because it is too large Load Diff
@@ -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(' ');
@@ -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 = {
@@ -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();
});
+8 -29
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
+76 -12
View File
@@ -616,8 +616,17 @@ body {
--puzzle-runtime-danger-fill: rgba(255, 228, 233, 0.9);
--puzzle-runtime-danger-text: #c2415d;
--puzzle-runtime-backdrop-fill: rgba(43, 20, 32, 0.34);
--puzzle-runtime-dialog-fill: var(--platform-modal-fill);
--puzzle-runtime-dialog-border: var(--platform-modal-border);
--puzzle-runtime-dialog-fill: radial-gradient(
circle at 12% 0%,
rgba(255, 91, 132, 0.18),
transparent 36%
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.98),
rgba(255, 246, 249, 0.95)
);
--puzzle-runtime-dialog-border: rgba(255, 126, 154, 0.3);
--puzzle-runtime-table-fill: rgba(255, 255, 255, 0.62);
--puzzle-runtime-table-row-fill: rgba(255, 91, 132, 0.12);
--puzzle-runtime-next-card-fill: rgba(255, 255, 255, 0.66);
@@ -848,8 +857,13 @@ body {
--puzzle-runtime-danger-fill: rgba(239, 68, 68, 0.2);
--puzzle-runtime-danger-text: #fee2e2;
--puzzle-runtime-backdrop-fill: rgba(2, 6, 23, 0.68);
--puzzle-runtime-dialog-fill: rgba(2, 6, 23, 0.94);
--puzzle-runtime-dialog-border: rgba(255, 255, 255, 0.14);
--puzzle-runtime-dialog-fill: radial-gradient(
circle at 14% 0%,
rgba(251, 191, 36, 0.16),
transparent 36%
),
linear-gradient(180deg, rgba(29, 17, 12, 0.96), rgba(2, 6, 23, 0.96));
--puzzle-runtime-dialog-border: rgba(251, 191, 36, 0.22);
--puzzle-runtime-table-fill: rgba(255, 255, 255, 0.06);
--puzzle-runtime-table-row-fill: rgba(251, 191, 36, 0.14);
--puzzle-runtime-next-card-fill: rgba(255, 255, 255, 0.06);
@@ -1735,14 +1749,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
line-height: 1;
}
.creation-work-card__swipe-button--share {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--platform-cool-text) 56%, #2563eb 44%),
#172554
);
}
.creation-work-card__swipe-button--danger {
background: linear-gradient(180deg, #fb7185, #e11d48);
}
@@ -1805,6 +1811,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.creation-work-card__title-lockup {
display: flex;
flex: 1 1 auto;
min-width: 0;
align-items: flex-start;
gap: 0.42rem;
@@ -1853,6 +1860,34 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
white-space: normal;
}
.creation-work-card__share-button {
display: inline-flex;
width: 2rem;
height: 2rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 0;
border-radius: 9999px;
background: transparent;
color: var(--platform-text-soft);
transition:
background-color 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.creation-work-card__share-button:hover {
transform: translateY(-1px);
background: color-mix(in srgb, var(--platform-cool-bg) 24%, transparent);
color: var(--platform-cool-text);
}
.creation-work-card__share-button:focus-visible {
outline: 2px solid var(--platform-cool-border);
outline-offset: 2px;
}
.creation-work-card__meta {
display: flex;
min-width: 0;
@@ -2651,9 +2686,38 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.puzzle-runtime-dialog {
position: relative;
border: 1px solid var(--puzzle-runtime-dialog-border);
background: var(--puzzle-runtime-dialog-fill);
color: var(--puzzle-runtime-text-strong);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
backdrop-filter: blur(18px);
}
.puzzle-runtime-dialog::before {
content: '';
pointer-events: none;
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.16),
transparent 36%
),
radial-gradient(
circle at 86% 8%,
var(--puzzle-runtime-control-hover-fill),
transparent 28%
);
}
.puzzle-runtime-dialog > * {
position: relative;
z-index: 1;
}
.puzzle-runtime-dialog__line {
+2 -2
View File
@@ -59,7 +59,7 @@ describe('RouteImageReadyGate image url helpers', () => {
RouteImageReadyGate,
{
eyebrow: '正在载入游戏',
text: '正在载入冒险...',
text: '正在加载内容',
},
createElement(
'section',
@@ -100,7 +100,7 @@ describe('RouteImageReadyGate image url helpers', () => {
const { container } = render(
createElement(RouteLoadingScreen, {
eyebrow: '正在载入游戏',
text: '正在载入冒险...',
text: '正在加载内容',
}),
);
const shell = container.firstElementChild;
+1 -1
View File
@@ -164,7 +164,7 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
return {
kind: 'game',
loadingEyebrow: '正在载入游戏',
loadingText: '正在载入冒险...',
loadingText: '正在加载内容',
Component: GameApp,
};
}