Files
Genarrative/src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx
T
kdletters cfc0c0eadf 合入最新 master
合并 origin/master 并保留平台入口运行态与推荐链路语义
修正合并后基于 tab 语义变化的前端断言
2026-06-11 22:59:35 +08:00

538 lines
17 KiB
TypeScript

import { ArrowLeft } from 'lucide-react';
import {
type CSSProperties,
type PointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import woodenFishRuntimeLogo from '../../../media/logo-runtime-hud.webp';
import type {
WoodenFishRuntimeRunSnapshotResponse,
WoodenFishWordCounter,
WoodenFishWorkProfileResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC,
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
} from '../../services/wooden-fish/woodenFishDefaults';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
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>;
onExit?: () => void;
onBack?: () => void;
};
type FloatingText = {
id: string;
text: string;
x: number;
y: number;
};
const AUDIO_POOL_SIZE = 10;
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,
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 [isCounterPanelOpen, setIsCounterPanelOpen] = useState(false);
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 counterMenuRef = useRef<HTMLDivElement>(null);
const currentSnapshotRef = useRef({ totalTapCount, wordCounters });
const words = useMemo(
() => normalizeWoodenFishFloatingWords(profile?.floatingWords ?? []),
[profile?.floatingWords],
);
const counterEntries = useMemo(
() =>
words.map((word) => ({
text: word,
count:
wordCounters.find((counter) => counter.text === word)?.count ?? 0,
})),
[words, wordCounters],
);
const hitObjectSrc =
profile?.hitObjectAsset?.imageSrc?.trim() ||
profile?.draft.hitObjectAsset?.imageSrc?.trim() ||
WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC;
const backgroundSrc =
profile?.backgroundAsset?.imageSrc?.trim() ||
profile?.draft.backgroundAsset?.imageSrc?.trim() ||
'';
const backButtonSrc =
profile?.backButtonAsset?.imageSrc?.trim() ||
profile?.draft.backButtonAsset?.imageSrc?.trim() ||
'';
const hitSoundSrc =
profile?.hitSoundAsset?.audioSrc ??
profile?.draft.hitSoundAsset?.audioSrc ??
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET.audioSrc;
const { resolvedUrl: resolvedAudioUrl, isResolving: isHitSoundResolving } =
useResolvedAssetReadUrl(hitSoundSrc);
useEffect(() => {
currentSnapshotRef.current = { totalTapCount, wordCounters };
}, [totalTapCount, wordCounters]);
useEffect(() => {
setTotalTapCount(activeRun?.totalTapCount ?? 0);
setWordCounters(activeRun?.wordCounters ?? []);
}, [activeRun?.runId, activeRun?.totalTapCount, activeRun?.wordCounters]);
useEffect(() => {
setIsCounterPanelOpen(false);
}, [activeRun?.runId]);
useEffect(() => {
if (!isCounterPanelOpen) {
return undefined;
}
const handleDocumentPointerDown = (event: globalThis.PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (counterMenuRef.current?.contains(target)) {
return;
}
setIsCounterPanelOpen(false);
};
document.addEventListener('pointerdown', handleDocumentPointerDown);
return () => {
document.removeEventListener('pointerdown', handleDocumentPointerDown);
};
}, [isCounterPanelOpen]);
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();
};
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}
>
<RuntimeResourcePendingMarker
source={hitSoundSrc}
kind="audio"
isPending={isHitSoundResolving}
/>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.92),transparent_26%),linear-gradient(180deg,#fff8e8_0%,#eef7ed_55%,#e5f2f7_100%)]" />
{backgroundSrc ? (
<ResolvedAssetImage
src={backgroundSrc}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : null}
<div className="pointer-events-none absolute inset-0 bg-white/10" />
<header
data-wooden-fish-functional="true"
className="relative z-30 flex items-start justify-between gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4"
>
<PlatformActionButton
onClick={exitHandler}
aria-label="返回"
tone="ghost"
shape="pill"
className={`wooden-fish-runtime__back-button min-h-0 overflow-hidden ${
backButtonSrc
? 'grid h-10 w-10 place-items-center rounded-full bg-transparent p-0 shadow-none sm:h-11 sm:w-11'
: 'bg-white/84 px-3 py-2 text-sm'
}`}
>
{backButtonSrc ? (
<ResolvedAssetImage
src={backButtonSrc}
alt=""
aria-hidden="true"
draggable={false}
className="h-full w-full object-contain drop-shadow-[0_8px_12px_rgba(63,36,18,0.2)]"
/>
) : (
<>
<ArrowLeft className="h-4 w-4" />
返回
</>
)}
</PlatformActionButton>
<div className="flex min-w-0 flex-1 justify-center px-1">
<div
ref={counterMenuRef}
data-wooden-fish-functional="true"
className="wooden-fish-runtime__counter-anchor relative inline-flex max-w-full flex-col items-center"
>
<button
type="button"
aria-controls="wooden-fish-counter-panel"
aria-expanded={isCounterPanelOpen}
aria-label={`总数 ${totalTapCount}${
isCounterPanelOpen
? ',已展开子项计数器'
: ',点击查看子项计数器'
}`}
onClick={() => {
setIsCounterPanelOpen((value) => !value);
}}
className="wooden-fish-runtime__counter-card flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:max-w-[22rem] sm:px-4 sm:pr-5"
>
<span
aria-hidden="true"
className="wooden-fish-runtime__counter-logo"
>
<img
data-testid="wooden-fish-runtime-logo"
src={woodenFishRuntimeLogo}
alt=""
className="wooden-fish-runtime__counter-logo__image"
draggable={false}
/>
</span>
<span className="wooden-fish-runtime__counter-badge shrink-0 text-[0.92rem] font-black sm:text-base">
总数
</span>
<span className="min-w-0 truncate text-[0.92rem] font-black tabular-nums sm:text-base">
{totalTapCount}
</span>
</button>
{isCounterPanelOpen ? (
<div
id="wooden-fish-counter-panel"
data-testid="wooden-fish-counter-panel"
className="wooden-fish-runtime__counter-panel absolute left-1/2 top-full z-40 mt-2 w-[min(16rem,calc(100vw_-_1rem))] -translate-x-1/2 overflow-hidden rounded-[0.95rem] shadow-[0_14px_30px_rgba(45,24,12,0.22)] backdrop-blur"
>
<div className="max-h-[min(46vh,19rem)] overflow-auto py-2">
{counterEntries.map((counter) => (
<div
key={counter.text}
className="wooden-fish-runtime__counter-row flex items-center justify-between gap-3 px-3.5 py-2 text-sm font-black"
>
<span className="min-w-0 truncate">{counter.text}</span>
<span className="shrink-0 tabular-nums">
{counter.count}
</span>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
<div
aria-hidden="true"
className="h-10 w-10 shrink-0 sm:h-11 sm:w-11"
/>
</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 text-[1.75rem] font-black text-white drop-shadow-[0_3px_8px_rgba(38,20,8,0.52)]"
style={{
left: `${item.x}%`,
top: `${item.y}%`,
}}
onAnimationEnd={() => {
setFloatingTexts((current) =>
current.filter((floating) => floating.id !== item.id),
);
}}
>
{item.text}
</div>
))}
</main>
{error ? (
<PlatformRuntimeStatusToast
data-wooden-fish-functional="true"
tone="error"
surface="solid"
size="md"
shape="rounded"
className="absolute bottom-20 left-3 right-3 z-40 shadow-lg"
>
{error}
</PlatformRuntimeStatusToast>
) : null}
<style>{`
.wooden-fish-runtime {
touch-action: manipulation;
user-select: none;
}
.wooden-fish-runtime__counter-card {
position: relative;
min-width: min(14rem, calc(100vw - 7.75rem));
min-height: 2.9rem;
border: 2px solid rgba(255, 222, 189, 0.6);
border-radius: 0.62rem 1.35rem 1.35rem 0.62rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.16), transparent 44%),
linear-gradient(135deg, #7d5129 0%, #68401f 58%, #543014 100%);
color: #fff8ea;
box-shadow: 0 12px 28px rgba(55, 28, 13, 0.2);
}
.wooden-fish-runtime__counter-card::before {
position: absolute;
inset: 0.2rem 0.58rem auto 3.05rem;
height: 1px;
content: '';
border-radius: 9999px;
background: rgba(255, 232, 199, 0.36);
}
.wooden-fish-runtime__counter-logo {
position: relative;
z-index: 1;
display: block;
width: 2.95rem;
height: 2.95rem;
flex: 0 0 auto;
margin: -0.62rem 0 -0.62rem -1.18rem;
overflow: visible;
}
.wooden-fish-runtime__counter-logo__image {
position: absolute;
left: -1.1rem;
top: -1.24rem;
display: block;
width: 5.3rem;
height: 5.3rem;
max-width: none;
object-fit: contain;
user-select: none;
pointer-events: none;
}
.wooden-fish-runtime__counter-panel {
border: 1.5px solid rgba(255, 222, 189, 0.56);
background: linear-gradient(180deg, rgba(92, 54, 26, 0.98), rgba(67, 36, 14, 0.98));
color: #fff8ea;
}
.wooden-fish-runtime__counter-row + .wooden-fish-runtime__counter-row {
border-top: 1px solid rgba(255, 224, 191, 0.14);
}
.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;