feat: add wooden fish play template

This commit is contained in:
2026-05-21 23:34:07 +08:00
parent ef09a23c35
commit 5b0f9f3763
121 changed files with 11580 additions and 159 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

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

View File

@@ -133,6 +133,7 @@ import {
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformPublicWorkCode,
@@ -1843,22 +1844,31 @@ async function getPublicWorkAuthorSummary(
}
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
const kind = isBigFishGalleryEntry(entry)
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: isMatch3DGalleryEntry(entry)
? '抓鹅'
: isSquareHoleGalleryEntry(entry)
? '方洞'
: isJumpHopGalleryEntry(entry)
? '跳一跳'
: isVisualNovelGalleryEntry(entry)
? '视觉'
: isEdutainmentGalleryEntry(entry)
? entry.templateName
: 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 (isEdutainmentGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag(entry.templateName);
}
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
}
function getPublicAuthorAvatarLabel(authorDisplayName: string) {

View File

@@ -10,8 +10,10 @@ import {
formatPlatformWorldTime,
isEdutainmentGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
mapWoodenFishWorkToPlatformGalleryCard,
type PlatformEdutainmentGalleryCard,
type PlatformPuzzleGalleryCard,
resolvePlatformPublicWorkCode,
@@ -165,6 +167,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

@@ -22,6 +22,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 {
@@ -32,7 +36,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;
@@ -48,6 +54,7 @@ export type PlatformWorldCardLike =
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard
| PlatformJumpHopGalleryCard
| PlatformWoodenFishGalleryCard
| PlatformVisualNovelGalleryCard
| PlatformEdutainmentGalleryCard;
@@ -202,6 +209,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;
@@ -233,6 +262,7 @@ export type PlatformPublicGalleryCard =
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard
| PlatformJumpHopGalleryCard
| PlatformWoodenFishGalleryCard
| PlatformVisualNovelGalleryCard
| PlatformEdutainmentGalleryCard;
@@ -278,6 +308,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 {
@@ -472,6 +508,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 {
@@ -553,6 +622,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';
}
@@ -722,6 +795,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)
@@ -818,6 +897,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,206 @@
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 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 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/78 p-5">
<ResolvedAssetImage
src={hitObjectSrc}
fallbackSrc={WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC}
alt="敲击物图案"
className="max-h-[min(46vh,22rem)] w-full object-contain drop-shadow-[0_18px_28px_rgba(15,23,42,0.18)]"
/>
</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,384 @@
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 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%)]" />
<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"]'))
);
}