feat(game): 更新马来西亚手机号验证和游戏界面优化

- 修改认证模块手机号验证规则适配马来西亚号码格式
- 添加新的投注限制提示文本支持多语言
- 重命名结算阶段标签为Drawing统一显示
- 新增桌面版动物游戏遮罩组件分离功能
- 添加回合开始投注提醒弹窗组件
- 优化开奖动画流程和视觉效果
- 添加奖励动画显示和投注汇总展示
- 新增多种投注限制和状态提示信息
This commit is contained in:
JiaJun
2026-05-30 17:22:57 +08:00
parent 54410aaac5
commit 87e8aca97d
21 changed files with 1008 additions and 492 deletions

View File

@@ -0,0 +1,208 @@
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useGameRoundStore } from '@/store/game'
const BETTING_START_ALERT_DURATION_MS = 2000
interface RoundBettingStartAlertProps {
className?: string
placement?: 'absolute' | 'fixed'
}
export function RoundBettingStartAlert({
className,
placement = 'absolute',
}: RoundBettingStartAlertProps) {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const roundId = useGameRoundStore((state) => state.round.id)
const roundPhase = useGameRoundStore((state) => state.round.phase)
const lastShownRoundIdRef = useRef<string | null>(null)
const [visibleRoundId, setVisibleRoundId] = useState<string | null>(null)
useEffect(() => {
if (roundPhase !== 'betting' || !roundId) {
setVisibleRoundId(null)
return
}
if (lastShownRoundIdRef.current === roundId) {
return
}
lastShownRoundIdRef.current = roundId
setVisibleRoundId(roundId)
const timerId = window.setTimeout(() => {
setVisibleRoundId((currentRoundId) =>
currentRoundId === roundId ? null : currentRoundId,
)
}, BETTING_START_ALERT_DURATION_MS)
return () => {
window.clearTimeout(timerId)
}
}, [roundId, roundPhase])
return (
<AnimatePresence>
{visibleRoundId ? (
<motion.div
aria-live="polite"
className={cn(
'pointer-events-none left-1/2 top-1/2 z-50 w-[min(calc(var(--design-unit)*560),88vw)] -translate-x-1/2 -translate-y-1/2',
placement === 'fixed' ? 'fixed' : 'absolute',
className,
)}
initial={
prefersReducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.82, y: 'calc(var(--design-unit)*18)' }
}
animate={
prefersReducedMotion
? { opacity: 1 }
: { opacity: 1, scale: 1, y: 0 }
}
exit={
prefersReducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.92, y: 'calc(var(--design-unit)*-12)' }
}
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
>
<motion.div
className="relative overflow-hidden rounded-[calc(var(--design-unit)*22)] border border-[rgba(126,255,248,0.76)] bg-[linear-gradient(180deg,rgba(7,33,43,0.96),rgba(4,16,24,0.98))] px-design-36 py-design-28 text-center shadow-[0_0_calc(var(--design-unit)*20)_rgba(70,245,255,0.4),0_0_calc(var(--design-unit)*52)_rgba(18,206,232,0.28),inset_0_0_calc(var(--design-unit)*22)_rgba(126,255,248,0.16)]"
animate={
prefersReducedMotion
? undefined
: {
boxShadow: [
'0 0 calc(var(--design-unit)*14) rgba(70,245,255,0.32), 0 0 calc(var(--design-unit)*34) rgba(18,206,232,0.2), inset 0 0 calc(var(--design-unit)*18) rgba(126,255,248,0.14)',
'0 0 calc(var(--design-unit)*24) rgba(87,250,255,0.54), 0 0 calc(var(--design-unit)*58) rgba(25,223,255,0.34), inset 0 0 calc(var(--design-unit)*24) rgba(146,255,251,0.22)',
'0 0 calc(var(--design-unit)*18) rgba(70,245,255,0.38), 0 0 calc(var(--design-unit)*46) rgba(18,206,232,0.26), inset 0 0 calc(var(--design-unit)*20) rgba(126,255,248,0.16)',
],
}
}
transition={
prefersReducedMotion
? undefined
: {
duration: 0.72,
ease: 'easeInOut',
}
}
>
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*22)] border border-[rgba(229,255,255,0.12)]" />
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-x-design-20 top-design-10 h-design-24 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.18),rgba(255,255,255,0))]"
animate={
prefersReducedMotion
? undefined
: { opacity: [0.54, 0.9, 0.58], x: ['-7%', '7%', '-2%'] }
}
transition={
prefersReducedMotion
? undefined
: { duration: 1.1, ease: 'easeInOut' }
}
/>
<div className="relative z-10 flex flex-col items-center gap-design-28">
<motion.div
className="text-design-22 font-semibold leading-tight tracking-[0.18em] text-[#78FF7F]"
animate={
prefersReducedMotion ? undefined : { opacity: [0.78, 1, 0.9] }
}
transition={
prefersReducedMotion
? undefined
: { duration: 0.72, ease: 'easeOut' }
}
>
{t('game.roundBettingStart.title', {
roundId: visibleRoundId,
})}
</motion.div>
<motion.div
className="relative flex items-center justify-center px-design-12 text-design-46 font-bold leading-tight tracking-[0.14em] text-[#F2FFFF] [text-shadow:0_0_calc(var(--design-unit)*16)_rgba(92,249,255,0.66)]"
animate={
prefersReducedMotion
? undefined
: {
opacity: [0.88, 1, 0.92],
scale: [0.98, 1.07, 0.99],
textShadow: [
'0 0 calc(var(--design-unit)*12) rgba(92,249,255,0.52), 0 0 calc(var(--design-unit)*22) rgba(120,255,127,0.22)',
'0 0 calc(var(--design-unit)*22) rgba(111,255,255,0.9), 0 0 calc(var(--design-unit)*42) rgba(120,255,127,0.44)',
'0 0 calc(var(--design-unit)*15) rgba(92,249,255,0.62), 0 0 calc(var(--design-unit)*28) rgba(120,255,127,0.28)',
],
}
}
transition={
prefersReducedMotion
? undefined
: {
duration: 1.05,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
}
>
<motion.span
aria-hidden="true"
className="absolute inset-x-0 top-1/2 h-design-30 -translate-y-1/2 rounded-full bg-[rgba(120,255,248,0.24)] blur-[calc(var(--design-unit)*10)]"
animate={
prefersReducedMotion
? undefined
: {
opacity: [0.28, 0.72, 0.34],
scaleX: [0.82, 1.12, 0.9],
}
}
transition={
prefersReducedMotion
? undefined
: {
duration: 1.05,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
}
/>
<span className="relative z-10">
{t('game.roundBettingStart.action')}
</span>
</motion.div>
<div className="mt-design-8 flex items-center gap-design-7">
{[0, 1, 2, 3, 4].map((index) => (
<motion.span
key={index}
className="h-design-5 w-design-22 rounded-full bg-[rgba(130,255,248,0.88)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(91,248,255,0.52)]"
animate={
prefersReducedMotion
? undefined
: { opacity: [0.36, 1, 0.42], y: [0, -2, 0] }
}
transition={
prefersReducedMotion
? undefined
: {
delay: index * 0.06,
duration: 1.1,
ease: 'easeInOut',
repeat: 1,
}
}
/>
))}
</div>
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
)
}