feat(game): 优化加载动画并添加无奖励结算显示

- 替换粒子动画为预加载背景图片,提升加载性能
- 添加桌面端无奖励结算界面显示玩家选择和开奖结果
- 实现消息广播组件的动画效果和状态管理
- 添加代码审查报告文档
- 更新GitNexus索引统计信息
- 调整加载界面布局和样式优化
This commit is contained in:
JiaJun
2026-06-08 17:59:44 +08:00
parent b6168301fe
commit 3b35c9817b
41 changed files with 681 additions and 340 deletions

View File

@@ -0,0 +1,167 @@
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import broadcast from '@/assets/system/broadcast.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { cn } from '@/lib/utils.ts'
import { useGameSessionStore } from '@/store/game'
import type { JackpotBroadcastItem } from '@/type'
const winAmountFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 6,
})
const MESSAGE_BROADCAST_ANIMATION_MS = 28_000
function formatWinAmount(value: string) {
const amount = Number(value)
return Number.isFinite(amount) ? winAmountFormatter.format(amount) : value
}
type MessageBroadcastProps = {
className?: string
}
export function MessageBroadcast({ className }: MessageBroadcastProps) {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const jackpotBroadcasts = useGameSessionStore(
(state) => state.jackpotBroadcasts,
)
const shownBroadcastIdsRef = useRef(new Set<string>())
const queuedBroadcastIdsRef = useRef(new Set<string>())
const [queue, setQueue] = useState<JackpotBroadcastItem[]>([])
const [activeBroadcast, setActiveBroadcast] =
useState<JackpotBroadcastItem | null>(null)
const activeBroadcastLabel = useMemo(() => {
if (!activeBroadcast) {
return ''
}
return t('game.jackpotBroadcast.ariaMessage', {
amount: formatWinAmount(activeBroadcast.totalWin),
nickname: activeBroadcast.nickname,
streak: activeBroadcast.currentStreak,
})
}, [activeBroadcast, t])
useEffect(() => {
const nextBroadcasts = jackpotBroadcasts.filter((item) => {
if (
shownBroadcastIdsRef.current.has(item.id) ||
queuedBroadcastIdsRef.current.has(item.id) ||
activeBroadcast?.id === item.id
) {
return false
}
return true
})
if (nextBroadcasts.length === 0) {
return
}
for (const item of nextBroadcasts) {
queuedBroadcastIdsRef.current.add(item.id)
}
setQueue((currentQueue) => [...currentQueue, ...nextBroadcasts])
}, [activeBroadcast?.id, jackpotBroadcasts])
useEffect(() => {
if (activeBroadcast || queue.length === 0) {
return
}
const [nextBroadcast, ...remainingQueue] = queue
queuedBroadcastIdsRef.current.delete(nextBroadcast.id)
shownBroadcastIdsRef.current.add(nextBroadcast.id)
setActiveBroadcast(nextBroadcast)
setQueue(remainingQueue)
}, [activeBroadcast, queue])
useEffect(() => {
if (!activeBroadcast) {
return
}
const timerId = window.setTimeout(() => {
setActiveBroadcast((currentBroadcast) =>
currentBroadcast?.id === activeBroadcast.id ? null : currentBroadcast,
)
}, MESSAGE_BROADCAST_ANIMATION_MS)
return () => {
window.clearTimeout(timerId)
}
}, [activeBroadcast])
return (
<section
aria-label={t('game.jackpotBroadcast.ariaLabel')}
className={cn(
'common-neon-inset flex w-full min-w-0 items-center overflow-hidden',
'h-design-24 gap-design-5 !rounded-[3px] !px-design-7 !py-0 text-design-8',
'md:h-design-65 md:gap-design-10 md:!rounded-[5px] md:!px-design-20 md:text-design-16',
className,
)}
>
<SmartImage
className="h-design-12 w-design-12 shrink-0 md:h-design-24 md:w-design-24"
alt={t('game.jackpotBroadcast.iconAlt')}
src={broadcast}
/>
<div className="relative h-design-16 min-w-0 flex-1 overflow-hidden md:h-design-28">
<AnimatePresence>
{activeBroadcast ? (
<motion.div
aria-label={activeBroadcastLabel}
className="absolute inset-0 flex min-w-max items-center whitespace-nowrap font-semibold"
key={activeBroadcast.id}
initial={
prefersReducedMotion
? { opacity: 0 }
: { opacity: 1, x: '108%' }
}
animate={
prefersReducedMotion
? { opacity: 1 }
: { opacity: 1, x: '-108%' }
}
exit={{ opacity: 0 }}
transition={{
duration: prefersReducedMotion
? 0.18
: MESSAGE_BROADCAST_ANIMATION_MS / 1000,
ease: prefersReducedMotion ? [0.16, 1, 0.3, 1] : 'linear',
}}
>
<span className="text-[#D5F7FF]">
{t('game.jackpotBroadcast.prefix')}
</span>
<span className="ml-design-4 text-[#4BFFFE] md:ml-design-8">
{activeBroadcast.nickname}
</span>
<span className="mx-design-4 text-[#D5F7FF] md:mx-design-8">
{t('game.jackpotBroadcast.separator')}
</span>
<span className="text-[#78FF7F]">
{t('game.jackpotBroadcast.streak', {
count: activeBroadcast.currentStreak,
})}
</span>
<span className="mx-design-4 text-[#D5F7FF] md:mx-design-8">
{t('game.jackpotBroadcast.winAction')}
</span>
<span className="text-[#FFB72B]">
{formatWinAmount(activeBroadcast.totalWin)}
</span>
</motion.div>
) : null}
</AnimatePresence>
</div>
</section>
)
}