Merge remote-tracking branch 'origin/codex/wooden-fish-template'

This commit is contained in:
kdletters
2026-05-22 08:09:58 +08:00
617 changed files with 31612 additions and 237 deletions

View File

@@ -0,0 +1,64 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import { derivePlatformCreationTypes } from './platformEntryCreationTypes';
const entryConfig = {
startCard: {
title: '新建作品',
description: '',
idleBadge: '模板',
busyBadge: '开启中',
},
typeModal: {
title: '选择创作类型',
description: '',
},
creationTypes: [
{
id: 'wooden-fish',
title: '敲木鱼',
subtitle: '轻点积累功德',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 10,
updatedAtMicros: 1,
},
],
} satisfies CreationEntryConfig;
test('dispatches wooden fish creation type selection', () => {
const onSelectWoodenFish = vi.fn();
render(
<PlatformEntryCreationTypeModal
isOpen
isBusy={false}
error={null}
entryConfig={entryConfig}
creationTypes={derivePlatformCreationTypes(entryConfig.creationTypes)}
onClose={() => {}}
onSelectRpg={() => {}}
onSelectBigFish={() => {}}
onSelectMatch3D={() => {}}
onSelectSquareHole={() => {}}
onSelectJumpHop={() => {}}
onSelectWoodenFish={onSelectWoodenFish}
onSelectPuzzle={() => {}}
onSelectCreativeAgent={() => {}}
onSelectBarkBattle={() => {}}
onSelectVisualNovel={() => {}}
onSelectBabyObjectMatch={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onSelectWoodenFish).toHaveBeenCalledTimes(1);
});

View File

@@ -19,6 +19,7 @@ export interface PlatformEntryCreationTypeModalProps {
onSelectMatch3D: () => void;
onSelectSquareHole: () => void;
onSelectJumpHop: () => void;
onSelectWoodenFish: () => void;
onSelectPuzzle: () => void;
onSelectCreativeAgent: () => void;
onSelectBarkBattle: () => void;
@@ -102,6 +103,7 @@ export function PlatformEntryCreationTypeModal({
onSelectMatch3D,
onSelectSquareHole,
onSelectJumpHop,
onSelectWoodenFish,
onSelectPuzzle,
onSelectCreativeAgent,
onSelectBarkBattle,
@@ -147,6 +149,9 @@ export function PlatformEntryCreationTypeModal({
if (item.id === 'jump-hop') {
onSelectJumpHop();
}
if (item.id === 'wooden-fish') {
onSelectWoodenFish();
}
if (item.id === 'puzzle') {
onSelectPuzzle();
}

File diff suppressed because it is too large Load Diff

View File

@@ -182,3 +182,29 @@ test('edutainment switch hides baby object match creation entry from database co
getVisiblePlatformCreationTypes(hiddenCards).map((item) => item.id),
).toEqual(['puzzle']);
});
test('baby object match entry is visible and open when database marks it creatable', () => {
const cards = derivePlatformCreationTypes([
{
id: 'baby-object-match',
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/child-motion-demo/picture-book-grass-stage.png',
visible: true,
open: true,
sortOrder: 90,
updatedAtMicros: 1,
},
]);
expect(getVisiblePlatformCreationTypes(cards)).toEqual([
expect.objectContaining({
id: 'baby-object-match',
hidden: false,
locked: false,
}),
]);
expect(isPlatformCreationTypeVisible(cards, 'baby-object-match')).toBe(true);
expect(isPlatformCreationTypeOpen(cards, 'baby-object-match')).toBe(true);
});

View File

@@ -39,6 +39,10 @@ export type SelectionStage =
| 'bark-battle-generating'
| 'bark-battle-result'
| 'bark-battle-runtime'
| 'wooden-fish-workspace'
| 'wooden-fish-generating'
| 'wooden-fish-result'
| 'wooden-fish-runtime'
| 'creative-agent-workspace'
| 'visual-novel-agent-workspace'
| 'visual-novel-generating'

View File

@@ -761,6 +761,7 @@ function renderLoggedOutHomeView(
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onOpenRecommendGalleryDetail'
| 'onOpenChildMotionDemo'
| 'onSearchPublicCode'
| 'recommendRuntimeContent'
| 'activeRecommendEntryKey'
@@ -814,6 +815,7 @@ function renderLoggedOutHomeView(
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenChildMotionDemo={overrides.onOpenChildMotionDemo}
onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
recommendRuntimeContent={
overrides.recommendRuntimeContent ?? (
@@ -912,6 +914,7 @@ function renderStatefulLoggedOutHomeView(
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onOpenRecommendGalleryDetail'
| 'onOpenChildMotionDemo'
| 'onSearchPublicCode'
| 'recommendRuntimeContent'
| 'activeRecommendEntryKey'
@@ -970,6 +973,7 @@ function renderStatefulLoggedOutHomeView(
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenChildMotionDemo={overrides.onOpenChildMotionDemo}
onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
recommendRuntimeContent={
overrides.recommendRuntimeContent ?? (
@@ -2214,6 +2218,7 @@ test('discover search fuzzy matches public work id, name, author and description
test('mobile discover keeps edutainment works in the last dedicated channel only', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
const onOpenChildMotionDemo = vi.fn();
const generalEntry = buildTaggedPuzzleEntry('normal01', '普通拼图作品', [
'儿童教育',
]);
@@ -2234,6 +2239,7 @@ test('mobile discover keeps edutainment works in the last dedicated channel only
renderStatefulLoggedOutHomeView({
latestEntries: [edutainmentEntry, generalEntry],
onOpenChildMotionDemo,
onSearchPublicCode,
});
await user.click(screen.getByRole('button', { name: '发现' }));
@@ -2266,6 +2272,12 @@ test('mobile discover keeps edutainment works in the last dedicated channel only
name: / Demo/u,
}),
).toBeTruthy();
const warmupButton = within(discoverPanel).getByRole('button', {
name: //u,
});
expect(warmupButton).toBeTruthy();
await user.click(warmupButton);
expect(onOpenChildMotionDemo).toHaveBeenCalledTimes(1);
expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull();
const searchInput =
@@ -2276,6 +2288,23 @@ test('mobile discover keeps edutainment works in the last dedicated channel only
expect(onSearchPublicCode).not.toHaveBeenCalled();
});
test('desktop discover shows child motion demo in edutainment channel', async () => {
mockDesktopLayout();
const user = userEvent.setup();
const onOpenChildMotionDemo = vi.fn();
renderStatefulLoggedOutHomeView({
onOpenChildMotionDemo,
});
await user.click(screen.getByRole('button', { name: '发现' }));
await user.click(screen.getByRole('button', { name: '寓教于乐' }));
const warmupButton = screen.getByRole('button', { name: //u });
expect(warmupButton).toBeTruthy();
await user.click(warmupButton);
expect(onOpenChildMotionDemo).toHaveBeenCalledTimes(1);
});
test('mobile discover hides edutainment channel and work when switch is disabled', async () => {
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
const user = userEvent.setup();

View File

@@ -136,6 +136,7 @@ import {
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformPublicWorkCode,
@@ -173,6 +174,7 @@ export interface RpgEntryHomeViewProps {
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
onOpenChildMotionDemo?: () => void;
onOpenBabyLoveDrawing?: () => void;
onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void;
recommendRuntimeContent?: ReactNode;
@@ -326,6 +328,11 @@ const BABY_LOVE_DRAWING_DEFAULT_CARD = {
subtitle: '空白画板',
summary: '挥动小手画一张画。',
};
const CHILD_MOTION_DEMO_DEFAULT_CARD = {
title: '热身关卡',
subtitle: '动作识别热身',
summary: '站位、招手和左右手活动。',
};
const PLATFORM_RANKING_TABS: Array<{
id: PlatformRankingTab;
@@ -1896,26 +1903,35 @@ async function getPublicWorkAuthorSummary(
}
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
const kind = isBigFishGalleryEntry(entry)
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: isMatch3DGalleryEntry(entry)
? '抓鹅'
: isSquareHoleGalleryEntry(entry)
? '方洞'
: isVisualNovelGalleryEntry(entry)
? '视觉'
: isBarkBattleGalleryEntry(entry)
? '汪汪'
: isEdutainmentGalleryEntry(entry)
? entry.templateName
: isJumpHopGalleryEntry(entry)
? '跳一跳'
: describePlatformThemeLabel(entry.themeMode);
return formatPlatformWorkDisplayTag(kind);
if (isBigFishGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isPuzzleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isMatch3DGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isSquareHoleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isJumpHopGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('???');
}
if (isWoodenFishGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('???');
}
if (isVisualNovelGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isBarkBattleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isEdutainmentGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag(entry.templateName);
}
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
}
function getPublicAuthorAvatarLabel(authorDisplayName: string) {
return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩';
}
@@ -3767,6 +3783,7 @@ export function RpgEntryHomeView({
onResumeSave,
onOpenCreateTypePicker,
onOpenGalleryDetail,
onOpenChildMotionDemo,
onOpenBabyLoveDrawing,
onOpenRecommendGalleryDetail,
recommendRuntimeContent,
@@ -5477,7 +5494,9 @@ export function RpgEntryHomeView({
<section className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : edutainmentFeedEntries.length > 0 || onOpenBabyLoveDrawing ? (
) : edutainmentFeedEntries.length > 0 ||
onOpenChildMotionDemo ||
onOpenBabyLoveDrawing ? (
<div className="grid min-w-0 gap-3">
{edutainmentFeedEntries.map((entry) => {
const cardKey = buildPublicGalleryCardKey(entry);
@@ -5493,6 +5512,24 @@ export function RpgEntryHomeView({
/>
);
})}
{onOpenChildMotionDemo ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenChildMotionDemo}
>
<span className="platform-edutainment-level-card__icon">
<Camera className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{CHILD_MOTION_DEMO_DEFAULT_CARD.title}</strong>
<span>{CHILD_MOTION_DEMO_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{CHILD_MOTION_DEMO_DEFAULT_CARD.summary}
</span>
</button>
) : null}
{onOpenBabyLoveDrawing ? (
<button
type="button"
@@ -5655,7 +5692,9 @@ export function RpgEntryHomeView({
<SectionHeader title={EDUTAINMENT_WORK_TAG} detail="EDUTAINMENT" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : edutainmentFeedEntries.length > 0 || onOpenBabyLoveDrawing ? (
) : edutainmentFeedEntries.length > 0 ||
onOpenChildMotionDemo ||
onOpenBabyLoveDrawing ? (
<div className="grid gap-4 xl:grid-cols-3">
{edutainmentFeedEntries.map((entry) => (
<WorldCard
@@ -5666,6 +5705,24 @@ export function RpgEntryHomeView({
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/>
))}
{onOpenChildMotionDemo ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenChildMotionDemo}
>
<span className="platform-edutainment-level-card__icon">
<Camera className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{CHILD_MOTION_DEMO_DEFAULT_CARD.title}</strong>
<span>{CHILD_MOTION_DEMO_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{CHILD_MOTION_DEMO_DEFAULT_CARD.summary}
</span>
</button>
) : null}
{onOpenBabyLoveDrawing ? (
<button
type="button"

View File

@@ -11,9 +11,11 @@ import {
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapBarkBattleWorkToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
mapWoodenFishWorkToPlatformGalleryCard,
type PlatformEdutainmentGalleryCard,
type PlatformPuzzleGalleryCard,
resolvePlatformPublicWorkCode,
@@ -167,6 +169,34 @@ test('maps visual novel work to platform gallery card with VN public code', () =
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']);
});
test('maps wooden fish work to platform gallery card with WF public code', () => {
const card = mapWoodenFishWorkToPlatformGalleryCard({
publicWorkCode: '',
workId: 'wooden-fish-work-1',
profileId: 'wooden-fish-profile-12345678',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
workTitle: '每日一敲',
workDescription: '敲一下,好事发生。',
coverImageSrc: '/generated-wooden-fish-assets/profile/hit-object.png',
themeTags: [],
publicationStatus: 'published',
playCount: 12,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
generationStatus: 'ready',
});
expect(isWoodenFishGalleryEntry(card)).toBe(true);
expect(card.sourceType).toBe('wooden-fish');
expect(card.publicWorkCode).toBe('WF-12345678');
expect(resolvePlatformPublicWorkCode(card)).toBe('WF-12345678');
expect(resolvePlatformWorldFallbackCoverImage(card)).toBe(
'/wooden-fish/default-hit-object.png',
);
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['敲木鱼']);
});
test('keeps baby object match public card code and template label intact', () => {
const card: PlatformEdutainmentGalleryCard = {
sourceType: 'edutainment',

View File

@@ -23,6 +23,10 @@ import type {
SquareHoleWorkSummary,
} from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type {
WoodenFishGalleryCardResponse,
WoodenFishWorkProfileResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
@@ -34,7 +38,9 @@ import {
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
buildVisualNovelPublicWorkCode,
buildWoodenFishPublicWorkCode,
} from '../../services/publicWorkCode';
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC } from '../../services/wooden-fish/woodenFishDefaults';
import type { CustomWorldProfile } from '../../types';
export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8;
@@ -50,6 +56,7 @@ export type PlatformWorldCardLike =
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard
| PlatformJumpHopGalleryCard
| PlatformWoodenFishGalleryCard
| PlatformVisualNovelGalleryCard
| PlatformBarkBattleGalleryCard
| PlatformEdutainmentGalleryCard;
@@ -205,6 +212,28 @@ export type PlatformJumpHopGalleryCard = {
stylePreset?: string;
};
export type PlatformWoodenFishGalleryCard = {
sourceType: 'wooden-fish';
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformEdutainmentGalleryCard = {
sourceType: 'edutainment';
templateId: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID;
@@ -264,6 +293,7 @@ export type PlatformPublicGalleryCard =
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard
| PlatformJumpHopGalleryCard
| PlatformWoodenFishGalleryCard
| PlatformVisualNovelGalleryCard
| PlatformBarkBattleGalleryCard
| PlatformEdutainmentGalleryCard;
@@ -310,6 +340,12 @@ export function isJumpHopGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'jump-hop';
}
export function isWoodenFishGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformWoodenFishGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'wooden-fish';
}
export function isEdutainmentGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformEdutainmentGalleryCard {
@@ -510,6 +546,39 @@ export function mapJumpHopWorkToPlatformGalleryCard(
};
}
export function mapWoodenFishWorkToPlatformGalleryCard(
work: WoodenFishGalleryCardResponse | WoodenFishWorkProfileResponse,
): PlatformWoodenFishGalleryCard {
const summary = 'summary' in work ? work.summary : work;
return {
sourceType: 'wooden-fish',
workId: summary.workId,
profileId: summary.profileId,
sourceSessionId:
'sourceSessionId' in summary ? (summary.sourceSessionId ?? null) : null,
publicWorkCode:
'publicWorkCode' in summary && summary.publicWorkCode.trim()
? summary.publicWorkCode
: buildWoodenFishPublicWorkCode(summary.profileId),
ownerUserId: summary.ownerUserId,
authorDisplayName:
'authorDisplayName' in summary ? summary.authorDisplayName : '玩家',
worldName: summary.workTitle,
subtitle: '敲木鱼',
summaryText: summary.workDescription,
coverImageSrc: summary.coverImageSrc ?? null,
themeTags: summary.themeTags.length > 0 ? summary.themeTags : ['敲木鱼'],
playCount: summary.playCount ?? 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: summary.publishedAt ?? null,
updatedAt: summary.updatedAt,
};
}
export function mapBabyObjectMatchDraftToPlatformGalleryCard(
draft: BabyObjectMatchDraft,
): PlatformEdutainmentGalleryCard {
@@ -649,6 +718,10 @@ export function resolvePlatformWorldFallbackCoverImage(
return '/creation-type-references/jump-hop.webp';
}
if (isWoodenFishGalleryEntry(entry)) {
return WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC;
}
if (isBigFishGalleryEntry(entry)) {
return '/creation-type-references/big-fish.webp';
}
@@ -822,6 +895,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
: ['跳一跳'];
}
if (isWoodenFishGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: ['敲木鱼'];
}
if (isEdutainmentGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
@@ -924,6 +1003,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode;
}
if (isWoodenFishGalleryEntry(entry)) {
return entry.publicWorkCode;
}
if (isEdutainmentGalleryEntry(entry)) {
return entry.publicWorkCode;
}

View File

@@ -0,0 +1,25 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { WoodenFishWorkspace } from './WoodenFishWorkspace';
test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀', () => {
render(
<WoodenFishWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const sectionTitle = screen.getByText('功德有什么');
const section = sectionTitle.closest('section');
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('幸运+1')).toBeNull();
expect(within(section as HTMLElement).queryByDisplayValue('功德+1')).toBeNull();
});

View File

@@ -0,0 +1,534 @@
import {
ArrowLeft,
Loader2,
Mic,
Pause,
Send,
Upload,
} from 'lucide-react';
import { useMemo, useRef, useState } from 'react';
import type {
WoodenFishAudioAsset,
WoodenFishSessionResponse,
WoodenFishWorkspaceCreateRequest,
} 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 { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
type WoodenFishWorkspaceProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitted: (
result: WoodenFishSessionResponse,
payload: WoodenFishWorkspaceCreateRequest,
) => void;
};
type WoodenFishWorkspaceFormState = {
workTitle: string;
workDescription: string;
themeTags: string;
hitObjectPrompt: string;
hitObjectReferenceImageSrc: string;
hitSoundPrompt: string;
hitSoundAsset: WoodenFishAudioAsset | null;
floatingWords: string[];
};
const DEFAULT_FLOATING_WORDS = [
'幸运',
'健康',
'财富',
'姻缘',
'幸福',
'事业',
'成功',
'功德',
];
const DEFAULT_FORM_STATE: WoodenFishWorkspaceFormState = {
workTitle: '今日敲木鱼',
workDescription: '',
themeTags: '敲木鱼 解压',
hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
hitObjectReferenceImageSrc: '',
hitSoundPrompt: '清脆短促的木鱼敲击声',
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[] = [];
for (const word of words) {
const trimmed = word.trim().replace(/[+]\s*1$/u, '').trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
normalized.push(trimmed);
if (normalized.length >= 8) {
break;
}
}
return normalized.length > 0 ? normalized : DEFAULT_FLOATING_WORDS;
}
function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') {
return new Promise<WoodenFishAudioAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
});
};
reader.readAsDataURL(file);
});
}
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;
}) {
const [isRecording, setIsRecording] = useState(false);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const startRecording = async () => {
if (disabled || isRecording) {
return;
}
try {
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === 'undefined'
) {
throw new Error('当前浏览器不支持录音。');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, {
type: recorder.mimeType || 'audio/webm',
});
stream.getTracks().forEach((track) => track.stop());
const file = new File([blob], `wooden-fish-hit-${Date.now()}.webm`, {
type: blob.type,
});
void readAudioFileAsAsset(file, 'recorded')
.then(onAssetChange)
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '录音保存失败。',
);
});
};
recorderRef.current = recorder;
recorder.start();
setIsRecording(true);
onError(null);
} catch (caughtError) {
onError(
caughtError instanceof Error ? caughtError.message : '录音启动失败。',
);
}
};
const stopRecording = () => {
recorderRef.current?.stop();
recorderRef.current = null;
setIsRecording(false);
};
return (
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
{asset ? (
<button
type="button"
onClick={() => onAssetChange(null)}
disabled={disabled}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
>
</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 ${
disabled ? 'pointer-events-none opacity-55' : ''
}`}
>
<Upload className="h-4 w-4" />
<input
type="file"
accept="audio/*"
disabled={disabled}
className="sr-only"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
void readAudioFileAsAsset(file, 'uploaded')
.then((nextAsset) => {
onError(null);
onAssetChange(nextAsset);
})
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}}
/>
</label>
<button
type="button"
disabled={disabled}
onClick={() => {
if (isRecording) {
stopRecording();
return;
}
void startRecording();
}}
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
>
{isRecording ? (
<Pause className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
{isRecording ? '停止' : '录音'}
</button>
{asset?.audioSrc ? (
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{asset ? '音效已选择' : '可生成、上传或录制'}
</div>
)}
</div>
</section>
);
}
export function WoodenFishWorkspace({
isBusy = false,
error = null,
onBack,
onSubmitted,
}: WoodenFishWorkspaceProps) {
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
const [localError, setLocalError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [aiRedraw, setAiRedraw] = useState(true);
const normalizedFloatingWords = useMemo(
() => normalizeFloatingWords(formState.floatingWords),
[formState.floatingWords],
);
const canSubmit = Boolean(
formState.workTitle.trim() &&
formState.hitObjectPrompt.trim() &&
normalizedFloatingWords.length > 0,
);
const handleSubmit = async () => {
if (!canSubmit || isSubmitting || isBusy) {
setLocalError('请先补全输入。');
return;
}
setIsSubmitting(true);
setLocalError(null);
try {
const payload: WoodenFishWorkspaceCreateRequest = {
templateId: 'wooden-fish',
workTitle: formState.workTitle.trim(),
workDescription: formState.workDescription.trim(),
themeTags: splitTags(formState.themeTags),
hitObjectPrompt: formState.hitObjectPrompt.trim(),
hitObjectReferenceImageSrc:
formState.hitObjectReferenceImageSrc.trim() || null,
hitSoundPrompt: formState.hitSoundAsset
? null
: formState.hitSoundPrompt.trim() || null,
hitSoundAsset: formState.hitSoundAsset,
floatingWords: normalizedFloatingWords,
};
const response = await woodenFishClient.createSession(payload);
onSubmitted(response, payload);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<ArrowLeft className="h-4 w-4" />
</button>
</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(19rem,0.88fr)]">
<div className="flex min-h-[26rem] min-w-0 flex-col">
<CreativeImageInputPanel
disabled={isBusy || isSubmitting}
isSubmitting={isSubmitting}
uploadedImageSrc={formState.hitObjectReferenceImageSrc}
uploadedImageAlt="敲击物参考图"
mainImageInputId="wooden-fish-hit-object-reference"
promptTextareaId="wooden-fish-hit-object-prompt"
prompt={formState.hitObjectPrompt}
promptLabel="敲什么"
promptRows={4}
aiRedraw={aiRedraw}
promptReferenceImages={[]}
submitLabel="生成"
submitDisabled={!canSubmit || isSubmitting || isBusy}
labels={{
imageField: '参考图',
uploadImage: '上传参考图',
replaceImage: '替换参考图',
emptyImageHint: '上传图像',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图',
removeImageConfirmBody: '移除后仍可用文字描述生成敲击物图案。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '敲击物参考图',
closePromptReferencePreview: '关闭预览',
}}
onMainImageFileSelect={(file) => {
void readPuzzleReferenceImageAsDataUrl(file)
.then((dataUrl) => {
setLocalError(null);
setFormState((current) => ({
...current,
hitObjectReferenceImageSrc: dataUrl,
}));
setAiRedraw(true);
})
.catch((caughtError) => {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '参考图读取失败。',
);
});
}}
onMainImageRemove={() => {
setFormState((current) => ({
...current,
hitObjectReferenceImageSrc: '',
}));
}}
onAiRedrawChange={setAiRedraw}
onPromptChange={(value) =>
setFormState((current) => ({
...current,
hitObjectPrompt: value,
}))
}
onSubmit={handleSubmit}
/>
</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}
prompt={formState.hitSoundPrompt}
asset={formState.hitSoundAsset}
onPromptChange={(value) =>
setFormState((current) => ({
...current,
hitSoundPrompt: value,
}))
}
onAssetChange={(asset) =>
setFormState((current) => ({
...current,
hitSoundAsset: asset,
}))
}
onError={setLocalError}
/>
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="grid gap-2 sm:grid-cols-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>
</section>
</div>
</div>
{localError || error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{localError ?? error}
</div>
) : null}
<div className="mt-3 flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting || isBusy}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || isSubmitting || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</div>
);
}
export default WoodenFishWorkspace;

View File

@@ -0,0 +1,221 @@
import {
ArrowLeft,
Loader2,
Play,
RefreshCcw,
Send,
Volume2,
} from 'lucide-react';
import { useState } from 'react';
import type {
WoodenFishDraftResponse,
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 { ResolvedAssetImage } from '../ResolvedAssetImage';
type WoodenFishResultViewProps = {
profile: WoodenFishDraftResponse | WoodenFishWorkProfileResponse;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onEdit: () => void;
onStartTestRun: () => void;
onPublish: () => void;
onRegenerateHitObject: () => void;
onGenerateHitSound: () => void;
};
function isWoodenFishWorkProfile(
profile: WoodenFishResultViewProps['profile'],
): profile is WoodenFishWorkProfileResponse {
return 'summary' in profile;
}
export function WoodenFishResultView({
profile,
isBusy = false,
error = null,
onBack,
onEdit,
onStartTestRun,
onPublish,
onRegenerateHitObject,
onGenerateHitSound,
}: WoodenFishResultViewProps) {
const [isPublishing, setIsPublishing] = useState(false);
const isWorkProfile = isWoodenFishWorkProfile(profile);
const draft = isWorkProfile ? profile.draft : profile;
const summary = isWorkProfile ? profile.summary : null;
const hitObjectAsset = isWorkProfile
? profile.hitObjectAsset
: draft.hitObjectAsset;
const hitObjectSrc =
hitObjectAsset?.imageSrc?.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC;
const backgroundAsset = isWorkProfile
? profile.backgroundAsset ?? draft.backgroundAsset
: draft.backgroundAsset;
const backgroundSrc = backgroundAsset?.imageSrc?.trim() || '';
const hitSoundAsset = isWorkProfile
? profile.hitSoundAsset
: draft.hitSoundAsset;
const floatingWords = isWorkProfile ? profile.floatingWords : draft.floatingWords;
const title =
summary?.workTitle?.trim() || draft.workTitle.trim() || '敲木鱼';
const description =
summary?.workDescription?.trim() || draft.workDescription.trim();
const { resolvedUrl: resolvedAudioUrl } = useResolvedAssetReadUrl(
hitSoundAsset?.audioSrc,
);
const handlePublish = async () => {
setIsPublishing(true);
try {
await Promise.resolve(onPublish());
} finally {
setIsPublishing(false);
}
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<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>
</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(19rem,0.75fr)]">
<section className="platform-subpanel flex min-h-0 flex-col rounded-[1.25rem] p-4">
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
{title}
</div>
{description ? (
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
{description}
</div>
) : null}
<div className="mt-4 grid min-h-0 flex-1 place-items-center">
<div className="relative grid aspect-[9/16] h-full max-h-[min(58vh,34rem)] w-full max-w-[20rem] place-items-center overflow-hidden rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/78">
{backgroundSrc ? (
<ResolvedAssetImage
src={backgroundSrc}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
) : null}
<div className="absolute inset-0 bg-white/10" />
<ResolvedAssetImage
src={hitObjectSrc}
fallbackSrc={WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC}
alt="敲击物图案"
className="relative z-10 w-[68%] object-contain drop-shadow-[0_18px_28px_rgba(15,23,42,0.18)]"
/>
</div>
</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="mt-3 flex flex-wrap gap-2">
{floatingWords.map((word) => (
<span
key={word}
className="rounded-full border border-[var(--platform-subpanel-border)] bg-white/88 px-3 py-1.5 text-xs font-black text-[var(--platform-text-strong)]"
>
{word}
</span>
))}
</div>
<div className="mt-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{resolvedAudioUrl ? (
<audio controls src={resolvedAudioUrl} className="mt-3 w-full" />
) : (
<div className="platform-banner platform-banner--neutral mt-3 rounded-2xl text-sm leading-6">
</div>
)}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
<div className="mt-auto grid gap-2 pt-4">
<button
type="button"
onClick={onEdit}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3 text-sm"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={onStartTestRun}
disabled={isBusy}
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" />
</button>
<button
type="button"
onClick={() => {
void handlePublish();
}}
disabled={isBusy || isPublishing}
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</section>
</div>
</div>
);
}
export default WoodenFishResultView;

View File

@@ -0,0 +1,398 @@
import { ArrowLeft, Loader2, RotateCcw, X } from 'lucide-react';
import {
type CSSProperties,
type PointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
WoodenFishRuntimeRunSnapshotResponse,
WoodenFishWordCounter,
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 { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
applyWoodenFishTap,
chooseWoodenFishFloatingWord,
formatWoodenFishFloatingText,
isWoodenFishFunctionalTarget,
normalizeWoodenFishFloatingWords,
} from './woodenFishRuntimeModel';
type WoodenFishRuntimeShellProps = {
profile?: WoodenFishWorkProfileResponse | null;
run?: WoodenFishRuntimeRunSnapshotResponse | null;
snapshot?: WoodenFishRuntimeRunSnapshotResponse | null;
isBusy?: boolean;
error?: string | null;
onCheckpoint?: (payload: {
totalTapCount: number;
wordCounters: WoodenFishWordCounter[];
}) => Promise<unknown>;
onFinish?: (payload: {
totalTapCount: number;
wordCounters: WoodenFishWordCounter[];
}) => Promise<unknown>;
onRestart?: () => void;
onExit?: () => void;
onBack?: () => void;
};
type FloatingText = {
id: string;
text: string;
x: number;
y: number;
};
const AUDIO_POOL_SIZE = 5;
const MIN_AUDIO_INTERVAL_MS = 48;
function getRun(
run: WoodenFishRuntimeRunSnapshotResponse | null | undefined,
snapshot: WoodenFishRuntimeRunSnapshotResponse | null | undefined,
) {
return run ?? snapshot ?? null;
}
export function WoodenFishRuntimeShell({
profile = null,
run,
snapshot,
isBusy = false,
error = null,
onCheckpoint,
onFinish,
onRestart,
onExit,
onBack,
}: WoodenFishRuntimeShellProps) {
const activeRun = getRun(run, snapshot);
const exitHandler = onExit ?? onBack;
const [totalTapCount, setTotalTapCount] = useState(
activeRun?.totalTapCount ?? 0,
);
const [wordCounters, setWordCounters] = useState<WoodenFishWordCounter[]>(
activeRun?.wordCounters ?? [],
);
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 currentSnapshotRef = useRef({ totalTapCount, wordCounters });
const words = useMemo(
() => normalizeWoodenFishFloatingWords(profile?.floatingWords ?? []),
[profile?.floatingWords],
);
const hitObjectSrc =
profile?.hitObjectAsset?.imageSrc?.trim() ||
profile?.draft.hitObjectAsset?.imageSrc?.trim() ||
WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC;
const backgroundSrc =
profile?.backgroundAsset?.imageSrc?.trim() ||
profile?.draft.backgroundAsset?.imageSrc?.trim() ||
'';
const hitSoundSrc =
profile?.hitSoundAsset?.audioSrc ?? profile?.draft.hitSoundAsset?.audioSrc;
const { resolvedUrl: resolvedAudioUrl } = useResolvedAssetReadUrl(hitSoundSrc);
useEffect(() => {
currentSnapshotRef.current = { totalTapCount, wordCounters };
}, [totalTapCount, wordCounters]);
useEffect(() => {
setTotalTapCount(activeRun?.totalTapCount ?? 0);
setWordCounters(activeRun?.wordCounters ?? []);
}, [activeRun?.runId, activeRun?.totalTapCount, activeRun?.wordCounters]);
useEffect(() => {
audioPoolRef.current.forEach((audio) => {
audio.pause();
audio.src = '';
});
audioPoolRef.current = [];
audioIndexRef.current = 0;
if (!resolvedAudioUrl) {
return undefined;
}
audioPoolRef.current = Array.from({ length: AUDIO_POOL_SIZE }, () => {
const audio = new Audio(resolvedAudioUrl);
audio.preload = 'auto';
return audio;
});
return () => {
audioPoolRef.current.forEach((audio) => {
audio.pause();
audio.src = '';
});
audioPoolRef.current = [];
};
}, [resolvedAudioUrl]);
useEffect(() => {
if (!onCheckpoint || !activeRun?.runId || activeRun.status !== 'playing') {
return undefined;
}
const timer = window.setInterval(() => {
const snapshotPayload = currentSnapshotRef.current;
if (
snapshotPayload.totalTapCount <= 0 ||
Date.now() - lastCheckpointAtRef.current < 2500
) {
return;
}
lastCheckpointAtRef.current = Date.now();
void onCheckpoint(snapshotPayload).catch(() => undefined);
}, 3000);
return () => window.clearInterval(timer);
}, [activeRun?.runId, activeRun?.status, onCheckpoint]);
const playHitSound = () => {
const now = Date.now();
if (now - lastAudioAtRef.current < MIN_AUDIO_INTERVAL_MS) {
return;
}
lastAudioAtRef.current = now;
const pool = audioPoolRef.current;
if (pool.length === 0) {
return;
}
const audio = pool[audioIndexRef.current % pool.length] ?? null;
if (!audio) {
return;
}
audioIndexRef.current += 1;
audio.currentTime = 0;
void audio.play().catch(() => undefined);
};
const registerTap = (event: PointerEvent<HTMLElement>) => {
if (
isBusy ||
activeRun?.status === 'finished' ||
isWoodenFishFunctionalTarget(event.target)
) {
return;
}
const bounds = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / Math.max(bounds.width, 1)) * 100;
const y = ((event.clientY - bounds.top) / Math.max(bounds.height, 1)) * 100;
const word = chooseWoodenFishFloatingWord(words);
const nextSnapshot = applyWoodenFishTap(
currentSnapshotRef.current,
word,
);
setTotalTapCount(nextSnapshot.totalTapCount);
setWordCounters(nextSnapshot.wordCounters);
setHitPulse((value) => value + 1);
setFloatingTexts((current) => [
...current.slice(-9),
{
id: `${Date.now()}-${nextSnapshot.totalTapCount}`,
text: formatWoodenFishFloatingText(word),
x,
y: Math.max(18, y - 10),
},
]);
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"
onPointerDown={registerTap}
>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.92),transparent_26%),linear-gradient(180deg,#fff8e8_0%,#eef7ed_55%,#e5f2f7_100%)]" />
{backgroundSrc ? (
<ResolvedAssetImage
src={backgroundSrc}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : null}
<div className="pointer-events-none absolute inset-0 bg-white/10" />
<header
data-wooden-fish-functional="true"
className="relative z-30 flex items-start justify-between gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4"
>
<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"
>
<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" />
) : (
<RotateCcw className="h-4 w-4" />
)}
</button>
</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">
<div className="pointer-events-none absolute left-1/2 top-[54%] h-[22rem] w-[22rem] max-w-[82vw] -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/36 blur-2xl" />
<div
key={hitPulse}
className="wooden-fish-runtime__object relative z-10 grid aspect-square w-[min(68vw,22rem)] place-items-center"
style={
{
'--wooden-fish-hit': hitPulse,
} as CSSProperties
}
>
<ResolvedAssetImage
src={hitObjectSrc}
fallbackSrc={WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC}
alt="敲击物图案"
draggable={false}
className="h-full w-full object-contain drop-shadow-[0_28px_30px_rgba(91,64,32,0.22)]"
/>
</div>
{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)]"
style={{
left: `${item.x}%`,
top: `${item.y}%`,
}}
onAnimationEnd={() => {
setFloatingTexts((current) =>
current.filter((floating) => floating.id !== item.id),
);
}}
>
{item.text}
</div>
))}
</main>
{error ? (
<div
data-wooden-fish-functional="true"
className="absolute bottom-20 left-3 right-3 z-40 rounded-2xl bg-rose-600 px-4 py-3 text-sm font-bold text-white shadow-lg"
>
{error}
</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__object {
animation: wooden-fish-hit 220ms ease both;
}
.wooden-fish-runtime__floating-text {
transform: translate(-50%, -50%);
animation: wooden-fish-float 920ms ease-out both;
}
@keyframes wooden-fish-hit {
0% {
transform: scale(1) rotate(0deg);
}
42% {
transform: scale(0.92, 0.86) rotate(-1deg);
}
72% {
transform: scale(1.04, 1.02) rotate(1deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
@keyframes wooden-fish-float {
0% {
opacity: 0;
transform: translate(-50%, 0.3rem) scale(0.88);
}
18% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(-50%, -3rem) scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.wooden-fish-runtime__object,
.wooden-fish-runtime__floating-text {
animation: none;
}
}
`}</style>
</div>
);
}
export default WoodenFishRuntimeShell;

View File

@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { expect, test } from 'vitest';
import {
applyWoodenFishTap,
chooseWoodenFishFloatingWord,
formatWoodenFishFloatingText,
isWoodenFishFunctionalTarget,
normalizeWoodenFishFloatingWords,
} from './woodenFishRuntimeModel';
test('applyWoodenFishTap creates word counter on first appearance', () => {
const snapshot = applyWoodenFishTap(
{
totalTapCount: 0,
wordCounters: [],
},
'幸运',
);
expect(snapshot).toEqual({
totalTapCount: 1,
wordCounters: [{ text: '幸运', count: 1 }],
});
});
test('applyWoodenFishTap keeps counting repeated and rapid taps', () => {
const first = applyWoodenFishTap(
{
totalTapCount: 0,
wordCounters: [],
},
'功德',
);
const second = applyWoodenFishTap(first, '功德');
const third = applyWoodenFishTap(second, '健康');
expect(third.totalTapCount).toBe(3);
expect(third.wordCounters).toEqual([
{ text: '功德', count: 2 },
{ text: '健康', count: 1 },
]);
});
test('chooseWoodenFishFloatingWord samples normalized words by random index', () => {
expect(chooseWoodenFishFloatingWord(['幸运', '功德'], () => 0.72)).toBe(
'功德',
);
expect(chooseWoodenFishFloatingWord([], () => 0)).toBe('幸运');
});
test('floating word model stores base terms and formats runtime reward text', () => {
expect(normalizeWoodenFishFloatingWords([' 幸运+1 ', '幸运', '健康1'])).toEqual(
['幸运', '健康'],
);
expect(formatWoodenFishFloatingText('幸运')).toBe('幸运+1');
expect(formatWoodenFishFloatingText('功德+1')).toBe('功德+1');
});
test('isWoodenFishFunctionalTarget detects functional controls', () => {
const root = document.createElement('div');
const button = document.createElement('button');
button.dataset.woodenFishFunctional = 'true';
const icon = document.createElement('span');
button.appendChild(icon);
root.appendChild(button);
expect(isWoodenFishFunctionalTarget(icon)).toBe(true);
expect(isWoodenFishFunctionalTarget(root)).toBe(false);
});

View File

@@ -0,0 +1,97 @@
import type { WoodenFishWordCounter } from '../../../packages/shared/src/contracts/woodenFish';
export type WoodenFishTapSnapshot = {
totalTapCount: number;
wordCounters: WoodenFishWordCounter[];
};
const DEFAULT_FLOATING_WORDS = [
'幸运',
'健康',
'财富',
'姻缘',
'幸福',
'事业',
'成功',
'功德',
] as const;
const DEFAULT_FLOATING_WORD = DEFAULT_FLOATING_WORDS[0];
function normalizeWoodenFishFloatingWord(word: string) {
return word.trim().replace(/[+]\s*1$/u, '').trim();
}
export function normalizeWoodenFishFloatingWords(words: readonly string[]) {
const seen = new Set<string>();
const normalized: string[] = [];
for (const word of words) {
const trimmed = normalizeWoodenFishFloatingWord(word);
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
normalized.push(trimmed);
if (normalized.length >= 8) {
break;
}
}
return normalized.length > 0 ? normalized : DEFAULT_FLOATING_WORDS;
}
export function formatWoodenFishFloatingText(word: string) {
const normalizedWord = normalizeWoodenFishFloatingWord(word) || DEFAULT_FLOATING_WORD;
return `${normalizedWord}+1`;
}
export function chooseWoodenFishFloatingWord(
words: readonly string[],
random: () => number = Math.random,
) {
const normalizedWords = normalizeWoodenFishFloatingWords(words);
const index = Math.max(
0,
Math.min(
normalizedWords.length - 1,
Math.floor(random() * normalizedWords.length),
),
);
return normalizedWords[index] ?? DEFAULT_FLOATING_WORD;
}
export function applyWoodenFishTap(
snapshot: WoodenFishTapSnapshot,
word: string,
): WoodenFishTapSnapshot {
const normalizedWord = normalizeWoodenFishFloatingWord(word) || DEFAULT_FLOATING_WORD;
let hasCounter = false;
const wordCounters = snapshot.wordCounters.map((counter) => {
if (counter.text !== normalizedWord) {
return counter;
}
hasCounter = true;
return {
...counter,
count: counter.count + 1,
};
});
if (!hasCounter) {
wordCounters.push({
text: normalizedWord,
count: 1,
});
}
return {
totalTapCount: snapshot.totalTapCount + 1,
wordCounters,
};
}
export function isWoodenFishFunctionalTarget(target: EventTarget | null) {
return (
target instanceof Element &&
Boolean(target.closest('[data-wooden-fish-functional="true"]'))
);
}

View File

@@ -50,4 +50,31 @@ describe('appPageRoutes', () => {
'/runtime/jump-hop',
);
});
it('resolves wooden fish creation and runtime routes', () => {
expect(resolveSelectionStageFromPath('/creation/wooden-fish')).toBe(
'wooden-fish-workspace',
);
expect(resolveSelectionStageFromPath('/creation/wooden-fish/generating')).toBe(
'wooden-fish-generating',
);
expect(resolveSelectionStageFromPath('/creation/wooden-fish/result')).toBe(
'wooden-fish-result',
);
expect(resolveSelectionStageFromPath('/runtime/wooden-fish')).toBe(
'wooden-fish-runtime',
);
expect(resolvePathForSelectionStage('wooden-fish-workspace')).toBe(
'/creation/wooden-fish',
);
expect(resolvePathForSelectionStage('wooden-fish-generating')).toBe(
'/creation/wooden-fish/generating',
);
expect(resolvePathForSelectionStage('wooden-fish-result')).toBe(
'/creation/wooden-fish/result',
);
expect(resolvePathForSelectionStage('wooden-fish-runtime')).toBe(
'/runtime/wooden-fish',
);
});
});

View File

@@ -29,6 +29,10 @@ const STAGE_ROUTE_ENTRIES = [
['bark-battle-generating', '/creation/bark-battle/generating'],
['bark-battle-result', '/creation/bark-battle/result'],
['bark-battle-runtime', '/runtime/bark-battle'],
['wooden-fish-workspace', '/creation/wooden-fish'],
['wooden-fish-generating', '/creation/wooden-fish/generating'],
['wooden-fish-result', '/creation/wooden-fish/result'],
['wooden-fish-runtime', '/runtime/wooden-fish'],
['creative-agent-workspace', '/creation/creative-agent'],
['visual-novel-agent-workspace', '/creation/visual-novel/agent'],
['visual-novel-result', '/creation/visual-novel/result'],

View File

@@ -6,6 +6,7 @@ import {
buildMatch3DGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
buildPuzzleGenerationAnchorEntries,
buildWoodenFishGenerationAnchorEntries,
createMiniGameDraftGenerationState,
type MiniGameDraftGenerationState,
} from './miniGameDraftGenerationProgress';
@@ -383,6 +384,56 @@ describe('miniGameDraftGenerationProgress', () => {
]);
});
test('wooden fish draft generation exposes hit object, background and sound pipeline', () => {
const state = createMiniGameDraftGenerationState('wooden-fish');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 28_000,
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'wooden-fish-draft',
'wooden-fish-hit-object',
'wooden-fish-background',
'wooden-fish-hit-sound',
'wooden-fish-write-draft',
]);
expect(progress?.phaseId).toBe('wooden-fish-hit-object');
expect(progress?.phaseLabel).toBe('生成敲击物图案');
expect(progress?.estimatedRemainingMs).toBe(272_000);
});
test('wooden fish generation anchors expose hit object, sound and words', () => {
const entries = buildWoodenFishGenerationAnchorEntries(null, {
templateId: 'wooden-fish',
workTitle: '每日一敲',
workDescription: '敲一下,好事发生。',
themeTags: ['解压'],
hitObjectPrompt: '金色小木鱼',
hitSoundPrompt: '清脆木鱼声',
floatingWords: ['幸运+1', '功德+1'],
});
expect(entries).toEqual([
{
id: 'wooden-fish-hit-object',
label: '敲击物',
value: '金色小木鱼',
},
{
id: 'wooden-fish-hit-sound',
label: '音效',
value: '清脆木鱼声',
},
{
id: 'wooden-fish-words',
label: '飘字',
value: '幸运+1、功德+1',
},
]);
});
test('puzzle generation anchors expose form payload as the display source', () => {
const entries = buildPuzzleGenerationAnchorEntries({
sessionId: 'puzzle-session-1',

View File

@@ -16,6 +16,10 @@ import type {
CustomWorldGenerationStep,
} from '../../packages/shared/src/contracts/runtime';
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkspaceCreateRequest,
} from '../../packages/shared/src/contracts/woodenFish';
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
import type {
CreateJumpHopSessionRequest,
@@ -28,7 +32,8 @@ export type MiniGameDraftGenerationKind =
| 'square-hole'
| 'match3d'
| 'baby-object-match'
| 'jump-hop';
| 'jump-hop'
| 'wooden-fish';
export type MiniGameDraftGenerationPhase =
| 'idle'
@@ -62,6 +67,11 @@ export type MiniGameDraftGenerationPhase =
| 'jump-hop-tile-atlas'
| 'jump-hop-slice-tiles'
| 'jump-hop-write-draft'
| 'wooden-fish-draft'
| 'wooden-fish-hit-object'
| 'wooden-fish-background'
| 'wooden-fish-hit-sound'
| 'wooden-fish-write-draft'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
@@ -395,6 +405,41 @@ const JUMP_HOP_STEPS = [
const JUMP_HOP_ESTIMATED_WAIT_MS = 5 * 60_000;
const WOODEN_FISH_STEPS = [
{
id: 'wooden-fish-draft',
label: '整理玩法草稿',
detail: '保存作品信息、敲击物、音效和飘字配置。',
weight: 8,
},
{
id: 'wooden-fish-hit-object',
label: '生成敲击物图案',
detail: '使用 image2 生成最终运行态敲击物图案。',
weight: 34,
},
{
id: 'wooden-fish-background',
label: '生成背景环境图',
detail: '使用 image2 生成敲击背景环境图。',
weight: 34,
},
{
id: 'wooden-fish-hit-sound',
label: '准备敲击音效',
detail: '生成或写回短促敲击音效资产。',
weight: 16,
},
{
id: 'wooden-fish-write-draft',
label: '写入正式草稿',
detail: '保存图案、背景、音效、飘字和封面摘要。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const WOODEN_FISH_ESTIMATED_WAIT_MS = 5 * 60_000;
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
@@ -415,6 +460,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'jump-hop') {
return JUMP_HOP_STEPS;
}
if (kind === 'wooden-fish') {
return WOODEN_FISH_STEPS;
}
return BIG_FISH_STEPS;
}
@@ -472,8 +520,10 @@ export function createMiniGameDraftGenerationState(
? 'match3d-work-title'
: kind === 'baby-object-match'
? 'baby-object-draft'
: kind === 'jump-hop'
? 'jump-hop-draft'
: kind === 'jump-hop'
? 'jump-hop-draft'
: kind === 'wooden-fish'
? 'wooden-fish-draft'
: 'compile',
startedAtMs: Date.now(),
completedAssetCount: 0,
@@ -558,6 +608,24 @@ function resolveJumpHopPhaseByElapsedMs(
return 'jump-hop-draft';
}
function resolveWoodenFishPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 270_000) {
return 'wooden-fish-write-draft';
}
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';
}
function resolvePuzzleTimelineByElapsedMs(
elapsedMs: number,
state: MiniGameDraftGenerationState,
@@ -646,6 +714,13 @@ export function buildMiniGameDraftGenerationProgress(
...state,
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
}
: state.kind === 'wooden-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveWoodenFishPhaseByElapsedMs(elapsedMs),
}
: state;
const steps =
@@ -680,6 +755,8 @@ export function buildMiniGameDraftGenerationProgress(
? 0.52
: normalizedState.kind === 'jump-hop'
? 0.5
: normalizedState.kind === 'wooden-fish'
? 0.5
: 0;
const overallProgress =
normalizedState.phase === 'failed'
@@ -713,6 +790,8 @@ export function buildMiniGameDraftGenerationProgress(
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
: normalizedState.kind === 'jump-hop'
? '跳一跳草稿已准备完成,可进入结果页试玩或发布。'
: normalizedState.kind === 'wooden-fish'
? '敲木鱼草稿已准备完成,可进入结果页试玩或发布。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: activeStep.detail),
batchLabel: activeStep.label,
@@ -738,6 +817,8 @@ export function buildMiniGameDraftGenerationProgress(
)
: normalizedState.kind === 'jump-hop'
? Math.max(0, JUMP_HOP_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'wooden-fish'
? Math.max(0, WOODEN_FISH_ESTIMATED_WAIT_MS - elapsedMs)
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(
@@ -808,6 +889,57 @@ export function buildJumpHopGenerationAnchorEntries(
.filter((entry) => entry.value.trim());
}
export function buildWoodenFishGenerationAnchorEntries(
session: WoodenFishSessionSnapshotResponse | null | undefined,
formPayload: WoodenFishWorkspaceCreateRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
const draft = session?.draft;
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'wooden-fish-hit-object',
label: '敲击物',
value:
formPayload?.hitObjectPrompt?.trim() ||
draft?.hitObjectPrompt?.trim() ||
'',
},
{
key: 'wooden-fish-hit-sound',
label: '音效',
value:
formPayload?.hitSoundPrompt?.trim() ||
draft?.hitSoundPrompt?.trim() ||
draft?.hitSoundAsset?.prompt?.trim() ||
'',
},
{
key: 'wooden-fish-words',
label: '飘字',
value:
formPayload?.floatingWords
?.map((word) => word.trim())
.filter(Boolean)
.slice(0, 8)
.join('、') ||
draft?.floatingWords
?.map((word) => word.trim())
.filter(Boolean)
.slice(0, 8)
.join('、') ||
'',
},
];
return entries
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
.map((entry) => ({
id: entry.key,
label: entry.label,
value: entry.value,
}))
.filter((entry) => entry.value.trim());
}
export function buildPuzzleGenerationAnchorEntries(
session: PuzzleAgentSessionSnapshot | null | undefined,
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
buildJumpHopPublicWorkCode,
buildWoodenFishPublicWorkCode,
isSameJumpHopPublicWorkCode,
isSameWoodenFishPublicWorkCode,
} from './publicWorkCode';
describe('publicWorkCode', () => {
@@ -23,4 +25,25 @@ describe('publicWorkCode', () => {
),
).toBe(true);
});
it('builds wooden fish public work codes with WF prefix', () => {
expect(buildWoodenFishPublicWorkCode('wooden-fish-profile-1234abcd')).toBe(
'WF-1234ABCD',
);
});
it('matches wooden fish public work codes and raw profile ids', () => {
expect(
isSameWoodenFishPublicWorkCode(
'wf-1234abcd',
'wooden-fish-profile-1234abcd',
),
).toBe(true);
expect(
isSameWoodenFishPublicWorkCode(
'wooden-fish-profile-1234abcd',
'wooden-fish-profile-1234abcd',
),
).toBe(true);
});
});

View File

@@ -75,6 +75,14 @@ export function buildJumpHopPublicWorkCode(profileId: string) {
return `JH-${suffix}`;
}
export function buildWoodenFishPublicWorkCode(profileId: string) {
const normalized = normalizePublicCodeText(profileId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `WF-${suffix}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -167,3 +175,16 @@ export function isSameJumpHopPublicWorkCode(keyword: string, profileId: string)
normalizedKeyword === normalizePublicCodeText(profileId)
);
}
export function isSameWoodenFishPublicWorkCode(
keyword: string,
profileId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildWoodenFishPublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}

View File

@@ -0,0 +1,276 @@
import type {
WoodenFishActionRequest,
WoodenFishActionResponse,
WoodenFishCheckpointRunRequest,
WoodenFishFinishRunRequest,
WoodenFishGalleryCardResponse,
WoodenFishGalleryDetailResponse,
WoodenFishGalleryResponse,
WoodenFishRunResponse,
WoodenFishRuntimeRunSnapshotResponse,
WoodenFishSessionResponse,
WoodenFishSessionSnapshotResponse,
WoodenFishWorkDetailResponse,
WoodenFishWorkMutationResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorkspaceCreateRequest,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { type ApiRetryOptions, requestJson } from '../apiClient';
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';
const WOODEN_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
export type {
WoodenFishActionRequest,
WoodenFishActionResponse,
WoodenFishCheckpointRunRequest,
WoodenFishFinishRunRequest,
WoodenFishGalleryCardResponse,
WoodenFishGalleryDetailResponse,
WoodenFishGalleryResponse,
WoodenFishRunResponse,
WoodenFishRuntimeRunSnapshotResponse,
WoodenFishSessionResponse,
WoodenFishSessionSnapshotResponse,
WoodenFishWorkDetailResponse,
WoodenFishWorkMutationResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorkspaceCreateRequest,
};
export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest;
export type WoodenFishSessionSnapshot = WoodenFishSessionSnapshotResponse;
const woodenFishCreationClient = createCreationAgentClient<
WoodenFishWorkspaceCreateRequest,
WoodenFishSessionResponse,
WoodenFishSessionResponse,
WoodenFishSessionSnapshotResponse,
never,
never,
WoodenFishActionRequest,
WoodenFishActionResponse
>({
apiBase: WOODEN_FISH_API_BASE,
messages: {
createSession: '创建敲木鱼共创会话失败',
getSession: '读取敲木鱼共创会话失败',
sendMessage: '发送敲木鱼共创消息失败',
streamIncomplete: '敲木鱼共创消息流式结果不完整',
executeAction: '执行敲木鱼共创操作失败',
},
});
type FlattenedWoodenFishWorkProfileResponse = Omit<
WoodenFishWorkProfileResponse,
'summary'
> &
WoodenFishWorkSummaryResponse;
function normalizeWoodenFishWorkProfile(
work:
| WoodenFishWorkProfileResponse
| FlattenedWoodenFishWorkProfileResponse,
): WoodenFishWorkProfileResponse {
if ('summary' in work && work.summary) {
return work;
}
const flattened = work as FlattenedWoodenFishWorkProfileResponse;
const summary: WoodenFishWorkProfileResponse['summary'] = {
runtimeKind: flattened.runtimeKind,
workId: flattened.workId,
profileId: flattened.profileId,
ownerUserId: flattened.ownerUserId,
sourceSessionId: flattened.sourceSessionId ?? null,
workTitle: flattened.workTitle,
workDescription: flattened.workDescription,
themeTags: flattened.themeTags,
coverImageSrc: flattened.coverImageSrc ?? null,
publicationStatus: flattened.publicationStatus,
playCount: flattened.playCount,
updatedAt: flattened.updatedAt,
publishedAt: flattened.publishedAt ?? null,
publishReady: flattened.publishReady,
generationStatus: flattened.generationStatus,
};
return {
summary,
draft: flattened.draft,
hitObjectAsset: flattened.hitObjectAsset,
backgroundAsset:
flattened.backgroundAsset ?? flattened.draft?.backgroundAsset ?? null,
hitSoundAsset: flattened.hitSoundAsset,
floatingWords: flattened.floatingWords,
};
}
function normalizeWoodenFishActionResponse(
response: WoodenFishActionResponse,
): WoodenFishActionResponse {
return {
...response,
work: response.work ? normalizeWoodenFishWorkProfile(response.work) : null,
};
}
function normalizeWoodenFishWorkDetailResponse(
response: WoodenFishWorkDetailResponse,
): WoodenFishWorkDetailResponse {
return {
...response,
item: normalizeWoodenFishWorkProfile(response.item),
};
}
function normalizeWoodenFishWorkMutationResponse(
response: WoodenFishWorkMutationResponse,
): WoodenFishWorkMutationResponse {
return {
...response,
item: normalizeWoodenFishWorkProfile(response.item),
};
}
export function createWoodenFishCreationSession(
payload: WoodenFishWorkspaceCreateRequest,
) {
return woodenFishCreationClient.createSession(payload);
}
export function getWoodenFishCreationSession(sessionId: string) {
return woodenFishCreationClient.getSession(sessionId);
}
export function executeWoodenFishCreationAction(
sessionId: string,
payload: WoodenFishActionRequest,
) {
return woodenFishCreationClient
.executeAction(sessionId, payload)
.then(normalizeWoodenFishActionResponse);
}
export async function getWoodenFishWorkDetail(profileId: string) {
const response = await requestJson<WoodenFishWorkDetailResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取敲木鱼作品详情失败',
);
return normalizeWoodenFishWorkDetailResponse(response);
}
export async function listWoodenFishGallery() {
return requestJson<WoodenFishGalleryResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery`,
{ method: 'GET' },
'读取敲木鱼广场失败',
{
retry: WOODEN_FISH_RUNTIME_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
}
export async function getWoodenFishGalleryDetail(publicWorkCode: string) {
const response = await requestJson<WoodenFishGalleryDetailResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery/${encodeURIComponent(publicWorkCode)}`,
{ method: 'GET' },
'读取敲木鱼广场详情失败',
{
retry: WOODEN_FISH_RUNTIME_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
return normalizeWoodenFishWorkDetailResponse(response);
}
export async function publishWoodenFishWork(profileId: string) {
const response = await requestJson<WoodenFishWorkMutationResponse>(
`${WOODEN_FISH_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
{ method: 'POST' },
'发布敲木鱼作品失败',
);
return normalizeWoodenFishWorkMutationResponse(response);
}
export async function startWoodenFishRuntimeRun(profileId: string) {
return requestJson<WoodenFishRunResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/runs`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ profileId }),
},
'启动敲木鱼运行态失败',
);
}
export async function checkpointWoodenFishRun(
runId: string,
payload: Omit<WoodenFishCheckpointRunRequest, 'clientEventId'>,
) {
const requestPayload: WoodenFishCheckpointRunRequest = {
...payload,
clientEventId: `checkpoint-${runId}-${Date.now()}`,
};
return requestJson<WoodenFishRunResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/checkpoint`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(requestPayload),
},
'保存敲木鱼进度失败',
);
}
export async function finishWoodenFishRun(
runId: string,
payload: Omit<WoodenFishFinishRunRequest, 'clientEventId'>,
) {
const requestPayload: WoodenFishFinishRunRequest = {
...payload,
clientEventId: `finish-${runId}-${Date.now()}`,
};
return requestJson<WoodenFishRunResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/finish`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(requestPayload),
},
'结束敲木鱼运行失败',
);
}
export const woodenFishClient = {
checkpointRun: checkpointWoodenFishRun,
createSession: createWoodenFishCreationSession,
executeAction: executeWoodenFishCreationAction,
finishRun: finishWoodenFishRun,
getGalleryDetail: getWoodenFishGalleryDetail,
getSession: getWoodenFishCreationSession,
getWorkDetail: getWoodenFishWorkDetail,
listGallery: listWoodenFishGallery,
publishWork: publishWoodenFishWork,
startRun: startWoodenFishRuntimeRun,
};

View File

@@ -0,0 +1,5 @@
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC =
'/wooden-fish/default-hit-object.png';
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT =
'默认敲击物图案,圆润木质质感,透明背景';