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:
@@ -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%)]';
|
||||
|
||||
Reference in New Issue
Block a user