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

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