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

@@ -28,6 +28,7 @@ import type { BetSelection, RewardAnimationType } from '@/type'
const REWARD_OVERLAY_FADE_OUT_MS = 300
const REWARD_CHILDREN_FADE_IN_MS = 2_000
const REWARD_CHILDREN_VISIBLE_MS = 1_000
const SETTLEMENT_OVERLAY_FADE_OUT_MS = 300
type RewardChildrenStage = 'hidden' | 'visible' | 'exiting'
type StopBetItem = {
@@ -175,6 +176,79 @@ function StopBetSummary({
)
}
function NoRewardSettlement({
betItems,
message,
selectedLabel,
resultLabel,
resultNumber,
}: {
betItems: StopBetItem[]
message: string
selectedLabel: string
resultLabel: string
resultNumber: number
}) {
const winningImageUrl = FLOWER_IMAGE_BY_ID[resultNumber]?.animalUrl ?? ''
return (
<div className="relative z-10 flex w-design-720 max-w-[84%] flex-col items-center justify-center gap-design-18 rounded-[calc(var(--design-unit)*22)] border border-[rgba(124,232,255,0.34)] bg-[linear-gradient(180deg,rgba(7,28,40,0.96),rgba(3,12,20,0.98))] px-design-28 py-design-24 text-center shadow-[0_0_calc(var(--design-unit)*28)_rgba(70,245,255,0.28),0_0_calc(var(--design-unit)*64)_rgba(20,112,210,0.18),inset_0_0_calc(var(--design-unit)*24)_rgba(124,232,255,0.12)]">
<div className="text-design-34 font-black tracking-[0.14em] text-[#D8FBFF] [text-shadow:0_0_calc(var(--design-unit)*16)_rgba(84,245,255,0.42)]">
{message}
</div>
<div className="flex w-full items-stretch justify-center gap-design-14">
<div className="flex w-design-180 shrink-0 flex-col items-center gap-design-8 rounded-[calc(var(--design-unit)*14)] border border-[rgba(120,255,127,0.3)] bg-[linear-gradient(180deg,rgba(5,42,36,0.82),rgba(3,16,20,0.9))] px-design-12 py-design-10">
<div className="text-design-18 font-bold text-[rgba(216,251,255,0.78)]">
{resultLabel}
</div>
<div className="relative h-design-76 w-design-76 overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[rgba(120,255,127,0.36)] bg-[rgba(1,8,13,0.84)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(120,255,127,0.2)]">
{winningImageUrl ? (
<img
src={winningImageUrl}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="rounded-full border border-[rgba(120,255,127,0.38)] bg-[rgba(4,31,31,0.78)] px-design-16 py-design-4 text-design-26 font-black leading-none text-[#78FF7F] [text-shadow:0_0_calc(var(--design-unit)*12)_rgba(120,255,127,0.52)]">
{String(resultNumber).padStart(2, '0')}
</div>
</div>
<div className="flex w-design-430 min-w-0 flex-col items-center gap-design-8 rounded-[calc(var(--design-unit)*14)] border border-[rgba(124,232,255,0.2)] bg-[rgba(3,13,20,0.38)] px-design-10 py-design-10">
<div className="text-design-18 font-bold text-[rgba(216,251,255,0.78)]">
{selectedLabel}
</div>
<div className="flex max-h-design-132 w-full flex-wrap items-center justify-center gap-design-8 overflow-hidden">
{betItems.map((item) => (
<div
key={item.cellId}
className="flex h-design-104 w-design-88 flex-col items-center justify-center gap-design-4 overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[rgba(124,232,255,0.22)] bg-[linear-gradient(180deg,rgba(8,34,42,0.9),rgba(3,13,20,0.96))] p-design-4"
>
<div className="relative h-design-76 w-design-76 overflow-hidden rounded-[calc(var(--design-unit)*10)] bg-[rgba(1,8,13,0.84)]">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="text-design-20 font-black leading-none text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(75,255,254,0.46)]">
{String(item.cellId).padStart(2, '0')}
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}
export function DesktopAnimalOverlay({
showStopOverlay,
}: DesktopAnimalOverlayProps) {
@@ -197,6 +271,9 @@ export function DesktopAnimalOverlay({
(state) => state.revealAnimation.rewardType,
)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
)
const rewardAmount = useGameRoundStore(
(state) => state.revealAnimation.rewardAmount,
)
@@ -212,6 +289,13 @@ export function DesktopAnimalOverlay({
useState<RewardChildrenStage>('hidden')
const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
const [hasRenderedReward, setHasRenderedReward] = useState(false)
const [activeNoRewardKey, setActiveNoRewardKey] = useState<string | null>(
null,
)
const [dismissedNoRewardKey, setDismissedNoRewardKey] = useState<
string | null
>(null)
const [isNoRewardFadingOut, setIsNoRewardFadingOut] = useState(false)
const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
? zhStopImage
: enStopImage
@@ -235,10 +319,30 @@ export function DesktopAnimalOverlay({
revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null
const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`
const childTimelineKey = shouldRenderReward ? overlayAnimationKey : 'closed'
const hasWinningSelection =
revealWinningCellId !== null &&
recentSuccessfulSelections.some(
(selection) => selection.cellId === revealWinningCellId,
)
const noRewardKey =
roundId && revealWinningCellId !== null
? `${roundId}:${revealWinningCellId}`
: null
const shouldRenderNoReward =
revealPhase === 'result' &&
rewardType === 'none' &&
noRewardKey !== null &&
lastBetPeriodNo === roundId &&
recentSuccessfulSelections.length > 0 &&
!hasWinningSelection &&
activeNoRewardKey === noRewardKey
useEffect(() => {
if (revealPhase !== 'result') {
setHasRenderedReward(false)
setActiveNoRewardKey(null)
setDismissedNoRewardKey(null)
setIsNoRewardFadingOut(false)
return
}
@@ -247,6 +351,54 @@ export function DesktopAnimalOverlay({
}
}, [revealPhase, shouldRenderReward])
useEffect(() => {
if (
revealPhase !== 'result' ||
rewardType !== 'none' ||
noRewardKey === null ||
lastBetPeriodNo !== roundId ||
recentSuccessfulSelections.length === 0 ||
hasWinningSelection ||
activeNoRewardKey === noRewardKey ||
dismissedNoRewardKey === noRewardKey
) {
return
}
setActiveNoRewardKey(noRewardKey)
setIsNoRewardFadingOut(false)
}, [
activeNoRewardKey,
dismissedNoRewardKey,
hasWinningSelection,
lastBetPeriodNo,
noRewardKey,
recentSuccessfulSelections.length,
revealPhase,
rewardType,
roundId,
])
useEffect(() => {
if (!activeNoRewardKey) {
return
}
const fadeTimerId = window.setTimeout(() => {
setIsNoRewardFadingOut(true)
}, REWARD_OVERLAY_DURATION_MS)
const clearTimerId = window.setTimeout(() => {
setDismissedNoRewardKey(activeNoRewardKey)
setActiveNoRewardKey(null)
setIsNoRewardFadingOut(false)
}, REWARD_OVERLAY_DURATION_MS + SETTLEMENT_OVERLAY_FADE_OUT_MS)
return () => {
window.clearTimeout(fadeTimerId)
window.clearTimeout(clearTimerId)
}
}, [activeNoRewardKey])
useEffect(() => {
if (!shouldRenderReward) {
return
@@ -400,6 +552,29 @@ export function DesktopAnimalOverlay({
)
}
if (shouldRenderNoReward && revealWinningCellId !== null) {
return (
<div
className={cn(
'absolute inset-0 z-40 flex items-center justify-center overflow-hidden bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px] transition-opacity duration-300',
isNoRewardFadingOut && 'pointer-events-none opacity-0',
)}
>
<div
aria-hidden="true"
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(49,208,255,0.12),rgba(3,7,15,0.92)_58%,rgba(2,4,10,0.98)_100%)]"
/>
<NoRewardSettlement
betItems={stopBetItems}
message={t('gameDesktop.animal.noReward')}
selectedLabel={t('gameDesktop.animal.selectedResult')}
resultLabel={t('gameDesktop.animal.winningResult')}
resultNumber={revealWinningCellId}
/>
</div>
)
}
if (hostingFlag) {
return (
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center gap-design-22 bg-[rgba(2,8,14,0.38)] px-design-24 backdrop-blur-[1px]">

View File

@@ -9,10 +9,10 @@ import lock from '@/assets/system/lock.webp'
import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp'
import { LottiePlayer } from '@/components/lottie-player.tsx'
import { MessageBroadcast } from '@/components/message-broadcast.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
import { MessageBroadcast } from '@/features/game/components/desktop/desktop-title.tsx'
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
import { cn } from '@/lib/utils.ts'

View File

@@ -1,150 +0,0 @@
import AutoScroll from 'embla-carousel-auto-scroll'
import useEmblaCarousel from 'embla-carousel-react'
import { useEffect, useMemo } from 'react'
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'
const winAmountFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 6,
})
function getDesktopTitleCarouselCycleCount(titleCount: number) {
if (titleCount >= 8) {
return 2
}
if (titleCount >= 5) {
return 3
}
if (titleCount >= 3) {
return 5
}
return 8
}
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 jackpotBroadcasts = useGameSessionStore(
(state) => state.jackpotBroadcasts,
)
const hasBroadcasts = jackpotBroadcasts.length > 0
const titles = useMemo(
() =>
hasBroadcasts
? jackpotBroadcasts.map((broadcast) => ({
id: broadcast.id,
message: `Player ${broadcast.nickname} won ${formatWinAmount(
broadcast.totalWin,
)}`,
}))
: [{ id: 'empty', message: '' }],
[hasBroadcasts, jackpotBroadcasts],
)
const carouselTitles = useMemo(
() =>
hasBroadcasts
? Array.from(
{ length: getDesktopTitleCarouselCycleCount(titles.length) },
(_, cycleIndex) =>
titles.map((title) => ({
...title,
cycleIndex,
id: `${title.id}:cycle-${cycleIndex}`,
})),
).flat()
: titles.map((title) => ({ ...title, cycleIndex: 0 })),
[hasBroadcasts, titles],
)
const carouselTitleCount = carouselTitles.length
const autoScrollPlugin = useMemo(
() =>
AutoScroll({
playOnInit: hasBroadcasts,
speed: 0.7,
startDelay: 0,
stopOnFocusIn: false,
stopOnInteraction: false,
stopOnMouseEnter: false,
}),
[hasBroadcasts],
)
const [emblaRef, emblaApi] = useEmblaCarousel(
{
align: 'start',
dragFree: true,
loop: hasBroadcasts,
watchDrag: false,
},
[autoScrollPlugin],
)
useEffect(() => {
if (!emblaApi) {
return
}
if (carouselTitleCount === 0) {
return
}
emblaApi.reInit()
const autoScroll = emblaApi.plugins().autoScroll
if (!hasBroadcasts) {
autoScroll?.stop()
return
}
autoScroll?.reset()
autoScroll?.play()
}, [emblaApi, hasBroadcasts, carouselTitleCount])
return (
<section
aria-label="Jackpot broadcast"
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={'broadcast'}
src={broadcast}
/>
<div className="relative h-design-16 min-w-0 flex-1 overflow-hidden md:h-design-28">
<div className="h-full overflow-hidden" ref={emblaRef}>
<div className="flex h-full items-center gap-design-36 md:gap-design-80">
{carouselTitles.map((title) => (
<div
aria-hidden={title.cycleIndex > 0}
className="flex h-design-16 min-w-max shrink-0 items-center whitespace-nowrap !text-[#FF970F] md:h-design-28"
key={title.id}
>
{title.message}
</div>
))}
</div>
</div>
</div>
</section>
)
}
export function DesktopTitle(props: MessageBroadcastProps) {
return <MessageBroadcast {...props} />
}

View File

@@ -28,6 +28,7 @@ import type { BetSelection, RewardAnimationType } from '@/type'
const REWARD_OVERLAY_FADE_OUT_MS = 300
const REWARD_CHILDREN_FADE_IN_MS = 2_000
const REWARD_CHILDREN_VISIBLE_MS = 1_000
const SETTLEMENT_OVERLAY_FADE_OUT_MS = 300
type RewardChildrenStage = 'hidden' | 'visible' | 'exiting'
type StopBetItem = {
@@ -173,6 +174,79 @@ function StopBetSummary({
)
}
function NoRewardSettlement({
betItems,
message,
selectedLabel,
resultLabel,
resultNumber,
}: {
betItems: StopBetItem[]
message: string
selectedLabel: string
resultLabel: string
resultNumber: number
}) {
const winningImageUrl = FLOWER_IMAGE_BY_ID[resultNumber]?.animalUrl ?? ''
return (
<div className="relative z-10 flex w-design-336 max-w-[96%] flex-col items-center justify-center gap-design-9 rounded-[calc(var(--design-unit)*13)] border border-[rgba(124,232,255,0.34)] bg-[linear-gradient(180deg,rgba(7,28,40,0.96),rgba(3,12,20,0.98))] px-design-8 py-design-10 text-center shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.28),0_0_calc(var(--design-unit)*34)_rgba(20,112,210,0.18),inset_0_0_calc(var(--design-unit)*16)_rgba(124,232,255,0.12)]">
<div className="text-design-20 font-black tracking-[0.12em] text-[#D8FBFF] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(84,245,255,0.42)]">
{message}
</div>
<div className="flex w-full items-stretch justify-center gap-design-6">
<div className="flex w-design-92 shrink-0 flex-col items-center gap-design-5 rounded-[calc(var(--design-unit)*9)] border border-[rgba(120,255,127,0.3)] bg-[linear-gradient(180deg,rgba(5,42,36,0.82),rgba(3,16,20,0.9))] px-design-5 py-design-6">
<div className="text-design-10 font-bold text-[rgba(216,251,255,0.72)]">
{resultLabel}
</div>
<div className="relative h-design-46 w-design-46 overflow-hidden rounded-[calc(var(--design-unit)*6)] border border-[rgba(120,255,127,0.32)] bg-[rgba(1,8,13,0.84)]">
{winningImageUrl ? (
<img
src={winningImageUrl}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="rounded-full border border-[rgba(120,255,127,0.36)] bg-[rgba(4,31,31,0.72)] px-design-8 py-design-2 text-design-15 font-black leading-none text-[#78FF7F] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(120,255,127,0.52)]">
{String(resultNumber).padStart(2, '0')}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col items-center gap-design-5 rounded-[calc(var(--design-unit)*9)] border border-[rgba(124,232,255,0.2)] bg-[rgba(3,13,20,0.38)] px-design-4 py-design-5">
<div className="text-design-10 font-bold text-[rgba(216,251,255,0.72)]">
{selectedLabel}
</div>
<div className="flex max-h-design-92 w-full flex-wrap items-center justify-center gap-design-4 overflow-hidden">
{betItems.map((item) => (
<div
key={item.cellId}
className="flex h-design-52 w-design-42 flex-col items-center justify-center gap-design-2 overflow-hidden rounded-[calc(var(--design-unit)*6)] border border-[rgba(124,232,255,0.2)] bg-[linear-gradient(180deg,rgba(8,34,42,0.88),rgba(3,13,20,0.94))] p-design-2"
>
<div className="relative h-design-30 w-design-30 overflow-hidden rounded-[calc(var(--design-unit)*4)] bg-[rgba(1,8,13,0.84)]">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="text-design-11 font-black leading-none text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*6)_rgba(75,255,254,0.46)]">
{String(item.cellId).padStart(2, '0')}
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}
export function MobileAnimalOverlay({
showStopOverlay,
}: MobileAnimalOverlayProps) {
@@ -195,6 +269,9 @@ export function MobileAnimalOverlay({
(state) => state.revealAnimation.rewardType,
)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
)
const rewardAmount = useGameRoundStore(
(state) => state.revealAnimation.rewardAmount,
)
@@ -210,6 +287,13 @@ export function MobileAnimalOverlay({
useState<RewardChildrenStage>('hidden')
const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
const [hasRenderedReward, setHasRenderedReward] = useState(false)
const [activeNoRewardKey, setActiveNoRewardKey] = useState<string | null>(
null,
)
const [dismissedNoRewardKey, setDismissedNoRewardKey] = useState<
string | null
>(null)
const [isNoRewardFadingOut, setIsNoRewardFadingOut] = useState(false)
const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
? zhStopImage
: enStopImage
@@ -233,10 +317,30 @@ export function MobileAnimalOverlay({
revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null
const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`
const childTimelineKey = shouldRenderReward ? overlayAnimationKey : 'closed'
const hasWinningSelection =
revealWinningCellId !== null &&
recentSuccessfulSelections.some(
(selection) => selection.cellId === revealWinningCellId,
)
const noRewardKey =
roundId && revealWinningCellId !== null
? `${roundId}:${revealWinningCellId}`
: null
const shouldRenderNoReward =
revealPhase === 'result' &&
rewardType === 'none' &&
noRewardKey !== null &&
lastBetPeriodNo === roundId &&
recentSuccessfulSelections.length > 0 &&
!hasWinningSelection &&
activeNoRewardKey === noRewardKey
useEffect(() => {
if (revealPhase !== 'result') {
setHasRenderedReward(false)
setActiveNoRewardKey(null)
setDismissedNoRewardKey(null)
setIsNoRewardFadingOut(false)
return
}
@@ -245,6 +349,54 @@ export function MobileAnimalOverlay({
}
}, [revealPhase, shouldRenderReward])
useEffect(() => {
if (
revealPhase !== 'result' ||
rewardType !== 'none' ||
noRewardKey === null ||
lastBetPeriodNo !== roundId ||
recentSuccessfulSelections.length === 0 ||
hasWinningSelection ||
activeNoRewardKey === noRewardKey ||
dismissedNoRewardKey === noRewardKey
) {
return
}
setActiveNoRewardKey(noRewardKey)
setIsNoRewardFadingOut(false)
}, [
activeNoRewardKey,
dismissedNoRewardKey,
hasWinningSelection,
lastBetPeriodNo,
noRewardKey,
recentSuccessfulSelections.length,
revealPhase,
rewardType,
roundId,
])
useEffect(() => {
if (!activeNoRewardKey) {
return
}
const fadeTimerId = window.setTimeout(() => {
setIsNoRewardFadingOut(true)
}, REWARD_OVERLAY_DURATION_MS)
const clearTimerId = window.setTimeout(() => {
setDismissedNoRewardKey(activeNoRewardKey)
setActiveNoRewardKey(null)
setIsNoRewardFadingOut(false)
}, REWARD_OVERLAY_DURATION_MS + SETTLEMENT_OVERLAY_FADE_OUT_MS)
return () => {
window.clearTimeout(fadeTimerId)
window.clearTimeout(clearTimerId)
}
}, [activeNoRewardKey])
useEffect(() => {
if (!shouldRenderReward) {
return
@@ -398,6 +550,29 @@ export function MobileAnimalOverlay({
)
}
if (shouldRenderNoReward && revealWinningCellId !== null) {
return (
<div
className={cn(
'absolute inset-0 z-40 flex items-center justify-center overflow-hidden bg-[rgba(2,8,14,0.66)] px-design-6 backdrop-blur-[2px] transition-opacity duration-300',
isNoRewardFadingOut && 'pointer-events-none opacity-0',
)}
>
<div
aria-hidden="true"
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(49,208,255,0.12),rgba(3,7,15,0.88)_62%,rgba(2,4,10,0.96)_100%)]"
/>
<NoRewardSettlement
betItems={stopBetItems}
message={t('gameDesktop.animal.noReward')}
selectedLabel={t('gameDesktop.animal.selectedResult')}
resultLabel={t('gameDesktop.animal.winningResult')}
resultNumber={revealWinningCellId}
/>
</div>
)
}
if (hostingFlag) {
return (
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center gap-design-10 bg-[rgba(2,8,14,0.38)] px-design-10 backdrop-blur-[1px]">