refactor: 重构中奖和推送大奖事件,中奖过度动画,和开奖动画

This commit is contained in:
JiaJun
2026-05-27 14:57:08 +08:00
parent 046f250ce3
commit 2b2b86a73d
48 changed files with 2471 additions and 422 deletions

View File

@@ -1,22 +1,20 @@
import { TriangleAlert } from 'lucide-react'
import { motion } from 'motion/react'
import { motion, useReducedMotion } from 'motion/react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import animalBorderImage from '@/assets/game/animal-border.webp'
import enStopImage from '@/assets/game/en-stop.webp'
import hostingBg from '@/assets/game/hosting-bg.webp'
import hostingBtn from '@/assets/game/hosting-btn.webp'
import zhStopImage from '@/assets/game/zh-stop.webp'
import diamondIcon from '@/assets/system/diamond.webp'
import { LottiePlayer } from '@/components/lottie-player'
import refreshIcon from '@/assets/system/refresh.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image'
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'
const revealBorderPath = new URL(
'../../../../assets/lottie/test.json',
import.meta.url,
).href
import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
@@ -35,6 +33,11 @@ const animalImageList = Object.entries(animalModules)
.filter((item) => item.id > 0)
.sort((left, right) => left.id - right.id)
const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 4_000
const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000
const SETTLEMENT_REVEAL_MIN_STEP_MS = 90
const SETTLEMENT_REVEAL_MAX_STEP_MS = 480
function getRandomAnimalId(ids: number[], currentId: number | null) {
if (ids.length === 0) {
return null
@@ -53,6 +56,17 @@ function getRandomAnimalId(ids: number[], currentId: number | null) {
return nextId
}
function getSettlementRevealStepDelay(progress: number) {
const clampedProgress = Math.min(Math.max(progress, 0), 1)
const easedProgress = clampedProgress ** 1.65
return (
SETTLEMENT_REVEAL_MIN_STEP_MS +
(SETTLEMENT_REVEAL_MAX_STEP_MS - SETTLEMENT_REVEAL_MIN_STEP_MS) *
easedProgress
)
}
interface DesktopAnimalProps {
className?: string
itemClassName?: string
@@ -67,6 +81,7 @@ export function DesktopAnimal({
onSelect,
}: DesktopAnimalProps) {
const { i18n, t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const animalIds = useMemo(() => animalImageList.map((item) => item.id), [])
const containerRef = useRef<HTMLElement | null>(null)
const cellRefs = useRef(new Map<number, HTMLButtonElement>())
@@ -77,6 +92,7 @@ export function DesktopAnimal({
top: number
width: number
} | null>(null)
const [isRevealHoldingResult, setIsRevealHoldingResult] = useState(false)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
@@ -86,6 +102,11 @@ export function DesktopAnimal({
const lastBetPeriodNo = useAuthStore(
(state) => state.currentUser?.lastBetPeriodNo,
)
const completedAutoHostingRounds = useGameAutoHostingStore(
(state) => state.completedRounds,
)
const hostingFlag = useGameAutoHostingStore((state) => state.isHosting)
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
const finishRevealAnimation = useGameRoundStore(
(state) => state.finishRevealAnimation,
)
@@ -99,8 +120,10 @@ export function DesktopAnimal({
selectionByCell,
showStandbyState,
} = useAnimalVm(animalIds, onSelect)
const isRevealRunning =
revealPhase === 'spinning' || revealPhase === 'stopping'
revealPhase === 'spinning' ||
(revealPhase === 'stopping' && !isRevealHoldingResult)
const isRevealResult = revealPhase === 'result'
const hasSubmittedCurrentRound =
roundPhase === 'betting' && Boolean(roundId) && lastBetPeriodNo === roundId
@@ -115,15 +138,18 @@ export function DesktopAnimal({
useEffect(() => {
if (revealPhase === 'idle') {
setRevealCellId(null)
setIsRevealHoldingResult(false)
return
}
if (revealPhase === 'result') {
setRevealCellId(revealWinningCellId)
setIsRevealHoldingResult(false)
return
}
if (revealPhase === 'spinning') {
setIsRevealHoldingResult(false)
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const intervalId = window.setInterval(() => {
@@ -136,35 +162,40 @@ export function DesktopAnimal({
}
if (revealWinningCellId === null) {
setIsRevealHoldingResult(false)
return
}
let elapsedMs = 0
const timeoutIds: number[] = []
const startedAt = performance.now()
let timeoutId = 0
setIsRevealHoldingResult(false)
for (let index = 0; index < 14; index += 1) {
elapsedMs += 42 + index * 13
timeoutIds.push(
window.setTimeout(() => {
setRevealCellId((currentId) =>
getRandomAnimalId(animalIds, currentId),
)
}, elapsedMs),
)
const step = () => {
const elapsedMs = performance.now() - startedAt
if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) {
setRevealCellId(revealWinningCellId)
setIsRevealHoldingResult(true)
timeoutId = window.setTimeout(() => {
finishRevealAnimation()
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
return
}
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const progress = elapsedMs / SETTLEMENT_REVEAL_RANDOM_DURATION_MS
const nextDelayMs = getSettlementRevealStepDelay(progress)
const remainingMs = SETTLEMENT_REVEAL_RANDOM_DURATION_MS - elapsedMs
timeoutId = window.setTimeout(step, Math.min(nextDelayMs, remainingMs))
}
elapsedMs += 160
timeoutIds.push(
window.setTimeout(() => {
setRevealCellId(revealWinningCellId)
finishRevealAnimation()
}, elapsedMs),
)
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
timeoutId = window.setTimeout(step, SETTLEMENT_REVEAL_MIN_STEP_MS)
return () => {
for (const timeoutId of timeoutIds) {
window.clearTimeout(timeoutId)
}
window.clearTimeout(timeoutId)
}
}, [animalIds, finishRevealAnimation, revealPhase, revealWinningCellId])
@@ -214,7 +245,9 @@ export function DesktopAnimal({
const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta)
const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner = isRevealResult && revealWinningCellId === item.id
const isRevealWinner =
(isRevealResult || isRevealHoldingResult) &&
revealWinningCellId === item.id
const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null
@@ -394,18 +427,27 @@ export function DesktopAnimal({
aria-hidden="true"
className="pointer-events-none absolute z-40 transition-[height,transform,width] duration-75 ease-linear"
style={{
height: revealFrame.height + 16,
transform: `translate(${revealFrame.left - 8}px, ${revealFrame.top - 8}px)`,
width: revealFrame.width + 16,
height: revealFrame.height,
transform: `translate(${revealFrame.left}px, ${revealFrame.top}px)`,
width: revealFrame.width,
}}
>
<LottiePlayer
path={revealBorderPath}
renderer="svg"
loop
autoplay
speed={1.8}
className="h-full w-full scale-[1.18] [&>svg]:h-full [&>svg]:w-full"
<div
className="gold-reveal-glow rounded-[calc(var(--design-unit)*16)]"
style={
prefersReducedMotion
? {
animation: 'none',
opacity: 0.36,
transform: 'scale(1)',
}
: undefined
}
/>
<div className="gold-reveal-static-border rounded-[calc(var(--design-unit)*16)]" />
<div
className="gold-reveal-shell rounded-[calc(var(--design-unit)*16)]"
style={prefersReducedMotion ? { animation: 'none' } : undefined}
/>
</div>
) : null}
@@ -426,6 +468,42 @@ export function DesktopAnimal({
</div>
) : null}
{hostingFlag ? (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]">
<SmartBackground
src={hostingBg}
className="h-design-350 w-design-930 flex flex-col items-center justify-center"
>
<div className={'flex flex-col gap-design-40 items-center'}>
<div className={'flex items-center gap-design-20'}>
<SmartImage
src={refreshIcon}
alt="refreshIcon"
priority
showSkeleton={false}
className="h-design-40 w-design-40"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
/>
<div className={'text-design-20 text-[#ffffff] font-bold'}>
{t('game.autoSpin.runningRounds', {
count: completedAutoHostingRounds,
})}
</div>
</div>
<SmartBackground
as="button"
type="button"
onClick={stopHosting}
src={hostingBtn}
className="h-design-80 w-design-170 flex cursor-pointer flex-col items-center justify-center transition-transform hover:-translate-y-[1px] active:translate-y-0"
>
{t('game.actions.stopAuto')}
</SmartBackground>
</div>
</SmartBackground>
</div>
) : null}
{showStandbyState ? (
<button
type="button"

View File

@@ -468,7 +468,7 @@ export function DesktopControl() {
: undefined
}
className={cn(
'relative z-10 flex h-full w-design-260 shrink-0 items-center justify-center bg-center bg-no-repeat text-design-32 font-bold',
'relative z-10 flex h-full w-design-260 shrink-0 items-center justify-center bg-center bg-no-repeat text-design-24 font-bold',
isConfirmClickable ? 'cursor-pointer' : 'cursor-not-allowed',
)}
>

View File

@@ -3,6 +3,8 @@ import {
Mail,
Maximize,
Minimize,
UserKey,
UserRoundPlus,
Volume2,
VolumeX,
} from 'lucide-react'
@@ -223,7 +225,7 @@ export function DesktopHeader() {
}
onClick={onOpenLogin}
>
<CircleAlert color={'#57B8BF'} size={16} />
<UserKey color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.login')}</div>
</button>
<button
@@ -233,7 +235,7 @@ export function DesktopHeader() {
}
onClick={onOpenRegister}
>
<CircleAlert color={'#57B8BF'} size={16} />
<UserRoundPlus color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.register')}</div>
</button>
</div>

View File

@@ -0,0 +1,244 @@
import { useEffect, useMemo, useState } from 'react'
import winLogo from '@/assets/game/win.webp'
import winBg from '@/assets/game/win-bg.webp'
import { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx'
import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
import { cn } from '@/lib/utils.ts'
import { useGameRoundStore } from '@/store'
const smallRewardPath = new URL(
'../../../../assets/lottie/pc-small-reward.json',
import.meta.url,
).href
const bigRewardPath = new URL(
'../../../../assets/lottie/pc-big-reward.json',
import.meta.url,
).href
const REWARD_OVERLAY_FADE_OUT_MS = 300
const REWARD_CHILDREN_FADE_IN_MS = 2_000
const REWARD_CHILDREN_VISIBLE_MS = 1_000
type RewardChildrenStage = 'hidden' | 'visible' | 'exiting'
function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3
}
function getAmountMeta(amount: string | null) {
if (!amount) {
return null
}
const normalizedAmount = amount.replace(/,/g, '')
const numericAmount = Number(normalizedAmount)
if (!Number.isFinite(numericAmount)) {
return null
}
const fractionDigits = normalizedAmount.includes('.')
? (normalizedAmount.split('.')[1]?.length ?? 0)
: 0
return {
fractionDigits,
numericAmount,
}
}
function formatRewardAmount(value: number, fractionDigits: number) {
return value.toLocaleString('en-US', {
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
})
}
function DesktopRewardOverlay() {
const rewardType = useGameRoundStore(
(state) => state.revealAnimation.rewardType,
)
const rewardAmount = useGameRoundStore(
(state) => state.revealAnimation.rewardAmount,
)
const revealKey = useGameRoundStore(
(state) => state.revealAnimation.revealKey,
)
const roundId = useGameRoundStore((state) => state.revealAnimation.roundId)
const clearRewardAnimation = useGameRoundStore(
(state) => state.clearRewardAnimation,
)
const [isFadingOut, setIsFadingOut] = useState(false)
const [childrenStage, setChildrenStage] =
useState<RewardChildrenStage>('hidden')
const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
const rewardAmountMeta = useMemo(
() => getAmountMeta(rewardAmount),
[rewardAmount],
)
const source = useMemo<FullscreenLottieSource | null>(() => {
if (rewardType === 'small') {
return {
id: 'pc-small-reward',
path: smallRewardPath,
loop: false,
autoplay: true,
}
}
if (rewardType === 'big') {
return {
id: 'pc-big-reward',
path: bigRewardPath,
loop: false,
autoplay: true,
}
}
return null
}, [rewardType])
useEffect(() => {
if (rewardType === 'none') {
return
}
setIsFadingOut(false)
const fadeTimerId = window.setTimeout(() => {
setIsFadingOut(true)
}, REWARD_OVERLAY_DURATION_MS)
const clearTimerId = window.setTimeout(() => {
clearRewardAnimation()
}, REWARD_OVERLAY_DURATION_MS + REWARD_OVERLAY_FADE_OUT_MS)
return () => {
window.clearTimeout(fadeTimerId)
window.clearTimeout(clearTimerId)
}
}, [clearRewardAnimation, rewardType])
const shouldRenderOverlay = rewardType !== 'none'
const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`
const childTimelineKey = shouldRenderOverlay ? overlayAnimationKey : 'closed'
useEffect(() => {
if (childTimelineKey === 'closed') {
setChildrenStage('hidden')
setDisplayRewardAmount('0')
return
}
setChildrenStage('hidden')
setDisplayRewardAmount(
rewardAmountMeta
? formatRewardAmount(0, rewardAmountMeta.fractionDigits)
: (rewardAmount ?? '0'),
)
const enterFrameId = window.requestAnimationFrame(() => {
setChildrenStage('visible')
})
const exitTimerId = window.setTimeout(() => {
setChildrenStage('exiting')
}, REWARD_CHILDREN_FADE_IN_MS + REWARD_CHILDREN_VISIBLE_MS)
return () => {
window.cancelAnimationFrame(enterFrameId)
window.clearTimeout(exitTimerId)
}
}, [childTimelineKey, rewardAmount, rewardAmountMeta])
useEffect(() => {
if (childTimelineKey === 'closed') {
return
}
if (!rewardAmountMeta) {
setDisplayRewardAmount(rewardAmount ?? '0')
return
}
let animationFrameId = 0
const startedAt = performance.now()
const syncRewardAmount = (now: number) => {
const progress = Math.min(
(now - startedAt) / REWARD_CHILDREN_FADE_IN_MS,
1,
)
setDisplayRewardAmount(
progress >= 1
? formatRewardAmount(
rewardAmountMeta.numericAmount,
rewardAmountMeta.fractionDigits,
)
: formatRewardAmount(
rewardAmountMeta.numericAmount * easeOutCubic(progress),
rewardAmountMeta.fractionDigits,
),
)
if (progress < 1) {
animationFrameId = window.requestAnimationFrame(syncRewardAmount)
}
}
animationFrameId = window.requestAnimationFrame(syncRewardAmount)
return () => {
window.cancelAnimationFrame(animationFrameId)
}
}, [childTimelineKey, rewardAmount, rewardAmountMeta])
return (
<FullscreenLottieOverlay
open={shouldRenderOverlay}
source={source}
animationKey={overlayAnimationKey}
zIndex={120}
loop={false}
autoplay
lockBodyScroll={!isFadingOut}
backdropClassName={cn(
'bg-black/70 transition-opacity duration-300',
isFadingOut && 'pointer-events-none opacity-0',
)}
viewportClassName="px-0 py-0"
>
<div
className={cn(
'absolute inset-0 flex items-center justify-center pb-design-220 transition-[opacity,transform,filter] ease-out',
childrenStage === 'visible' || childrenStage === 'exiting'
? 'duration-[2000ms]'
: 'duration-0',
childrenStage === 'visible' &&
'translate-y-0 scale-100 opacity-75 blur-none',
childrenStage === 'hidden' &&
'translate-y-[calc(var(--design-unit)*18)] scale-[0.96] opacity-0 blur-[calc(var(--design-unit)*2)]',
childrenStage === 'exiting' &&
'translate-y-[calc(var(--design-unit)*-14)] scale-[0.97] opacity-0 blur-[calc(var(--design-unit)*1.5)]',
)}
>
<SmartBackground
className="flex h-design-175 w-design-900 items-center justify-center gap-design-24 pb-design-50"
src={winBg}
size="contain"
>
<SmartImage
className="h-design-50 w-design-225 drop-shadow-[0_0_calc(var(--design-unit)*10)_rgba(255,218,122,0.72)]"
alt="win"
src={winLogo}
/>
<div className="h-design-50 min-w-design-150 animate-bounce text-center font-sans text-design-56 leading-[calc(var(--design-unit)*50)] font-black tabular-nums text-[#FFE89A] [animation-duration:900ms] [-webkit-text-stroke:calc(var(--design-unit)*1)_#8A3A08] [text-shadow:0_0_calc(var(--design-unit)*6)_rgba(255,236,154,0.95),0_calc(var(--design-unit)*3)_0_#7A2F05,0_0_calc(var(--design-unit)*18)_rgba(255,151,15,0.72)]">
{displayRewardAmount}
</div>
</SmartBackground>
</div>
</FullscreenLottieOverlay>
)
}
export default DesktopRewardOverlay

View File

@@ -6,7 +6,6 @@ import fire from '@/assets/system/fire.webp'
import lock from '@/assets/system/lock.webp'
import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp'
import streakBg from '@/assets/system/streak.webp'
import { LottiePlayer } from '@/components/lottie-player.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'

View File

@@ -1,18 +1,53 @@
import { useTranslation } from 'react-i18next'
import broadcast from '@/assets/system/broadcast.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { useGameSessionStore } from '@/store/game'
export function DesktopTitle() {
const { t } = useTranslation()
const jackpotBroadcasts = useGameSessionStore(
(state) => state.jackpotBroadcasts,
)
const titles =
jackpotBroadcasts.length > 0
? jackpotBroadcasts.map((broadcast) => ({
id: broadcast.id,
message: broadcast.message,
}))
: [{ id: 'empty', message: '' }]
const marqueeTitles =
jackpotBroadcasts.length > 0
? [
...titles.map((title) => ({ ...title, id: `${title.id}:first` })),
...titles.map((title) => ({ ...title, id: `${title.id}:second` })),
]
: titles
return (
<section className="common-neon-inset text-design-16 w-full flex h-design-65 items-center gap-design-10 !px-design-20 ">
<section className="common-neon-inset text-design-16 w-full flex h-design-65 items-center gap-design-10 !px-design-20 overflow-hidden">
<SmartImage
className={'w-design-24 h-design-24'}
alt={'broadcast'}
src={broadcast}
/>
<div className={'!text-[#FF970F]'}>
{t('gameDesktop.title.announcement')}
<div className="shrink-0 !text-[#FF970F]">
{t('gameDesktop.title.announcement')}:
</div>
<div className="relative h-design-28 min-w-0 flex-1 overflow-hidden">
<div
className={
jackpotBroadcasts.length > 0 ? 'desktop-title-vertical-marquee' : ''
}
>
{marqueeTitles.map((title) => (
<div
className="flex h-design-28 items-center whitespace-nowrap !text-[#FF970F]"
key={title.id}
>
{title.message}
</div>
))}
</div>
</div>
</section>
)

View File

@@ -654,7 +654,7 @@ function DesktopWithdraw() {
>
{withdrawSubmitMutation.isPending
? t('commonUi.action.submitting')
: `${t('gameDesktop.withdraw.confirm')} ${t('gameDesktop.withdraw.withdrawal')}`}
: `${t('gameDesktop.withdraw.confirm')}${t('gameDesktop.withdraw.withdrawal')}`}
</SmartBackground>
</div>
</div>

View File

@@ -162,8 +162,8 @@ export function EntryNoticeGateModal() {
)}
</div>
<div className="flex shrink-0 items-center justify-center gap-design-28">
<label className="inline-flex cursor-pointer items-center gap-design-12 text-design-20 text-[#C4F2F7]">
<div className="flex shrink-0 flex-col items-center justify-center gap-design-20">
<label className="inline-flex cursor-pointer items-center justify-center gap-design-12 text-design-20 text-[#C4F2F7]">
<input
type="checkbox"
checked={hasAgreed}