feat: add wooden fish play template
This commit is contained in:
384
src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx
Normal file
384
src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
97
src/components/wooden-fish-runtime/woodenFishRuntimeModel.ts
Normal file
97
src/components/wooden-fish-runtime/woodenFishRuntimeModel.ts
Normal 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"]'))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user