feat: 完善敲木鱼玩法模板链路

This commit is contained in:
2026-05-24 02:49:13 +08:00
parent 2ba4691bc0
commit 8638397faa
402 changed files with 2329 additions and 1781 deletions

View File

@@ -7589,8 +7589,6 @@ export function PlatformEntryFlowShellImpl({
hitObjectReferenceImageSrc:
payload?.hitObjectReferenceImageSrc ??
created.session.draft?.hitObjectReferenceImageSrc,
hitSoundPrompt:
payload?.hitSoundPrompt ?? created.session.draft?.hitSoundPrompt,
hitSoundAsset:
payload?.hitSoundAsset ?? created.session.draft?.hitSoundAsset,
floatingWords:
@@ -7643,7 +7641,7 @@ export function PlatformEntryFlowShellImpl({
}, [compileWoodenFishSession, setSelectionStage, woodenFishSession]);
const regenerateWoodenFishAsset = useCallback(
async (actionType: 'regenerate-hit-object' | 'generate-hit-sound') => {
async (actionType: 'regenerate-hit-object') => {
if (!woodenFishSession?.sessionId) {
setSelectionStage('wooden-fish-workspace');
return;
@@ -7665,7 +7663,6 @@ export function PlatformEntryFlowShellImpl({
hitObjectPrompt: woodenFishSession.draft?.hitObjectPrompt,
hitObjectReferenceImageSrc:
woodenFishSession.draft?.hitObjectReferenceImageSrc,
hitSoundPrompt: woodenFishSession.draft?.hitSoundPrompt,
hitSoundAsset: woodenFishSession.draft?.hitSoundAsset,
floatingWords: woodenFishSession.draft?.floatingWords,
},
@@ -7679,9 +7676,7 @@ export function PlatformEntryFlowShellImpl({
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
error,
actionType === 'regenerate-hit-object'
? '重新生成敲击物图案失败。'
: '生成敲击音效失败。',
'重新生成敲击物图案失败。',
);
setWoodenFishError(errorMessage);
setWoodenFishGenerationState(
@@ -7811,33 +7806,6 @@ export function PlatformEntryFlowShellImpl({
[setSelectionStage],
);
const restartWoodenFishRuntimeRun = useCallback(async () => {
const profileId =
woodenFishRun?.profileId?.trim() ??
woodenFishWork?.summary.profileId?.trim();
if (!profileId) {
await startWoodenFishTestRunFromProfile();
return;
}
setIsWoodenFishBusy(true);
setWoodenFishError(null);
try {
const response = await woodenFishClient.startRun(profileId);
setWoodenFishRun(response.run);
} catch (error) {
setWoodenFishError(
resolveRpgCreationErrorMessage(error, '重新开始敲木鱼失败。'),
);
} finally {
setIsWoodenFishBusy(false);
}
}, [
startWoodenFishTestRunFromProfile,
woodenFishRun?.profileId,
woodenFishWork?.summary.profileId,
]);
const checkpointWoodenFishRuntimeRun = useCallback(
async (payload: {
totalTapCount: number;
@@ -7853,31 +7821,6 @@ export function PlatformEntryFlowShellImpl({
[woodenFishRun?.runId],
);
const finishWoodenFishRuntimeRun = useCallback(
async (payload: {
totalTapCount: number;
wordCounters: WoodenFishRunResponse['run']['wordCounters'];
}) => {
const runId = woodenFishRun?.runId;
if (!runId) {
return;
}
setIsWoodenFishBusy(true);
setWoodenFishError(null);
try {
const response = await woodenFishClient.finishRun(runId, payload);
setWoodenFishRun(response.run);
} catch (error) {
setWoodenFishError(
resolveRpgCreationErrorMessage(error, '结束敲木鱼失败。'),
);
} finally {
setIsWoodenFishBusy(false);
}
},
[woodenFishRun?.runId],
);
const executePuzzleAction = puzzleFlow.executeAction;
const executePuzzleBackgroundAction = useCallback(
@@ -11479,11 +11422,7 @@ export function PlatformEntryFlowShellImpl({
onBack={() => {
setActiveRecommendRuntimeKind(null);
}}
onRestart={() => {
void restartWoodenFishRuntimeRun();
}}
onCheckpoint={checkpointWoodenFishRuntimeRun}
onFinish={finishWoodenFishRuntimeRun}
/>
);
}
@@ -11650,7 +11589,6 @@ export function PlatformEntryFlowShellImpl({
resolveSquareHoleErrorMessage,
reportBigFishObservedPlayTime,
restartBigFishRun,
restartWoodenFishRuntimeRun,
selectedPuzzleDetail,
selectionStage,
setMatch3DError,
@@ -11674,7 +11612,6 @@ export function PlatformEntryFlowShellImpl({
visualNovelSession,
visualNovelWork,
checkpointWoodenFishRuntimeRun,
finishWoodenFishRuntimeRun,
]);
useEffect(() => {
@@ -14449,9 +14386,6 @@ export function PlatformEntryFlowShellImpl({
onRegenerateHitObject={() => {
void regenerateWoodenFishAsset('regenerate-hit-object');
}}
onGenerateHitSound={() => {
void regenerateWoodenFishAsset('generate-hit-sound');
}}
/>
</Suspense>
</motion.div>
@@ -14476,11 +14410,7 @@ export function PlatformEntryFlowShellImpl({
onBack={() => {
setSelectionStage(woodenFishRuntimeReturnStage);
}}
onRestart={() => {
void restartWoodenFishRuntimeRun();
}}
onCheckpoint={checkpointWoodenFishRuntimeRun}
onFinish={finishWoodenFishRuntimeRun}
/>
</Suspense>
</motion.div>

View File

@@ -23,3 +23,20 @@ test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀'
expect(within(section as HTMLElement).queryByDisplayValue('幸运+1')).toBeNull();
expect(within(section as HTMLElement).queryByDisplayValue('功德+1')).toBeNull();
});
test('敲击音效临时关闭提示词生成入口,仅保留上传和录音', () => {
render(
<WoodenFishWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const sectionTitle = screen.getByText('敲击音效');
const section = sectionTitle.closest('section');
expect(section).not.toBeNull();
expect(within(section as HTMLElement).queryByText('音效描述')).toBeNull();
expect(within(section as HTMLElement).getByText('上传')).toBeTruthy();
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
});

View File

@@ -15,7 +15,10 @@ import type {
} from '../../../packages/shared/src/contracts/woodenFish';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../services/wooden-fish/woodenFishDefaults';
import {
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
} from '../../services/wooden-fish/woodenFishDefaults';
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
type WoodenFishWorkspaceProps = {
@@ -34,7 +37,6 @@ type WoodenFishWorkspaceFormState = {
themeTags: string;
hitObjectPrompt: string;
hitObjectReferenceImageSrc: string;
hitSoundPrompt: string;
hitSoundAsset: WoodenFishAudioAsset | null;
floatingWords: string[];
};
@@ -56,7 +58,6 @@ const DEFAULT_FORM_STATE: WoodenFishWorkspaceFormState = {
themeTags: '敲木鱼 解压',
hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
hitObjectReferenceImageSrc: '',
hitSoundPrompt: '清脆短促的木鱼敲击声',
hitSoundAsset: null,
floatingWords: DEFAULT_FLOATING_WORDS,
};
@@ -111,16 +112,12 @@ function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') {
function WoodenFishAudioInputPanel({
disabled,
prompt,
asset,
onPromptChange,
onAssetChange,
onError,
}: {
disabled: boolean;
prompt: string;
asset: WoodenFishAudioAsset | null;
onPromptChange: (value: string) => void;
onAssetChange: (asset: WoodenFishAudioAsset | null) => void;
onError: (message: string | null) => void;
}) {
@@ -201,18 +198,6 @@ function WoodenFishAudioInputPanel({
</button>
) : null}
</div>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={prompt}
disabled={disabled || Boolean(asset)}
onChange={(event) => onPromptChange(event.target.value)}
rows={2}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<div className="mt-3 flex flex-wrap items-center gap-2">
<label
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
@@ -270,7 +255,7 @@ function WoodenFishAudioInputPanel({
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{asset ? '音效已选择' : '可生成、上传或录制'}
{asset ? '音效已选择' : '默认木鱼音'}
</div>
)}
</div>
@@ -317,10 +302,9 @@ export function WoodenFishWorkspace({
hitObjectPrompt: formState.hitObjectPrompt.trim(),
hitObjectReferenceImageSrc:
formState.hitObjectReferenceImageSrc.trim() || null,
hitSoundPrompt: formState.hitSoundAsset
? null
: formState.hitSoundPrompt.trim() || null,
hitSoundAsset: formState.hitSoundAsset,
hitSoundPrompt: null,
hitSoundAsset:
formState.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
floatingWords: normalizedFloatingWords,
};
const response = await woodenFishClient.createSession(payload);
@@ -462,14 +446,7 @@ export function WoodenFishWorkspace({
<WoodenFishAudioInputPanel
disabled={isBusy || isSubmitting}
prompt={formState.hitSoundPrompt}
asset={formState.hitSoundAsset}
onPromptChange={(value) =>
setFormState((current) => ({
...current,
hitSoundPrompt: value,
}))
}
onAssetChange={(asset) =>
setFormState((current) => ({
...current,

View File

@@ -0,0 +1,66 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { WoodenFishDraftResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { WoodenFishResultView } from './WoodenFishResultView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src?.trim() ?? '',
isResolving: false,
shouldResolve: false,
}),
}));
function createDraft(): WoodenFishDraftResponse {
return {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: 'wooden-fish-profile-1',
workTitle: '今日敲木鱼',
workDescription: '',
themeTags: ['敲木鱼'],
hitObjectPrompt: '金色木鱼',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['幸运', '功德'],
hitObjectAsset: null,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: null,
coverImageSrc: null,
generationStatus: 'ready',
};
}
test('结果页缺少音频资产时使用默认木鱼音且不展示生成音效入口', () => {
const { container } = render(
<WoodenFishResultView
profile={createDraft()}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateHitObject={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '音效' })).toBeNull();
expect(container.querySelector('audio')?.getAttribute('src')).toBe(
'/wooden-fish/default-hit-sound.mp3',
);
});

View File

@@ -4,7 +4,6 @@ import {
Play,
RefreshCcw,
Send,
Volume2,
} from 'lucide-react';
import { useState } from 'react';
@@ -13,7 +12,10 @@ import type {
WoodenFishWorkProfileResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC } from '../../services/wooden-fish/woodenFishDefaults';
import {
WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC,
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
} from '../../services/wooden-fish/woodenFishDefaults';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type WoodenFishResultViewProps = {
@@ -25,7 +27,6 @@ type WoodenFishResultViewProps = {
onStartTestRun: () => void;
onPublish: () => void;
onRegenerateHitObject: () => void;
onGenerateHitSound: () => void;
};
function isWoodenFishWorkProfile(
@@ -43,7 +44,6 @@ export function WoodenFishResultView({
onStartTestRun,
onPublish,
onRegenerateHitObject,
onGenerateHitSound,
}: WoodenFishResultViewProps) {
const [isPublishing, setIsPublishing] = useState(false);
const isWorkProfile = isWoodenFishWorkProfile(profile);
@@ -59,8 +59,8 @@ export function WoodenFishResultView({
: draft.backgroundAsset;
const backgroundSrc = backgroundAsset?.imageSrc?.trim() || '';
const hitSoundAsset = isWorkProfile
? profile.hitSoundAsset
: draft.hitSoundAsset;
? profile.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET
: draft.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET;
const floatingWords = isWorkProfile ? profile.floatingWords : draft.floatingWords;
const title =
summary?.workTitle?.trim() || draft.workTitle.trim() || '敲木鱼';
@@ -90,26 +90,15 @@ export function WoodenFishResultView({
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex gap-2">
<button
type="button"
onClick={onRegenerateHitObject}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<RefreshCcw className="h-4 w-4" />
</button>
<button
type="button"
onClick={onGenerateHitSound}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<Volume2 className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={onRegenerateHitObject}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<RefreshCcw className="h-4 w-4" />
</button>
</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(19rem,0.75fr)]">

View File

@@ -0,0 +1,249 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { WoodenFishWorkProfileResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { WoodenFishRuntimeShell } from './WoodenFishRuntimeShell';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src?.trim() ?? '',
isResolving: false,
shouldResolve: false,
}),
}));
let audioConstructor: ReturnType<typeof vi.fn>;
function createProfile(): WoodenFishWorkProfileResponse {
return {
summary: {
runtimeKind: 'wooden-fish',
workId: 'wooden-fish-work-1',
profileId: 'wooden-fish-profile-1',
ownerUserId: 'user-1',
sourceSessionId: null,
workTitle: '今日敲木鱼',
workDescription: '',
themeTags: ['敲木鱼'],
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-22T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'ready',
},
draft: {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: 'wooden-fish-profile-1',
workTitle: '今日敲木鱼',
workDescription: '',
themeTags: ['敲木鱼'],
hitObjectPrompt: '金色木鱼',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['幸运', '功德'],
hitObjectAsset: {
assetId: 'wooden-fish-hit-object-1',
imageSrc: '/wooden-fish/default-hit-object.png',
imageObjectKey: 'public/wooden-fish/default-hit-object.png',
assetObjectId: 'wooden-fish-hit-object-asset',
generationProvider: 'bundled-default',
prompt: '默认敲击物图案,圆润木质质感,透明背景',
width: 1024,
height: 1024,
},
backgroundAsset: null,
backButtonAsset: {
assetId: 'wooden-fish-back-button-1',
imageSrc: '/generated/back-button.png',
imageObjectKey: 'generated/back-button.png',
assetObjectId: 'wooden-fish-back-button-asset',
generationProvider: 'image2',
prompt: '主题返回按钮',
width: 1024,
height: 1024,
},
hitSoundAsset: null,
coverImageSrc: null,
generationStatus: 'ready',
},
hitObjectAsset: {
assetId: 'wooden-fish-hit-object-1',
imageSrc: '/wooden-fish/default-hit-object.png',
imageObjectKey: 'public/wooden-fish/default-hit-object.png',
assetObjectId: 'wooden-fish-hit-object-asset',
generationProvider: 'bundled-default',
prompt: '默认敲击物图案,圆润木质质感,透明背景',
width: 1024,
height: 1024,
},
backgroundAsset: null,
backButtonAsset: {
assetId: 'wooden-fish-back-button-1',
imageSrc: '/generated/back-button.png',
imageObjectKey: 'generated/back-button.png',
assetObjectId: 'wooden-fish-back-button-asset',
generationProvider: 'image2',
prompt: '主题返回按钮',
width: 1024,
height: 1024,
},
hitSoundAsset: null as unknown as WoodenFishWorkProfileResponse['hitSoundAsset'],
floatingWords: ['幸运', '功德'],
};
}
function createRun() {
return {
runId: 'wooden-fish-run-1',
profileId: 'wooden-fish-profile-1',
ownerUserId: 'user-1',
status: 'playing' as const,
totalTapCount: 284,
wordCounters: [
{ text: '幸运', count: 63 },
{ text: '功德', count: 17 },
],
startedAtMs: 1_717_000_000_000,
updatedAtMs: 1_717_000_006_000,
finishedAtMs: null,
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
audioConstructor = vi.fn().mockImplementation((src: string) => ({
src,
preload: '',
pause: vi.fn(),
play: vi.fn().mockResolvedValue(undefined),
currentTime: 0,
}));
vi.stubGlobal('Audio', audioConstructor);
});
test('运行态缺少音频资产时使用默认木鱼音', () => {
render(<WoodenFishRuntimeShell profile={createProfile()} />);
expect(audioConstructor).toHaveBeenCalledWith(
'/wooden-fish/default-hit-sound.mp3',
);
});
test('顶部只展示总数,点击后展开子项计数器面板,点外部收起', () => {
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
const totalCounterButton = screen.getByRole('button', { name: /总数 284/ });
expect(totalCounterButton).toBeTruthy();
expect(screen.queryByTestId('wooden-fish-counter-panel')).toBeNull();
expect(screen.queryByText('幸运')).toBeNull();
expect(screen.queryByText('功德')).toBeNull();
fireEvent.click(totalCounterButton);
const panel = screen.getByTestId('wooden-fish-counter-panel');
const panelRows = within(panel).getAllByText(/幸运|功德/);
expect(panelRows).toHaveLength(2);
expect(within(panel).getByText('63')).toBeTruthy();
expect(within(panel).getByText('17')).toBeTruthy();
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId('wooden-fish-counter-panel')).toBeNull();
});
test('木鱼运行态不渲染右上角重开按钮', () => {
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
expect(screen.queryByRole('button', { name: '重开' })).toBeNull();
});
test('木鱼运行态使用生成的主题返回按钮图', () => {
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
const backButton = screen.getByRole('button', { name: '返回' });
const image = backButton.querySelector('img');
expect(image?.getAttribute('src')).toBe('/generated/back-button.png');
expect(backButton.className).not.toContain('backdrop-blur');
expect(backButton.className).not.toContain('bg-white');
expect(backButton.className).toContain('h-10');
expect(backButton.className).toContain('w-10');
});
test('木鱼运行态飘字去掉底板并放大字号', () => {
const { container } = render(
<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />,
);
const playfield = container.querySelector('.wooden-fish-runtime');
expect(playfield).not.toBeNull();
if (!playfield) {
throw new Error('缺少木鱼运行态根节点。');
}
fireEvent.pointerDown(playfield, { clientX: 260, clientY: 360 });
const floatingText = screen.getByText(/幸运|功德/);
expect(floatingText.className).toContain('text-[1.75rem]');
expect(floatingText.className).not.toContain('bg-slate-950/78');
expect(floatingText.className).not.toContain('rounded-full');
});
test('子项计数器面板预置全部词条未出现词条初始值为0', () => {
render(
<WoodenFishRuntimeShell
profile={createProfile()}
run={{
...createRun(),
totalTapCount: 63,
wordCounters: [{ text: '幸运', count: 63 }],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /总数 63/ }));
const panel = screen.getByTestId('wooden-fish-counter-panel');
expect(within(panel).getByText('幸运')).toBeTruthy();
expect(within(panel).getByText('63')).toBeTruthy();
expect(within(panel).getByText('功德')).toBeTruthy();
expect(within(panel).getByText('0')).toBeTruthy();
});
test('顶部总数条的 logo 使用紧凑标题条布局', () => {
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
const totalCounterButton = screen.getByRole('button', { name: /总数 284/ });
const logo = totalCounterButton.querySelector(
'[data-testid="wooden-fish-runtime-logo"]',
) as HTMLImageElement | null;
expect(logo).toBeTruthy();
expect(logo?.className).toContain('wooden-fish-runtime__counter-logo__image');
expect(logo?.tagName).toBe('IMG');
});
test('木鱼运行态不渲染底部进行中和结束操作区', () => {
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
expect(screen.queryByText('进行中')).toBeNull();
expect(screen.queryByRole('button', { name: '结束' })).toBeNull();
});

View File

@@ -1,4 +1,4 @@
import { ArrowLeft, Loader2, RotateCcw, X } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
import {
type CSSProperties,
type PointerEvent,
@@ -13,8 +13,12 @@ import type {
WoodenFishWordCounter,
WoodenFishWorkProfileResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import woodenFishRuntimeLogo from '../../../media/logo.png';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC } from '../../services/wooden-fish/woodenFishDefaults';
import {
WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC,
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
} from '../../services/wooden-fish/woodenFishDefaults';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
applyWoodenFishTap,
@@ -34,11 +38,6 @@ type WoodenFishRuntimeShellProps = {
totalTapCount: number;
wordCounters: WoodenFishWordCounter[];
}) => Promise<unknown>;
onFinish?: (payload: {
totalTapCount: number;
wordCounters: WoodenFishWordCounter[];
}) => Promise<unknown>;
onRestart?: () => void;
onExit?: () => void;
onBack?: () => void;
};
@@ -67,8 +66,6 @@ export function WoodenFishRuntimeShell({
isBusy = false,
error = null,
onCheckpoint,
onFinish,
onRestart,
onExit,
onBack,
}: WoodenFishRuntimeShellProps) {
@@ -80,17 +77,27 @@ export function WoodenFishRuntimeShell({
const [wordCounters, setWordCounters] = useState<WoodenFishWordCounter[]>(
activeRun?.wordCounters ?? [],
);
const [isCounterPanelOpen, setIsCounterPanelOpen] = useState(false);
const [floatingTexts, setFloatingTexts] = useState<FloatingText[]>([]);
const [hitPulse, setHitPulse] = useState(0);
const audioPoolRef = useRef<HTMLAudioElement[]>([]);
const audioIndexRef = useRef(0);
const lastAudioAtRef = useRef(0);
const lastCheckpointAtRef = useRef(0);
const counterMenuRef = useRef<HTMLDivElement>(null);
const currentSnapshotRef = useRef({ totalTapCount, wordCounters });
const words = useMemo(
() => normalizeWoodenFishFloatingWords(profile?.floatingWords ?? []),
[profile?.floatingWords],
);
const counterEntries = useMemo(
() =>
words.map((word) => ({
text: word,
count: wordCounters.find((counter) => counter.text === word)?.count ?? 0,
})),
[words, wordCounters],
);
const hitObjectSrc =
profile?.hitObjectAsset?.imageSrc?.trim() ||
profile?.draft.hitObjectAsset?.imageSrc?.trim() ||
@@ -99,8 +106,14 @@ export function WoodenFishRuntimeShell({
profile?.backgroundAsset?.imageSrc?.trim() ||
profile?.draft.backgroundAsset?.imageSrc?.trim() ||
'';
const backButtonSrc =
profile?.backButtonAsset?.imageSrc?.trim() ||
profile?.draft.backButtonAsset?.imageSrc?.trim() ||
'';
const hitSoundSrc =
profile?.hitSoundAsset?.audioSrc ?? profile?.draft.hitSoundAsset?.audioSrc;
profile?.hitSoundAsset?.audioSrc ??
profile?.draft.hitSoundAsset?.audioSrc ??
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET.audioSrc;
const { resolvedUrl: resolvedAudioUrl } = useResolvedAssetReadUrl(hitSoundSrc);
useEffect(() => {
@@ -112,6 +125,32 @@ export function WoodenFishRuntimeShell({
setWordCounters(activeRun?.wordCounters ?? []);
}, [activeRun?.runId, activeRun?.totalTapCount, activeRun?.wordCounters]);
useEffect(() => {
setIsCounterPanelOpen(false);
}, [activeRun?.runId]);
useEffect(() => {
if (!isCounterPanelOpen) {
return undefined;
}
const handleDocumentPointerDown = (event: globalThis.PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (counterMenuRef.current?.contains(target)) {
return;
}
setIsCounterPanelOpen(false);
};
document.addEventListener('pointerdown', handleDocumentPointerDown);
return () => {
document.removeEventListener('pointerdown', handleDocumentPointerDown);
};
}, [isCounterPanelOpen]);
useEffect(() => {
audioPoolRef.current.forEach((audio) => {
audio.pause();
@@ -210,11 +249,6 @@ export function WoodenFishRuntimeShell({
playHitSound();
};
const finishRun = async () => {
const payload = currentSnapshotRef.current;
await onFinish?.(payload);
};
return (
<div
className="wooden-fish-runtime relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#f7f4ec] text-slate-950"
@@ -239,37 +273,84 @@ export function WoodenFishRuntimeShell({
<button
type="button"
onClick={exitHandler}
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/84 px-3 py-2 text-sm shadow-sm backdrop-blur"
aria-label="返回"
className={`wooden-fish-runtime__back-button min-h-0 overflow-hidden ${
backButtonSrc
? 'grid h-10 w-10 place-items-center rounded-full bg-transparent p-0 shadow-none sm:h-11 sm:w-11'
: 'platform-button platform-button--ghost bg-white/84 px-3 py-2 text-sm'
}`}
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex max-w-[58vw] flex-wrap justify-center gap-1.5">
<span className="rounded-full border border-white/70 bg-white/84 px-3 py-2 text-sm font-black shadow-sm backdrop-blur">
{totalTapCount}
</span>
{wordCounters.map((counter) => (
<span
key={counter.text}
className="rounded-full border border-white/70 bg-white/84 px-2.5 py-2 text-xs font-black shadow-sm backdrop-blur"
>
{counter.text} {counter.count}
</span>
))}
</div>
<button
type="button"
onClick={onRestart}
disabled={isBusy || !onRestart}
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/84 px-3 py-2 text-sm shadow-sm backdrop-blur"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
{backButtonSrc ? (
<ResolvedAssetImage
src={backButtonSrc}
alt=""
aria-hidden="true"
draggable={false}
className="h-full w-full object-contain drop-shadow-[0_8px_12px_rgba(63,36,18,0.2)]"
/>
) : (
<RotateCcw className="h-4 w-4" />
<>
<ArrowLeft className="h-4 w-4" />
</>
)}
</button>
<div className="flex min-w-0 flex-1 justify-center px-1">
<div
ref={counterMenuRef}
data-wooden-fish-functional="true"
className="wooden-fish-runtime__counter-anchor relative inline-flex max-w-full flex-col items-center"
>
<button
type="button"
aria-controls="wooden-fish-counter-panel"
aria-expanded={isCounterPanelOpen}
aria-label={`总数 ${totalTapCount}${
isCounterPanelOpen ? ',已展开子项计数器' : ',点击查看子项计数器'
}`}
onClick={() => {
setIsCounterPanelOpen((value) => !value);
}}
className="wooden-fish-runtime__counter-card flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:max-w-[22rem] sm:px-4 sm:pr-5"
>
<span aria-hidden="true" className="wooden-fish-runtime__counter-logo">
<img
data-testid="wooden-fish-runtime-logo"
src={woodenFishRuntimeLogo}
alt=""
className="wooden-fish-runtime__counter-logo__image"
draggable={false}
/>
</span>
<span className="wooden-fish-runtime__counter-badge shrink-0 text-[0.92rem] font-black sm:text-base">
</span>
<span className="min-w-0 truncate text-[0.92rem] font-black tabular-nums sm:text-base">
{totalTapCount}
</span>
</button>
{isCounterPanelOpen ? (
<div
id="wooden-fish-counter-panel"
data-testid="wooden-fish-counter-panel"
className="wooden-fish-runtime__counter-panel absolute left-1/2 top-full z-40 mt-2 w-[min(16rem,calc(100vw_-_1rem))] -translate-x-1/2 overflow-hidden rounded-[0.95rem] shadow-[0_14px_30px_rgba(45,24,12,0.22)] backdrop-blur"
>
<div className="max-h-[min(46vh,19rem)] overflow-auto py-2">
{counterEntries.map((counter) => (
<div
key={counter.text}
className="wooden-fish-runtime__counter-row flex items-center justify-between gap-3 px-3.5 py-2 text-sm font-black"
>
<span className="min-w-0 truncate">{counter.text}</span>
<span className="shrink-0 tabular-nums">{counter.count}</span>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
<div aria-hidden="true" className="h-10 w-10 shrink-0 sm:h-11 sm:w-11" />
</header>
<main className="relative z-10 flex flex-1 items-center justify-center px-5 pb-[max(5rem,env(safe-area-inset-bottom))] pt-4">
@@ -295,7 +376,7 @@ export function WoodenFishRuntimeShell({
{floatingTexts.map((item) => (
<div
key={item.id}
className="wooden-fish-runtime__floating-text pointer-events-none absolute z-20 rounded-full bg-slate-950/78 px-3 py-1.5 text-sm font-black text-white shadow-[0_10px_24px_rgba(15,23,42,0.2)]"
className="wooden-fish-runtime__floating-text pointer-events-none absolute z-20 text-[1.75rem] font-black text-white drop-shadow-[0_3px_8px_rgba(38,20,8,0.52)]"
style={{
left: `${item.x}%`,
top: `${item.y}%`,
@@ -320,32 +401,68 @@ export function WoodenFishRuntimeShell({
</div>
) : null}
<footer
data-wooden-fish-functional="true"
className="absolute bottom-0 left-0 right-0 z-30 flex items-center justify-between gap-3 bg-white/76 px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 shadow-[0_-14px_34px_rgba(15,23,42,0.08)] backdrop-blur sm:px-4"
>
<div className="min-w-0 text-sm font-black text-slate-700">
{activeRun?.status === 'finished' ? '已完成' : '进行中'}
</div>
<button
type="button"
onClick={() => {
void finishRun();
}}
disabled={isBusy || !onFinish}
className="platform-button platform-button--primary min-h-11 rounded-full px-4 py-2 text-sm"
>
<X className="h-4 w-4" />
</button>
</footer>
<style>{`
.wooden-fish-runtime {
touch-action: manipulation;
user-select: none;
}
.wooden-fish-runtime__counter-card {
position: relative;
min-width: min(14rem, calc(100vw - 7.75rem));
min-height: 2.9rem;
border: 2px solid rgba(255, 222, 189, 0.6);
border-radius: 0.62rem 1.35rem 1.35rem 0.62rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.16), transparent 44%),
linear-gradient(135deg, #7d5129 0%, #68401f 58%, #543014 100%);
color: #fff8ea;
box-shadow: 0 12px 28px rgba(55, 28, 13, 0.2);
}
.wooden-fish-runtime__counter-card::before {
position: absolute;
inset: 0.2rem 0.58rem auto 3.05rem;
height: 1px;
content: '';
border-radius: 9999px;
background: rgba(255, 232, 199, 0.36);
}
.wooden-fish-runtime__counter-logo {
position: relative;
z-index: 1;
display: block;
width: 2.95rem;
height: 2.95rem;
flex: 0 0 auto;
margin: -0.62rem 0 -0.62rem -1.18rem;
overflow: visible;
}
.wooden-fish-runtime__counter-logo__image {
position: absolute;
left: -1.1rem;
top: -1.24rem;
display: block;
width: 5.3rem;
height: 5.3rem;
max-width: none;
object-fit: contain;
user-select: none;
pointer-events: none;
}
.wooden-fish-runtime__counter-panel {
border: 1.5px solid rgba(255, 222, 189, 0.56);
background: linear-gradient(180deg, rgba(92, 54, 26, 0.98), rgba(67, 36, 14, 0.98));
color: #fff8ea;
}
.wooden-fish-runtime__counter-row + .wooden-fish-runtime__counter-row {
border-top: 1px solid rgba(255, 224, 191, 0.14);
}
.wooden-fish-runtime__object {
animation: wooden-fish-hit 220ms ease both;
}

View File

@@ -434,7 +434,15 @@ describe('miniGameDraftGenerationProgress', () => {
workDescription: '敲一下,好事发生。',
themeTags: ['解压'],
hitObjectPrompt: '金色小木鱼',
hitSoundPrompt: '清脆木鱼声',
hitSoundAsset: {
assetId: 'wooden-fish-default-hit-sound',
audioSrc: '/wooden-fish/default-hit-sound.mp3',
audioObjectKey: 'public/wooden-fish/default-hit-sound.mp3',
assetObjectId: 'wooden-fish-default-hit-sound',
source: 'bundled-default',
prompt: '默认木鱼音',
durationMs: 3000,
},
floatingWords: ['幸运+1', '功德+1'],
});
@@ -447,7 +455,7 @@ describe('miniGameDraftGenerationProgress', () => {
{
id: 'wooden-fish-hit-sound',
label: '音效',
value: '清脆木鱼',
value: '默认木鱼',
},
{
id: 'wooden-fish-words',

View File

@@ -427,7 +427,7 @@ const WOODEN_FISH_STEPS = [
{
id: 'wooden-fish-hit-sound',
label: '准备敲击音效',
detail: '生成或写回短促敲击音效资产。',
detail: '写回上传、录音或默认短促敲击音效资产。',
weight: 16,
},
{
@@ -917,8 +917,7 @@ export function buildWoodenFishGenerationAnchorEntries(
key: 'wooden-fish-hit-sound',
label: '音效',
value:
formPayload?.hitSoundPrompt?.trim() ||
draft?.hitSoundPrompt?.trim() ||
formPayload?.hitSoundAsset?.prompt?.trim() ||
draft?.hitSoundAsset?.prompt?.trim() ||
'',
},

View File

@@ -0,0 +1,36 @@
import { beforeEach, expect, test, vi } from 'vitest';
const { createCreationAgentClientMock } = vi.hoisted(() => ({
createCreationAgentClientMock: vi.fn(),
}));
vi.mock('../creation-agent', () => ({
createCreationAgentClient: createCreationAgentClientMock,
}));
vi.mock('../apiClient', () => ({
requestJson: vi.fn(),
}));
beforeEach(() => {
vi.resetModules();
createCreationAgentClientMock.mockReset();
createCreationAgentClientMock.mockReturnValue({
createSession: vi.fn(),
getSession: vi.fn(),
sendMessage: vi.fn(),
streamMessage: vi.fn(),
executeAction: vi.fn(),
});
});
test('wooden fish creation keeps image2 generation requests alive long enough', async () => {
await import('./woodenFishClient');
expect(createCreationAgentClientMock).toHaveBeenCalledWith(
expect.objectContaining({
createSessionTimeoutMs: 20 * 60 * 1000,
executeActionTimeoutMs: 20 * 60 * 1000,
}),
);
});

View File

@@ -22,6 +22,8 @@ import { createCreationAgentClient } from '../creation-agent';
const WOODEN_FISH_API_BASE = '/api/creation/wooden-fish/sessions';
const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works';
const WOODEN_FISH_RUNTIME_API_BASE = '/api/runtime/wooden-fish';
// 中文注释:敲木鱼创作会串行等待多次 image2 与 OSS 写入,前端请求窗口需要覆盖完整生成链路。
const WOODEN_FISH_GENERATION_TIMEOUT_MS = 20 * 60 * 1000;
const WOODEN_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
@@ -66,6 +68,8 @@ const woodenFishCreationClient = createCreationAgentClient<
streamIncomplete: '敲木鱼共创消息流式结果不完整',
executeAction: '执行敲木鱼共创操作失败',
},
createSessionTimeoutMs: WOODEN_FISH_GENERATION_TIMEOUT_MS,
executeActionTimeoutMs: WOODEN_FISH_GENERATION_TIMEOUT_MS,
});
type FlattenedWoodenFishWorkProfileResponse = Omit<
@@ -108,6 +112,8 @@ function normalizeWoodenFishWorkProfile(
hitObjectAsset: flattened.hitObjectAsset,
backgroundAsset:
flattened.backgroundAsset ?? flattened.draft?.backgroundAsset ?? null,
backButtonAsset:
flattened.backButtonAsset ?? flattened.draft?.backButtonAsset ?? null,
hitSoundAsset: flattened.hitSoundAsset,
floatingWords: flattened.floatingWords,
};

View File

@@ -1,5 +1,20 @@
import type { WoodenFishAudioAsset } from '../../../packages/shared/src/contracts/woodenFish';
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC =
'/wooden-fish/default-hit-object.png';
export const WOODEN_FISH_DEFAULT_HIT_SOUND_SRC =
'/wooden-fish/default-hit-sound.mp3';
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT =
'默认敲击物图案,圆润木质质感,透明背景';
export const WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET: WoodenFishAudioAsset = {
assetId: 'wooden-fish-default-hit-sound',
audioSrc: WOODEN_FISH_DEFAULT_HIT_SOUND_SRC,
audioObjectKey: 'public/wooden-fish/default-hit-sound.mp3',
assetObjectId: 'wooden-fish-default-hit-sound',
source: 'bundled-default',
prompt: '默认木鱼音',
durationMs: 3000,
};