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,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"]'))
);
}