feat: 完善敲木鱼结果页元信息补录
This commit is contained in:
@@ -56,6 +56,7 @@ export type CreativeImageInputPanelProps = {
|
||||
imageModelPicker?: ReactNode;
|
||||
error?: string | null;
|
||||
inputError?: string | null;
|
||||
showSubmitButton?: boolean;
|
||||
submitLabel: string;
|
||||
submitCostLabel?: string | null;
|
||||
submitDisabled: boolean;
|
||||
@@ -98,6 +99,7 @@ export function CreativeImageInputPanel({
|
||||
imageModelPicker = null,
|
||||
error = null,
|
||||
inputError = null,
|
||||
showSubmitButton = true,
|
||||
submitLabel,
|
||||
submitCostLabel = null,
|
||||
submitDisabled,
|
||||
@@ -382,27 +384,31 @@ export function CreativeImageInputPanel({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || submitDisabled}
|
||||
onClick={onSubmit}
|
||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${
|
||||
submitDisabled ? 'cursor-not-allowed opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{submitLabel}</span>
|
||||
{submitCostLabel ? (
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
{submitCostLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{showSubmitButton ? (
|
||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || submitDisabled}
|
||||
onClick={onSubmit}
|
||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${
|
||||
submitDisabled ? 'cursor-not-allowed opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{submitLabel}</span>
|
||||
{submitCostLabel ? (
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
{submitCostLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{previewReferenceImage ? (
|
||||
<div
|
||||
|
||||
@@ -7699,6 +7699,54 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const updateWoodenFishWorkMeta = useCallback(
|
||||
async (payload: {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
themeTags: string[];
|
||||
}) => {
|
||||
const sessionId = woodenFishSession?.sessionId?.trim();
|
||||
const profileId =
|
||||
woodenFishWork?.summary.profileId?.trim() ||
|
||||
woodenFishSession?.draft?.profileId?.trim() ||
|
||||
'';
|
||||
if (!sessionId || !profileId) {
|
||||
setWoodenFishError('敲木鱼草稿尚未生成可保存作品信息。');
|
||||
setSelectionStage('wooden-fish-result');
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsWoodenFishBusy(true);
|
||||
setWoodenFishError(null);
|
||||
try {
|
||||
const response = await woodenFishClient.executeAction(sessionId, {
|
||||
actionType: 'update-work-meta',
|
||||
profileId,
|
||||
workTitle: payload.workTitle,
|
||||
workDescription: payload.workDescription,
|
||||
themeTags: payload.themeTags,
|
||||
});
|
||||
setWoodenFishSession(response.session);
|
||||
setWoodenFishWork(response.work ?? woodenFishWork);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setWoodenFishError(
|
||||
resolveRpgCreationErrorMessage(error, '保存敲木鱼作品信息失败。'),
|
||||
);
|
||||
setSelectionStage('wooden-fish-result');
|
||||
return false;
|
||||
} finally {
|
||||
setIsWoodenFishBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
setSelectionStage,
|
||||
woodenFishSession?.draft?.profileId,
|
||||
woodenFishSession?.sessionId,
|
||||
woodenFishWork,
|
||||
],
|
||||
);
|
||||
|
||||
const publishWoodenFishDraft = useCallback(async () => {
|
||||
const profileId = woodenFishWork?.summary.profileId?.trim();
|
||||
if (!profileId) {
|
||||
@@ -14386,6 +14434,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onRegenerateHitObject={() => {
|
||||
void regenerateWoodenFishAsset('regenerate-hit-object');
|
||||
}}
|
||||
onUpdateWorkMeta={updateWoodenFishWorkMeta}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,10 +1,51 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { expect, test } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import { WoodenFishWorkspace } from './WoodenFishWorkspace';
|
||||
|
||||
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
|
||||
woodenFishClient: {
|
||||
createSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(woodenFishClient.createSession).mockReset();
|
||||
vi.mocked(woodenFishClient.createSession).mockResolvedValue({
|
||||
session: {
|
||||
sessionId: 'wooden-fish-session-test',
|
||||
ownerUserId: 'user-test',
|
||||
status: 'draft',
|
||||
draft: null,
|
||||
createdAt: '2026-05-24T00:00:00Z',
|
||||
updatedAt: '2026-05-24T00:00:00Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('敲什么输入栏初始置空但提交时仍使用默认生成提示词', async () => {
|
||||
const onSubmitted = vi.fn();
|
||||
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('敲什么')).toHaveProperty('value', '');
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
|
||||
expect(onSubmitted.mock.calls[0]?.[1]).toMatchObject({
|
||||
hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
});
|
||||
});
|
||||
|
||||
test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
@@ -18,10 +59,35 @@ test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀'
|
||||
|
||||
expect(section).not.toBeNull();
|
||||
expect(within(section as HTMLElement).getByDisplayValue('幸运')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByDisplayValue('健康')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByDisplayValue('财富')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('健康')).toBeNull();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('财富')).toBeNull();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('幸运+1')).toBeNull();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('功德+1')).toBeNull();
|
||||
expect(
|
||||
within(section as HTMLElement).getByRole('button', {
|
||||
name: '新增功德词条',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('功德有什么支持通过加号新增词条并移除新增格子', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增功德词条' }));
|
||||
const secondInput = screen.getByLabelText('功德词条 2');
|
||||
fireEvent.change(secondInput, { target: { value: '健康' } });
|
||||
|
||||
expect(screen.getByDisplayValue('幸运')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('健康')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除功德词条 2' }));
|
||||
expect(screen.queryByDisplayValue('健康')).toBeNull();
|
||||
expect(screen.getByDisplayValue('幸运')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('敲击音效临时关闭提示词生成入口,仅保留上传和录音', () => {
|
||||
@@ -40,3 +106,14 @@ test('敲击音效临时关闭提示词生成入口,仅保留上传和录音',
|
||||
expect(within(section as HTMLElement).getByText('上传')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('工作台只保留一个生成按钮', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('button', { name: '生成' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
Loader2,
|
||||
Mic,
|
||||
Pause,
|
||||
Plus,
|
||||
Send,
|
||||
X,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
@@ -32,44 +34,24 @@ type WoodenFishWorkspaceProps = {
|
||||
};
|
||||
|
||||
type WoodenFishWorkspaceFormState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
themeTags: string;
|
||||
hitObjectPrompt: string;
|
||||
hitObjectReferenceImageSrc: string;
|
||||
hitSoundAsset: WoodenFishAudioAsset | null;
|
||||
floatingWords: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_FLOATING_WORDS = [
|
||||
'幸运',
|
||||
'健康',
|
||||
'财富',
|
||||
'姻缘',
|
||||
'幸福',
|
||||
'事业',
|
||||
'成功',
|
||||
'功德',
|
||||
];
|
||||
const DEFAULT_WORK_TITLE = '今日敲木鱼';
|
||||
const DEFAULT_THEME_TAGS = ['敲木鱼', '解压'];
|
||||
const DEFAULT_FLOATING_WORDS = ['幸运'];
|
||||
const MAX_FLOATING_WORD_COUNT = 8;
|
||||
|
||||
const DEFAULT_FORM_STATE: WoodenFishWorkspaceFormState = {
|
||||
workTitle: '今日敲木鱼',
|
||||
workDescription: '',
|
||||
themeTags: '敲木鱼 解压',
|
||||
hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
hitObjectPrompt: '',
|
||||
hitObjectReferenceImageSrc: '',
|
||||
hitSoundAsset: null,
|
||||
floatingWords: DEFAULT_FLOATING_WORDS,
|
||||
};
|
||||
|
||||
function splitTags(value: string) {
|
||||
return value
|
||||
.split(/[,,、\s]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
function normalizeFloatingWords(words: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
@@ -84,7 +66,7 @@ function normalizeFloatingWords(words: string[]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return normalized.length > 0 ? normalized : DEFAULT_FLOATING_WORDS;
|
||||
return normalized.length > 0 ? normalized : [...DEFAULT_FLOATING_WORDS];
|
||||
}
|
||||
|
||||
function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') {
|
||||
@@ -278,11 +260,42 @@ export function WoodenFishWorkspace({
|
||||
() => normalizeFloatingWords(formState.floatingWords),
|
||||
[formState.floatingWords],
|
||||
);
|
||||
const canSubmit = Boolean(
|
||||
formState.workTitle.trim() &&
|
||||
formState.hitObjectPrompt.trim() &&
|
||||
normalizedFloatingWords.length > 0,
|
||||
);
|
||||
const canSubmit = normalizedFloatingWords.length > 0;
|
||||
|
||||
const updateFloatingWord = (index: number, value: string) => {
|
||||
setFormState((current) => {
|
||||
const nextWords = [...current.floatingWords];
|
||||
nextWords[index] = value;
|
||||
return {
|
||||
...current,
|
||||
floatingWords: nextWords.slice(0, MAX_FLOATING_WORD_COUNT),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const addFloatingWord = () => {
|
||||
setFormState((current) => {
|
||||
if (current.floatingWords.length >= MAX_FLOATING_WORD_COUNT) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
floatingWords: [...current.floatingWords, ''],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const removeFloatingWord = (index: number) => {
|
||||
if (index <= 0) {
|
||||
return;
|
||||
}
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
floatingWords: current.floatingWords.filter(
|
||||
(_word, currentIndex) => currentIndex !== index,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || isSubmitting || isBusy) {
|
||||
@@ -296,10 +309,11 @@ export function WoodenFishWorkspace({
|
||||
try {
|
||||
const payload: WoodenFishWorkspaceCreateRequest = {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: formState.workTitle.trim(),
|
||||
workDescription: formState.workDescription.trim(),
|
||||
themeTags: splitTags(formState.themeTags),
|
||||
hitObjectPrompt: formState.hitObjectPrompt.trim(),
|
||||
workTitle: DEFAULT_WORK_TITLE,
|
||||
workDescription: '',
|
||||
themeTags: DEFAULT_THEME_TAGS,
|
||||
hitObjectPrompt:
|
||||
formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
hitObjectReferenceImageSrc:
|
||||
formState.hitObjectReferenceImageSrc.trim() || null,
|
||||
hitSoundPrompt: null,
|
||||
@@ -345,6 +359,7 @@ export function WoodenFishWorkspace({
|
||||
promptRows={4}
|
||||
aiRedraw={aiRedraw}
|
||||
promptReferenceImages={[]}
|
||||
showSubmitButton={false}
|
||||
submitLabel="生成"
|
||||
submitDisabled={!canSubmit || isSubmitting || isBusy}
|
||||
labels={{
|
||||
@@ -395,55 +410,6 @@ export function WoodenFishWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标题
|
||||
</span>
|
||||
<input
|
||||
value={formState.workTitle}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品简介
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.workDescription}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workDescription: 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>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
主题标签
|
||||
</span>
|
||||
<input
|
||||
value={formState.themeTags}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
themeTags: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<WoodenFishAudioInputPanel
|
||||
disabled={isBusy || isSubmitting}
|
||||
asset={formState.hitSoundAsset}
|
||||
@@ -460,24 +426,45 @@ export function WoodenFishWorkspace({
|
||||
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
功德有什么
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
{formState.floatingWords.map((word, index) => (
|
||||
<input
|
||||
key={index}
|
||||
value={word}
|
||||
maxLength={16}
|
||||
disabled={isBusy || isSubmitting}
|
||||
onChange={(event) => {
|
||||
const nextWords = [...formState.floatingWords];
|
||||
nextWords[index] = event.target.value;
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
floatingWords: nextWords.slice(0, 8),
|
||||
}));
|
||||
}}
|
||||
className="w-full rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2.5 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
<div key={index} className="relative">
|
||||
<input
|
||||
value={word}
|
||||
maxLength={16}
|
||||
disabled={isBusy || isSubmitting}
|
||||
aria-label={`功德词条 ${index + 1}`}
|
||||
onChange={(event) =>
|
||||
updateFloatingWord(index, event.target.value)
|
||||
}
|
||||
className="h-12 w-full rounded-[0.95rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 pr-10 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
{index > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isSubmitting}
|
||||
onClick={() => removeFloatingWord(index)}
|
||||
className="absolute right-2 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full bg-white/92 text-[var(--platform-text-soft)] shadow-sm transition hover:text-[var(--platform-accent)] disabled:opacity-45"
|
||||
aria-label={`删除功德词条 ${index + 1}`}
|
||||
title="删除词条"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{formState.floatingWords.length < MAX_FLOATING_WORD_COUNT ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isSubmitting}
|
||||
onClick={addFloatingWord}
|
||||
className="grid h-12 place-items-center rounded-[0.95rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/55 text-[var(--platform-text-soft)] transition hover:border-[var(--platform-accent)] hover:bg-white/78 hover:text-[var(--platform-accent)] disabled:opacity-45"
|
||||
aria-label="新增功德词条"
|
||||
title="新增词条"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { WoodenFishDraftResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
@@ -64,3 +64,42 @@ test('结果页缺少音频资产时使用默认木鱼音且不展示生成音
|
||||
'/wooden-fish/default-hit-sound.mp3',
|
||||
);
|
||||
});
|
||||
|
||||
test('结果页支持在试玩前编辑并保存主题信息', async () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const onPublish = vi.fn();
|
||||
const onUpdateWorkMeta = vi.fn().mockResolvedValue(true);
|
||||
|
||||
render(
|
||||
<WoodenFishResultView
|
||||
profile={createDraft()}
|
||||
onBack={() => {}}
|
||||
onEdit={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
onPublish={onPublish}
|
||||
onRegenerateHitObject={() => {}}
|
||||
onUpdateWorkMeta={onUpdateWorkMeta}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('作品标题'), {
|
||||
target: { value: '新的木鱼作品' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('作品简介'), {
|
||||
target: { value: '敲一下,心静一下。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增主题标签' }));
|
||||
fireEvent.change(screen.getByLabelText('新题材标签'), {
|
||||
target: { value: '治愈' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(onUpdateWorkMeta).toHaveBeenCalledWith({
|
||||
workTitle: '新的木鱼作品',
|
||||
workDescription: '敲一下,心静一下。',
|
||||
themeTags: ['敲木鱼', '治愈'],
|
||||
});
|
||||
await waitFor(() => expect(onStartTestRun).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
@@ -2,10 +2,12 @@ import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCcw,
|
||||
Send,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
WoodenFishDraftResponse,
|
||||
@@ -27,6 +29,13 @@ type WoodenFishResultViewProps = {
|
||||
onStartTestRun: () => void;
|
||||
onPublish: () => void;
|
||||
onRegenerateHitObject: () => void;
|
||||
onUpdateWorkMeta?: (payload: WoodenFishWorkMetaEditState) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export type WoodenFishWorkMetaEditState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
themeTags: string[];
|
||||
};
|
||||
|
||||
function isWoodenFishWorkProfile(
|
||||
@@ -35,6 +44,184 @@ function isWoodenFishWorkProfile(
|
||||
return 'summary' in profile;
|
||||
}
|
||||
|
||||
function normalizeThemeTags(tags: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const tag of tags) {
|
||||
const trimmed = tag.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
if (normalized.length >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeThemeTagInput(value: string) {
|
||||
return normalizeThemeTags(value.split(/[,,、\s]+/u));
|
||||
}
|
||||
|
||||
function buildMetaEditState(
|
||||
profile: WoodenFishResultViewProps['profile'],
|
||||
): WoodenFishWorkMetaEditState {
|
||||
const isWorkProfile = isWoodenFishWorkProfile(profile);
|
||||
const draft = isWorkProfile ? profile.draft : profile;
|
||||
const summary = isWorkProfile ? profile.summary : null;
|
||||
return {
|
||||
workTitle:
|
||||
summary?.workTitle?.trim() || draft.workTitle.trim() || '今日敲木鱼',
|
||||
workDescription:
|
||||
summary?.workDescription?.trim() || draft.workDescription.trim(),
|
||||
themeTags: normalizeThemeTags(
|
||||
summary?.themeTags?.length ? summary.themeTags : draft.themeTags,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function areMetaStatesEqual(
|
||||
left: WoodenFishWorkMetaEditState,
|
||||
right: WoodenFishWorkMetaEditState,
|
||||
) {
|
||||
return (
|
||||
left.workTitle.trim() === right.workTitle.trim() &&
|
||||
left.workDescription.trim() === right.workDescription.trim() &&
|
||||
normalizeThemeTags(left.themeTags).join('\u0000') ===
|
||||
normalizeThemeTags(right.themeTags).join('\u0000')
|
||||
);
|
||||
}
|
||||
|
||||
function WoodenFishThemeTagEditor({
|
||||
editState,
|
||||
isBusy,
|
||||
onChange,
|
||||
}: {
|
||||
editState: WoodenFishWorkMetaEditState;
|
||||
isBusy: boolean;
|
||||
onChange: (nextState: WoodenFishWorkMetaEditState) => void;
|
||||
}) {
|
||||
const [newTagText, setNewTagText] = useState('');
|
||||
const [isAddingTag, setIsAddingTag] = useState(false);
|
||||
|
||||
const addTags = () => {
|
||||
const nextTags = normalizeThemeTagInput(newTagText);
|
||||
if (nextTags.length <= 0) {
|
||||
setNewTagText('');
|
||||
setIsAddingTag(false);
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...editState,
|
||||
themeTags: normalizeThemeTags([...editState.themeTags, ...nextTags]),
|
||||
});
|
||||
setNewTagText('');
|
||||
setIsAddingTag(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
主题标签
|
||||
</div>
|
||||
{!isAddingTag && editState.themeTags.length < 6 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsAddingTag(true)}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="新增主题标签"
|
||||
title="新增主题标签"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{editState.themeTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1.5 text-xs font-semibold text-amber-700"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...editState,
|
||||
themeTags: editState.themeTags.filter(
|
||||
(currentTag) => currentTag !== tag,
|
||||
),
|
||||
});
|
||||
}}
|
||||
className="rounded-full text-amber-800/70 transition hover:text-amber-950 disabled:opacity-45"
|
||||
aria-label={`删除标签 ${tag}`}
|
||||
title="删除标签"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{editState.themeTags.length <= 0 ? (
|
||||
<span className="text-sm text-[var(--platform-text-soft)]">
|
||||
暂无标签
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isAddingTag ? (
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
|
||||
<input
|
||||
autoFocus
|
||||
value={newTagText}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setNewTagText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addTags();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setIsAddingTag(false);
|
||||
setNewTagText('');
|
||||
}
|
||||
}}
|
||||
className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
|
||||
placeholder="输入新标签"
|
||||
aria-label="新题材标签"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={addTags}
|
||||
className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setIsAddingTag(false);
|
||||
setNewTagText('');
|
||||
}}
|
||||
className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WoodenFishResultView({
|
||||
profile,
|
||||
isBusy = false,
|
||||
@@ -44,11 +231,14 @@ export function WoodenFishResultView({
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
onRegenerateHitObject,
|
||||
onUpdateWorkMeta,
|
||||
}: WoodenFishResultViewProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isSavingMeta, setIsSavingMeta] = useState(false);
|
||||
const isWorkProfile = isWoodenFishWorkProfile(profile);
|
||||
const draft = isWorkProfile ? profile.draft : profile;
|
||||
const summary = isWorkProfile ? profile.summary : null;
|
||||
const canonicalMeta = useMemo(() => buildMetaEditState(profile), [profile]);
|
||||
const [metaEditState, setMetaEditState] = useState(canonicalMeta);
|
||||
const hitObjectAsset = isWorkProfile
|
||||
? profile.hitObjectAsset
|
||||
: draft.hitObjectAsset;
|
||||
@@ -62,18 +252,52 @@ export function WoodenFishResultView({
|
||||
? 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() || '敲木鱼';
|
||||
const description =
|
||||
summary?.workDescription?.trim() || draft.workDescription.trim();
|
||||
const title = metaEditState.workTitle.trim() || '敲木鱼';
|
||||
const description = metaEditState.workDescription.trim();
|
||||
const isMetaDirty = !areMetaStatesEqual(metaEditState, canonicalMeta);
|
||||
const { resolvedUrl: resolvedAudioUrl } = useResolvedAssetReadUrl(
|
||||
hitSoundAsset?.audioSrc,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setMetaEditState(canonicalMeta);
|
||||
}, [canonicalMeta]);
|
||||
|
||||
const saveMetaIfNeeded = async () => {
|
||||
const normalizedMeta: WoodenFishWorkMetaEditState = {
|
||||
workTitle: metaEditState.workTitle.trim() || '今日敲木鱼',
|
||||
workDescription: metaEditState.workDescription.trim(),
|
||||
themeTags: normalizeThemeTags(metaEditState.themeTags),
|
||||
};
|
||||
if (!onUpdateWorkMeta || areMetaStatesEqual(normalizedMeta, canonicalMeta)) {
|
||||
setMetaEditState(normalizedMeta);
|
||||
return true;
|
||||
}
|
||||
|
||||
setIsSavingMeta(true);
|
||||
try {
|
||||
const saved = await onUpdateWorkMeta(normalizedMeta);
|
||||
if (saved) {
|
||||
setMetaEditState(normalizedMeta);
|
||||
}
|
||||
return saved;
|
||||
} finally {
|
||||
setIsSavingMeta(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTestRun = async () => {
|
||||
if (await saveMetaIfNeeded()) {
|
||||
onStartTestRun();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
await Promise.resolve(onPublish());
|
||||
if (await saveMetaIfNeeded()) {
|
||||
await Promise.resolve(onPublish());
|
||||
}
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
@@ -132,10 +356,54 @@ export function WoodenFishResultView({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel flex min-h-0 flex-col rounded-[1.25rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
飘字
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标题
|
||||
</div>
|
||||
<input
|
||||
value={metaEditState.workTitle}
|
||||
disabled={isBusy || isSavingMeta}
|
||||
onChange={(event) =>
|
||||
setMetaEditState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="作品标题"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品简介
|
||||
</div>
|
||||
<textarea
|
||||
value={metaEditState.workDescription}
|
||||
disabled={isBusy || isSavingMeta}
|
||||
rows={4}
|
||||
onChange={(event) =>
|
||||
setMetaEditState((current) => ({
|
||||
...current,
|
||||
workDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-3 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="作品简介"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<WoodenFishThemeTagEditor
|
||||
editState={metaEditState}
|
||||
isBusy={isBusy || isSavingMeta}
|
||||
onChange={setMetaEditState}
|
||||
/>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
飘字
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{floatingWords.map((word) => (
|
||||
<span
|
||||
@@ -146,8 +414,9 @@ export function WoodenFishResultView({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-4">
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
音效
|
||||
</div>
|
||||
@@ -158,7 +427,7 @@ export function WoodenFishResultView({
|
||||
音效尚未准备完成。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
@@ -166,7 +435,7 @@ export function WoodenFishResultView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto grid gap-2 pt-4">
|
||||
<div className="mt-auto grid gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
@@ -178,11 +447,17 @@ export function WoodenFishResultView({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartTestRun}
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
void handleStartTestRun();
|
||||
}}
|
||||
disabled={isBusy || isSavingMeta}
|
||||
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{isSavingMeta ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
试玩
|
||||
</button>
|
||||
<button
|
||||
@@ -190,18 +465,18 @@ export function WoodenFishResultView({
|
||||
onClick={() => {
|
||||
void handlePublish();
|
||||
}}
|
||||
disabled={isBusy || isPublishing}
|
||||
disabled={isBusy || isPublishing || isSavingMeta}
|
||||
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
{isPublishing ? (
|
||||
{isPublishing || isSavingMeta ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
发布
|
||||
{isMetaDirty ? '保存并发布' : '发布'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -407,7 +407,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('wooden fish draft generation exposes hit object, background and sound pipeline', () => {
|
||||
test('wooden fish draft generation exposes hit object, background and back button pipeline', () => {
|
||||
const state = createMiniGameDraftGenerationState('wooden-fish');
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
@@ -419,12 +419,40 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
'wooden-fish-draft',
|
||||
'wooden-fish-hit-object',
|
||||
'wooden-fish-background',
|
||||
'wooden-fish-hit-sound',
|
||||
'wooden-fish-back-button',
|
||||
'wooden-fish-write-draft',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('wooden-fish-hit-object');
|
||||
expect(progress?.phaseLabel).toBe('生成敲击物图案');
|
||||
expect(progress?.estimatedRemainingMs).toBe(272_000);
|
||||
expect(progress?.estimatedRemainingMs).toBe(530_000);
|
||||
});
|
||||
|
||||
test('wooden fish draft generation follows hit object, background, back button and writeback', () => {
|
||||
const state = createMiniGameDraftGenerationState('wooden-fish');
|
||||
|
||||
const hitObjectProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 20_000,
|
||||
);
|
||||
const backgroundProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 200_000,
|
||||
);
|
||||
const backButtonProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 390_000,
|
||||
);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 575_000,
|
||||
);
|
||||
|
||||
expect(hitObjectProgress?.phaseId).toBe('wooden-fish-hit-object');
|
||||
expect(backgroundProgress?.phaseId).toBe('wooden-fish-background');
|
||||
expect(backButtonProgress?.phaseId).toBe('wooden-fish-back-button');
|
||||
expect(writeBackProgress?.phaseId).toBe('wooden-fish-write-draft');
|
||||
expect(writeBackProgress?.estimatedRemainingMs).toBe(0);
|
||||
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
|
||||
});
|
||||
|
||||
test('wooden fish generation anchors expose hit object, sound and words', () => {
|
||||
|
||||
@@ -70,7 +70,7 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'wooden-fish-draft'
|
||||
| 'wooden-fish-hit-object'
|
||||
| 'wooden-fish-background'
|
||||
| 'wooden-fish-hit-sound'
|
||||
| 'wooden-fish-back-button'
|
||||
| 'wooden-fish-write-draft'
|
||||
| 'puzzle-cover-image'
|
||||
| 'puzzle-level-scene'
|
||||
@@ -415,30 +415,36 @@ const WOODEN_FISH_STEPS = [
|
||||
{
|
||||
id: 'wooden-fish-hit-object',
|
||||
label: '生成敲击物图案',
|
||||
detail: '使用 image2 生成最终运行态敲击物图案。',
|
||||
weight: 34,
|
||||
detail: '调用 image2 生成绿幕敲击物并去绿透明化,预计约 3 分钟。',
|
||||
weight: 32,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-background',
|
||||
label: '生成背景环境图',
|
||||
detail: '使用 image2 生成敲击背景环境图。',
|
||||
weight: 34,
|
||||
detail: '使用透明敲击物作参考生成 9:16 背景环境图,预计约 3 分钟。',
|
||||
weight: 32,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-hit-sound',
|
||||
label: '准备敲击音效',
|
||||
detail: '写回上传、录音或默认短促敲击音效资产。',
|
||||
weight: 16,
|
||||
id: 'wooden-fish-back-button',
|
||||
label: '生成返回按钮图',
|
||||
detail: '使用敲击物和背景作参考生成主题圆形返回按钮,预计约 3 分钟。',
|
||||
weight: 20,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-write-draft',
|
||||
label: '写入正式草稿',
|
||||
detail: '保存图案、背景、音效、飘字和封面摘要。',
|
||||
detail: '保存图案、背景、返回按钮、音效、飘字和封面摘要。',
|
||||
weight: 8,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const WOODEN_FISH_ESTIMATED_WAIT_MS = 5 * 60_000;
|
||||
const WOODEN_FISH_COMPILE_EXPECTED_MS = 8_000;
|
||||
const WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS = 180_000;
|
||||
const WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS = 10_000;
|
||||
const WOODEN_FISH_ESTIMATED_WAIT_MS =
|
||||
WOODEN_FISH_COMPILE_EXPECTED_MS +
|
||||
WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS * 3 +
|
||||
WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
@@ -486,15 +492,16 @@ function buildMiniGameProgressSteps(
|
||||
return steps.map((step, index) => {
|
||||
// 中文注释:拼图草稿编译的 action 回包才代表可进入结果页;
|
||||
// 但预计写入时长已耗尽时,最后一步自身应呈现已完成,避免出现“进行中 100%”。
|
||||
const isPuzzleWriteStepCompleted =
|
||||
state.kind === 'puzzle' &&
|
||||
const isTimedWriteStepCompleted =
|
||||
(state.kind === 'puzzle' || state.kind === 'wooden-fish') &&
|
||||
state.phase !== 'failed' &&
|
||||
step.id === 'puzzle-select-image' &&
|
||||
(step.id === 'puzzle-select-image' ||
|
||||
step.id === 'wooden-fish-write-draft') &&
|
||||
clampProgress(activeStepProgressRatio * 100) >= 100;
|
||||
const isCompleted =
|
||||
state.phase === 'ready' ||
|
||||
index < activeStepIndex ||
|
||||
isPuzzleWriteStepCompleted;
|
||||
isTimedWriteStepCompleted;
|
||||
const isActive =
|
||||
state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
|
||||
const isAssetStep = step.id === state.phase && state.totalAssetCount > 0;
|
||||
@@ -618,22 +625,64 @@ function resolveJumpHopPhaseByElapsedMs(
|
||||
return 'jump-hop-draft';
|
||||
}
|
||||
|
||||
function resolveWoodenFishPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 270_000) {
|
||||
return 'wooden-fish-write-draft';
|
||||
function buildWoodenFishPhaseTimeline(): Array<{
|
||||
phase: Extract<
|
||||
MiniGameDraftGenerationPhase,
|
||||
| 'wooden-fish-draft'
|
||||
| 'wooden-fish-hit-object'
|
||||
| 'wooden-fish-background'
|
||||
| 'wooden-fish-back-button'
|
||||
| 'wooden-fish-write-draft'
|
||||
>;
|
||||
durationMs: number;
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
phase: 'wooden-fish-draft',
|
||||
durationMs: WOODEN_FISH_COMPILE_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
phase: 'wooden-fish-hit-object',
|
||||
durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
phase: 'wooden-fish-background',
|
||||
durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
phase: 'wooden-fish-back-button',
|
||||
durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
phase: 'wooden-fish-write-draft',
|
||||
durationMs: WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function resolveWoodenFishTimelineByElapsedMs(elapsedMs: number) {
|
||||
let elapsedBeforePhase = 0;
|
||||
|
||||
for (const item of buildWoodenFishPhaseTimeline()) {
|
||||
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
|
||||
|
||||
if (elapsedInPhase < item.durationMs) {
|
||||
return {
|
||||
phase: item.phase,
|
||||
activeStepProgressRatio: Math.max(
|
||||
0,
|
||||
Math.min(1, elapsedInPhase / item.durationMs),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
elapsedBeforePhase += item.durationMs;
|
||||
}
|
||||
if (elapsedMs >= 240_000) {
|
||||
return 'wooden-fish-hit-sound';
|
||||
}
|
||||
if (elapsedMs >= 120_000) {
|
||||
return 'wooden-fish-background';
|
||||
}
|
||||
if (elapsedMs >= 12_000) {
|
||||
return 'wooden-fish-hit-object';
|
||||
}
|
||||
return 'wooden-fish-draft';
|
||||
|
||||
return {
|
||||
phase: 'wooden-fish-write-draft' as const,
|
||||
activeStepProgressRatio: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(
|
||||
@@ -683,12 +732,23 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
state.phase !== 'ready'
|
||||
? resolvePuzzleTimelineByElapsedMs(elapsedMs, state)
|
||||
: null;
|
||||
const woodenFishTimeline =
|
||||
state.kind === 'wooden-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? resolveWoodenFishTimelineByElapsedMs(elapsedMs)
|
||||
: null;
|
||||
const normalizedState =
|
||||
puzzleTimeline != null
|
||||
? {
|
||||
...state,
|
||||
phase: puzzleTimeline.phase,
|
||||
}
|
||||
: woodenFishTimeline != null
|
||||
? {
|
||||
...state,
|
||||
phase: woodenFishTimeline.phase,
|
||||
}
|
||||
: state.kind === 'big-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
@@ -724,13 +784,6 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
...state,
|
||||
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'wooden-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveWoodenFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
|
||||
const steps =
|
||||
@@ -766,7 +819,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? 0.5
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? 0.5
|
||||
? (woodenFishTimeline?.activeStepProgressRatio ?? 0)
|
||||
: 0;
|
||||
const overallProgress =
|
||||
normalizedState.phase === 'failed'
|
||||
@@ -779,6 +832,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? overallProgress
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
|
||||
: overallProgress;
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user