cfc0c0eadf
合并 origin/master 并保留平台入口运行态与推荐链路语义 修正合并后基于 tab 语义变化的前端断言
538 lines
17 KiB
TypeScript
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;
|