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"