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
@@ -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);
});
@@ -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
@@ -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'
+26 -16
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) {
@@ -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',
@@ -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;
}
@@ -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();
});
File diff suppressed because it is too large Load Diff
@@ -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;
@@ -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;
@@ -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);
});
@@ -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"]'))
);
}