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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,12 +1,12 @@
import { z } from 'zod'
const mobilePhonePattern = /^1[3-9]\d{9}$/
const malaysiaMobilePhonePattern = /^60\d{1,9}$/
const usernameSchema = z
.string()
.trim()
.min(1, 'auth.validation.username.required')
.regex(mobilePhonePattern, 'auth.validation.username.invalidPhone')
.regex(malaysiaMobilePhonePattern, 'auth.validation.username.invalidPhone')
const passwordSchema = z
.string()

View File

@@ -0,0 +1,500 @@
import { motion, useReducedMotion } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 winLogo from '@/assets/game/win.webp'
import winBg from '@/assets/game/win-bg.webp'
import zhStopImage from '@/assets/game/zh-stop.webp'
import bigRewardPath from '@/assets/lottie/pc-big-reward.json?url'
import smallRewardPath from '@/assets/lottie/pc-small-reward.json?url'
import diamondIcon from '@/assets/system/diamond.webp'
import refreshIcon from '@/assets/system/refresh.webp'
import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
import { LottiePlayer } from '@/components/lottie-player.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image'
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
import {
type BetSelection,
FLOWER_IMAGE_BY_ID,
groupSelectionsByCell,
} from '@/features/game/shared'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import {
type RewardAnimationType,
useGameAutoHostingStore,
useGameRoundStore,
} from '@/store/game'
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'
type StopBetItem = {
amount: number
cellId: number
imageUrl: string
}
interface DesktopAnimalOverlayProps {
showStopOverlay: boolean
}
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 getRewardSource(
rewardType: RewardAnimationType,
): 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
}
function formatStopBetAmount(amount: number) {
return amount.toLocaleString('en-US', {
maximumFractionDigits: 2,
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
})
}
function getStopBetItems(selections: BetSelection[]) {
const groupedSelections = groupSelectionsByCell(selections)
return Object.entries(groupedSelections)
.map(([cellId, meta]) => {
const normalizedCellId = Number(cellId)
return {
amount: meta.amount,
cellId: normalizedCellId,
imageUrl: FLOWER_IMAGE_BY_ID[normalizedCellId]?.animalUrl ?? '',
} satisfies StopBetItem
})
.sort((left, right) => left.cellId - right.cellId)
}
function StopBetSummary({
items,
noBetText,
}: {
items: StopBetItem[]
noBetText: string
}) {
if (items.length === 0) {
return (
<div className="game-chip-glow rounded-[calc(var(--design-unit)*18)] border border-[rgba(124,232,255,0.22)] bg-[linear-gradient(180deg,rgba(8,34,42,0.94),rgba(3,13,20,0.96))] px-design-42 py-design-14 text-design-28 font-black tracking-[0.14em] text-[#E8FFFF] [text-shadow:0_0_calc(var(--design-unit)*12)_rgba(70,245,255,0.34)]">
{noBetText}
</div>
)
}
return (
<div className="flex max-h-design-194 max-w-design-850 flex-wrap items-center justify-center gap-design-16 overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-[rgba(124,232,255,0.28)] bg-[linear-gradient(180deg,rgba(10,31,42,0.84),rgba(3,12,18,0.74))] px-design-16 py-design-14 shadow-[0_0_calc(var(--design-unit)*24)_rgba(34,214,255,0.2),0_calc(var(--design-unit)*12)_calc(var(--design-unit)*24)_rgba(0,0,0,0.34),inset_0_calc(var(--design-unit)*1)_0_rgba(255,255,255,0.08),inset_0_calc(var(--design-unit)*-1)_0_rgba(124,232,255,0.18)] ring-1 ring-inset ring-[rgba(3,12,18,0.7)] backdrop-blur-[1px]">
{items.map((item) => (
<div
key={item.cellId}
className="flex h-design-90 min-w-design-230 items-center gap-design-10 overflow-hidden rounded-[calc(var(--design-unit)*12)] border border-[rgba(124,232,255,0.2)] bg-[linear-gradient(90deg,rgba(7,42,38,0.94),rgba(5,23,34,0.96)_44%,rgba(18,25,25,0.94))] px-design-8 shadow-[0_0_calc(var(--design-unit)*16)_rgba(51,231,255,0.16),inset_0_calc(var(--design-unit)*1)_0_rgba(255,255,255,0.08)]"
>
<div className="relative h-design-72 w-design-72 shrink-0 overflow-hidden rounded-[calc(var(--design-unit)*10)] bg-[rgba(1,8,13,0.84)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(86,247,255,0.18)]">
{item.imageUrl ? (
<SmartImage
src={item.imageUrl}
alt=""
showSkeleton={false}
className="h-full w-full"
imgClassName="object-cover"
/>
) : null}
</div>
<div className="flex h-design-72 min-w-0 flex-1 flex-col items-stretch justify-center gap-design-7 leading-none">
<div className="flex h-design-28 w-full items-center justify-center rounded-full bg-[rgba(4,31,31,0.9)] text-design-24 font-black leading-none text-[#78FF7F] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(120,255,127,0.08)] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(120,255,127,0.52)]">
{String(item.cellId).padStart(2, '0')}
</div>
<div className="flex h-design-32 w-full min-w-0 items-center justify-center gap-design-4 rounded-full bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-8 shadow-[0_0_calc(var(--design-unit)*14)_rgba(70,245,255,0.16)]">
<SmartImage
src={diamondIcon}
alt=""
showSkeleton={false}
className="h-design-20 w-design-20 shrink-0 object-contain"
/>
<span className="min-w-0 truncate text-design-22 font-black leading-none tabular-nums text-[#FFE58A] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,222,130,0.46)]">
{formatStopBetAmount(item.amount)}
</span>
</div>
</div>
</div>
))}
</div>
)
}
export function DesktopAnimalOverlay({
showStopOverlay,
}: DesktopAnimalOverlayProps) {
const { i18n, t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const completedAutoHostingRounds = useGameAutoHostingStore(
(state) => state.completedRounds,
)
const currentRoundId = useGameRoundStore((state) => state.round.id)
const lastBetPeriodNo = useAuthStore(
(state) => state.currentUser?.lastBetPeriodNo,
)
const hostingFlag = useGameAutoHostingStore((state) => state.isHosting)
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
const selections = useGameRoundStore((state) => state.selections)
const recentSuccessfulSelections = useGameRoundStore(
(state) => state.recentSuccessfulSelections,
)
const rewardType = useGameRoundStore(
(state) => state.revealAnimation.rewardType,
)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
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 [isRewardFadingOut, setIsRewardFadingOut] = useState(false)
const [childrenStage, setChildrenStage] =
useState<RewardChildrenStage>('hidden')
const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
const [hasRenderedReward, setHasRenderedReward] = useState(false)
const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
? zhStopImage
: enStopImage
const stopBetItems = useMemo(
() =>
getStopBetItems(
selections.length > 0
? selections
: lastBetPeriodNo === currentRoundId
? recentSuccessfulSelections
: [],
),
[currentRoundId, lastBetPeriodNo, recentSuccessfulSelections, selections],
)
const rewardAmountMeta = useMemo(
() => getAmountMeta(rewardAmount),
[rewardAmount],
)
const rewardSource = useMemo(() => getRewardSource(rewardType), [rewardType])
const shouldRenderReward =
revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null
const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`
const childTimelineKey = shouldRenderReward ? overlayAnimationKey : 'closed'
useEffect(() => {
if (revealPhase !== 'result') {
setHasRenderedReward(false)
return
}
if (shouldRenderReward) {
setHasRenderedReward(true)
}
}, [revealPhase, shouldRenderReward])
useEffect(() => {
if (!shouldRenderReward) {
return
}
setIsRewardFadingOut(false)
const fadeTimerId = window.setTimeout(() => {
setIsRewardFadingOut(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, shouldRenderReward])
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])
if (shouldRenderReward && rewardSource) {
const playerKey = `${overlayAnimationKey}-${rewardSource.id}`
return (
<div
className={cn(
'absolute inset-0 z-50 flex items-center justify-center overflow-hidden bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px] transition-opacity duration-300',
isRewardFadingOut && '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%)]"
/>
<LottiePlayer
key={playerKey}
path={rewardSource.path}
renderer={rewardSource.renderer ?? 'svg'}
loop={rewardSource.loop ?? false}
autoplay={rewardSource.autoplay ?? true}
className="absolute inset-0 h-full w-full [&>svg]:h-full [&>svg]:w-full [&>svg]:object-contain [&_canvas]:h-full [&_canvas]:w-full"
/>
<div
className={cn(
'pointer-events-none absolute inset-0 z-10 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>
</div>
)
}
if (revealPhase === 'result' && hasRenderedReward) {
return (
<div
aria-hidden="true"
className="absolute inset-0 z-50 bg-[rgba(2,8,14,0.72)] backdrop-blur-[2px]"
/>
)
}
if (hostingFlag) {
return (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center gap-design-22 bg-[rgba(2,8,14,0.38)] px-design-24 backdrop-blur-[1px]">
{showStopOverlay ? (
<div className="relative h-design-170 w-design-520 max-w-[72%] overflow-visible">
<SmartImage
src={stopImageSrc}
alt="stop betting"
priority
showSkeleton={false}
className="h-full w-full overflow-visible"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
/>
</div>
) : null}
<SmartBackground
src={hostingBg}
size="100% 100%"
repeat="no-repeat"
position="center"
className="h-design-350 w-design-930 flex flex-col items-center justify-center"
>
<div className={'flex flex-col gap-design-44 items-center'}>
<div className={'flex items-center gap-design-20'}>
<motion.span
aria-hidden="true"
animate={prefersReducedMotion ? undefined : { rotate: 360 }}
transition={{
duration: 1.4,
ease: 'linear',
repeat: Number.POSITIVE_INFINITY,
}}
className="flex h-design-48 w-design-48 items-center justify-center"
>
<SmartImage
src={refreshIcon}
alt=""
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.36)]"
/>
</motion.span>
<div
className={
'text-design-22 text-[#ffffff] font-bold [text-shadow:0_0_calc(var(--design-unit)*12)_rgba(76,236,255,0.45)]'
}
>
{t('game.autoSpin.runningRounds', {
count: completedAutoHostingRounds,
})}
</div>
</div>
<SmartBackground
as="button"
type="button"
onClick={stopHosting}
src={hostingBtn}
size="100% 100%"
repeat="no-repeat"
position="center"
className="h-design-70 w-design-220 flex cursor-pointer items-center justify-center pb-design-4 text-design-24 font-bold text-[#EFFFFF] [text-shadow:0_1px_0_rgba(255,255,255,0.18),0_0_calc(var(--design-unit)*10)_rgba(46,220,255,0.5)] transition-transform hover:-translate-y-[1px] active:translate-y-0"
>
{t('game.actions.stopAuto')}
</SmartBackground>
</div>
</SmartBackground>
</div>
)
}
if (showStopOverlay) {
return (
<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]">
<div className="flex max-w-[88%] flex-col items-center justify-center gap-design-18">
<StopBetSummary
items={stopBetItems}
noBetText={t('gameDesktop.animal.noBet')}
/>
<SmartImage
src={stopImageSrc}
alt="stop betting"
priority
showSkeleton={false}
className="h-design-220 w-design-560 max-w-full overflow-visible"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
/>
</div>
</div>
)
}
return null
}

View File

@@ -3,21 +3,18 @@ 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 refreshIcon from '@/assets/system/refresh.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image'
import { DesktopAnimalOverlay } from '@/features/game/components/desktop/desktop-animal-overlay.tsx'
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
import { useGameRoundStore } from '@/store/game'
const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 4_000
const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 3_200
const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800
const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000
const SETTLEMENT_REVEAL_MIN_STEP_MS = 90
const SETTLEMENT_REVEAL_MAX_STEP_MS = 480
@@ -64,7 +61,7 @@ export function DesktopAnimal({
imageClassName,
onSelect,
}: DesktopAnimalProps) {
const { i18n, t } = useTranslation()
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const animalIds = useMemo(() => FLOWER_IMAGE_LIST.map((item) => item.id), [])
const containerRef = useRef<HTMLElement | null>(null)
@@ -77,20 +74,19 @@ export function DesktopAnimal({
width: number
} | null>(null)
const [isRevealHoldingResult, setIsRevealHoldingResult] = useState(false)
const [isRevealSettlingResult, setIsRevealSettlingResult] = useState(false)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
)
const revealRewardType = useGameRoundStore(
(state) => state.revealAnimation.rewardType,
)
const roundPhase = useGameRoundStore((state) => state.round.phase)
const roundId = useGameRoundStore((state) => state.round.id)
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,
)
@@ -109,31 +105,46 @@ export function DesktopAnimal({
revealPhase === 'spinning' ||
(revealPhase === 'stopping' && !isRevealHoldingResult)
const isRevealResult = revealPhase === 'result'
const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false)
const shouldHideRevealHighlight =
revealPhase === 'result' &&
(revealRewardType !== 'none' || hasRewardOverlayStarted)
const hasSubmittedCurrentRound =
roundPhase === 'betting' && Boolean(roundId) && lastBetPeriodNo === roundId
const showStopOverlay =
hasSubmittedCurrentRound ||
roundPhase === 'locked' ||
roundPhase === 'revealing'
const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
? zhStopImage
: enStopImage
useEffect(() => {
if (revealPhase === 'idle') {
setHasRewardOverlayStarted(false)
return
}
if (revealPhase === 'result' && revealRewardType !== 'none') {
setHasRewardOverlayStarted(true)
}
}, [revealPhase, revealRewardType])
useEffect(() => {
if (revealPhase === 'idle') {
setRevealCellId(null)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
if (revealPhase === 'result') {
setRevealCellId(revealWinningCellId)
setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
if (revealPhase === 'spinning') {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const intervalId = window.setInterval(() => {
@@ -147,22 +158,28 @@ export function DesktopAnimal({
if (revealWinningCellId === null) {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
const startedAt = performance.now()
let timeoutId = 0
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
const step = () => {
const elapsedMs = performance.now() - startedAt
if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) {
setIsRevealSettlingResult(true)
setRevealCellId(revealWinningCellId)
setIsRevealHoldingResult(true)
timeoutId = window.setTimeout(() => {
finishRevealAnimation()
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
setIsRevealSettlingResult(false)
setIsRevealHoldingResult(true)
timeoutId = window.setTimeout(() => {
finishRevealAnimation()
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
}, SETTLEMENT_REVEAL_SETTLE_DURATION_MS)
return
}
@@ -181,7 +198,13 @@ export function DesktopAnimal({
return () => {
window.clearTimeout(timeoutId)
}
}, [animalIds, finishRevealAnimation, revealPhase, revealWinningCellId])
}, [
animalIds,
finishRevealAnimation,
revealPhase,
revealWinningCellId,
shouldHideRevealHighlight,
])
useLayoutEffect(() => {
if (revealCellId === null) {
@@ -230,6 +253,7 @@ export function DesktopAnimal({
const hasPlacedSelection = Boolean(selectionMeta)
const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner =
!shouldHideRevealHighlight &&
(isRevealResult || isRevealHoldingResult) &&
revealWinningCellId === item.id
const warningType =
@@ -238,7 +262,9 @@ export function DesktopAnimal({
const warningLabel =
warningType === 'balance'
? t('gameDesktop.animal.insufficientBalanceRecharge')
: t('gameDesktop.animal.selectionLimitReached')
: warningType === 'betLimit'
? t('gameDesktop.animal.betLimitExceeded')
: t('gameDesktop.animal.selectionLimitReached')
return (
<motion.button
@@ -409,7 +435,14 @@ export function DesktopAnimal({
{revealFrame ? (
<div
aria-hidden="true"
className="pointer-events-none absolute z-40 transition-[height,transform,width] duration-75 ease-linear"
className={cn(
'pointer-events-none absolute z-40 transition-[height,transform,width]',
prefersReducedMotion
? 'duration-0'
: isRevealSettlingResult
? 'duration-[800ms] ease-[cubic-bezier(0.16,1,0.3,1)]'
: 'duration-75 ease-linear',
)}
style={{
height: revealFrame.height,
transform: `translate(${revealFrame.left}px, ${revealFrame.top}px)`,
@@ -436,88 +469,8 @@ export function DesktopAnimal({
</div>
) : null}
{showStopOverlay && !hostingFlag ? (
<div
aria-hidden="true"
className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]"
>
<SmartImage
src={stopImageSrc}
alt="stop betting"
priority
showSkeleton={false}
className="h-design-220 w-design-560 max-w-[78%] overflow-visible"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
/>
</div>
) : null}
{hostingFlag ? (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center gap-design-22 bg-[rgba(2,8,14,0.6)] px-design-24 backdrop-blur-[1px]">
{showStopOverlay ? (
<SmartImage
src={stopImageSrc}
alt="stop betting"
priority
showSkeleton={false}
className="h-design-170 w-design-520 max-w-[72%] overflow-visible"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
/>
) : null}
<SmartBackground
src={hostingBg}
size="100% 100%"
repeat="no-repeat"
position="center"
className="h-design-350 w-design-930 flex flex-col items-center justify-center"
>
<div className={'flex flex-col gap-design-44 items-center'}>
<div className={'flex items-center gap-design-20'}>
<motion.span
aria-hidden="true"
animate={prefersReducedMotion ? undefined : { rotate: 360 }}
transition={{
duration: 1.4,
ease: 'linear',
repeat: Number.POSITIVE_INFINITY,
}}
className="flex h-design-48 w-design-48 items-center justify-center"
>
<SmartImage
src={refreshIcon}
alt=""
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.36)]"
/>
</motion.span>
<div
className={
'text-design-22 text-[#ffffff] font-bold [text-shadow:0_0_calc(var(--design-unit)*12)_rgba(76,236,255,0.45)]'
}
>
{t('game.autoSpin.runningRounds', {
count: completedAutoHostingRounds,
})}
</div>
</div>
<SmartBackground
as="button"
type="button"
onClick={stopHosting}
src={hostingBtn}
size="100% 100%"
repeat="no-repeat"
position="center"
className="h-design-70 w-design-220 flex cursor-pointer items-center justify-center pb-design-4 text-design-24 font-bold text-[#EFFFFF] [text-shadow:0_1px_0_rgba(255,255,255,0.18),0_0_calc(var(--design-unit)*10)_rgba(46,220,255,0.5)] transition-transform hover:-translate-y-[1px] active:translate-y-0"
>
{t('game.actions.stopAuto')}
</SmartBackground>
</div>
</SmartBackground>
</div>
) : null}
<DesktopAnimalOverlay showStopOverlay={showStopOverlay} />
<RoundBettingStartAlert />
{showStandbyState ? (
<button
@@ -526,29 +479,6 @@ export function DesktopAnimal({
aria-busy={isRealtimeConnecting}
className="group absolute inset-0 z-10 flex cursor-pointer items-center justify-center overflow-hidden bg-[rgba(3,13,20,0.66)]"
>
<motion.div
aria-hidden="true"
animate={{ rotate: 360 }}
transition={{
duration: 18,
repeat: Number.POSITIVE_INFINITY,
ease: 'linear',
}}
className="pointer-events-none absolute inset-[12%] rounded-full bg-[conic-gradient(from_0deg,rgba(129,255,250,0)_0deg,rgba(129,255,250,0.26)_60deg,rgba(129,255,250,0)_120deg,rgba(255,255,255,0)_360deg)] opacity-70 blur-[18px]"
/>
<motion.div
aria-hidden="true"
animate={{
scale: [1, 1.03, 1],
opacity: [0.42, 0.7, 0.42],
}}
transition={{
duration: 2.8,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}}
className="pointer-events-none absolute inset-[22%] rounded-full border border-[rgba(124,255,248,0.22)] shadow-[0_0_calc(var(--design-unit)*22)_rgba(74,245,255,0.12),inset_0_0_calc(var(--design-unit)*18)_rgba(122,255,250,0.14)]"
/>
<div className="relative flex min-w-design-260 flex-col items-center gap-design-10 rounded-[calc(var(--design-unit)*22)] border border-[rgba(111,255,247,0.56)] bg-[linear-gradient(180deg,rgba(8,30,42,0.94),rgba(4,14,20,0.96))] px-design-32 py-design-18 text-center shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.38),0_0_calc(var(--design-unit)*42)_rgba(19,210,232,0.22),inset_0_0_calc(var(--design-unit)*18)_rgba(120,255,249,0.12)] transition-[transform,box-shadow,border-color] duration-200 group-hover:-translate-y-[1px] group-hover:border-[rgba(141,255,250,0.82)] group-hover:shadow-[0_0_calc(var(--design-unit)*24)_rgba(88,247,255,0.5),0_0_calc(var(--design-unit)*52)_rgba(32,228,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(145,255,251,0.16)]">
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*22)] border border-[rgba(226,255,255,0.1)]" />
<span className="pointer-events-none absolute inset-x-design-14 top-design-10 h-design-18 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.16),rgba(255,255,255,0))] opacity-70" />

View File

@@ -1,7 +1,7 @@
import { motion } from 'motion/react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import add from '@/assets/game/add.webp'
import reduce from '@/assets/game/add.webp'
import arrow from '@/assets/game/arrow.webp'
import chipBg from '@/assets/game/chip-bg.webp'
import chipLineBg from '@/assets/game/chip-line-bg.webp'
@@ -10,7 +10,7 @@ import confirmBg from '@/assets/game/confirm-bg.webp'
import confirmRedBg from '@/assets/game/confirm-red-bg.png'
import controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp'
import reduce from '@/assets/game/reduce.webp'
import add from '@/assets/game/reduce.webp'
import totalBg from '@/assets/game/total-bg.webp'
import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
@@ -45,6 +45,8 @@ export function DesktopControl() {
const [clickedId, setClickedId] = useState<string | null>(null)
const [hidingId, setHidingId] = useState<string | null>(null)
const [confirmClicked, setConfirmClicked] = useState(false)
const isConfirmWarning =
confirmState === 'insufficient' || confirmState === 'limit'
const handleChipClick = (chipId: string) => {
if (!acceptingBets) {
@@ -348,7 +350,7 @@ export function DesktopControl() {
>
<div>
{t('gameDesktop.control.selected')}:{' '}
<span className={'text-red-500'}>{selectedCountLabel}</span> /{' '}
<span className={'text-reduce-500'}>{selectedCountLabel}</span> /{' '}
{maxSelectionCountLabel}
</div>
<div className={'flex'}>
@@ -455,7 +457,7 @@ export function DesktopControl() {
</SmartBackground>
<SmartBackground
as={motion.button}
src={confirmState === 'insufficient' ? confirmRedBg : confirmBg}
src={isConfirmWarning ? confirmRedBg : confirmBg}
size="100% 100%"
type="button"
onClick={handleConfirmClick}
@@ -505,7 +507,7 @@ export function DesktopControl() {
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="pointer-events-none absolute inset-0 bg-center bg-no-repeat"
src={confirmState === 'insufficient' ? confirmRedBg : confirmBg}
src={isConfirmWarning ? confirmRedBg : confirmBg}
size="100% 100%"
/>
)}
@@ -524,10 +526,9 @@ export function DesktopControl() {
: {
opacity: confirmClicked ? 0 : 1,
y: confirmClicked ? 2 : 0,
textShadow:
confirmState === 'insufficient'
? '0 0 10px rgba(255,206,206,0.45)'
: 'none',
textShadow: isConfirmWarning
? '0 0 10px rgba(255,206,206,0.45)'
: 'none',
}
}
transition={
@@ -539,10 +540,7 @@ export function DesktopControl() {
}
: { duration: 0.15 }
}
className={cn(
'relative',
confirmState === 'insufficient' && 'text-[#FFF1F1]',
)}
className={cn('relative', isConfirmWarning && 'text-[#FFF1F1]')}
>
{confirmLabel}
</motion.span>

View File

@@ -90,7 +90,7 @@ export function DesktopGameHistory() {
ref={parentRef}
onScroll={handleScroll}
className={
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-6 overflow-y-auto overflow-x-hidden px-design-14 py-design-14'
}
>
{isInitialLoading ? (
@@ -142,10 +142,10 @@ export function DesktopGameHistory() {
: 'linear-gradient(180deg,rgba(31,111,54,0.38),rgba(6,28,20,0.92))'
return (
<div key={item.id} className="w-full pb-design-12 last:pb-0">
<div key={item.id} className="w-full pb-design-4 last:pb-0">
<div
className={
'relative isolate flex w-full flex-col items-center overflow-hidden rounded-[calc(var(--design-unit)*14)] border bg-[linear-gradient(180deg,rgba(6,33,45,0.97),rgba(3,14,23,0.94))] text-[#FFE375] shadow-[0_0_calc(var(--design-unit)*16)_rgba(63,226,255,0.14),inset_0_1px_0_rgba(218,255,255,0.12)] transition-colors duration-200'
'relative isolate flex w-full flex-col overflow-hidden rounded-[calc(var(--design-unit)*8)] border bg-[linear-gradient(180deg,rgba(6,33,45,0.95),rgba(3,14,23,0.92))] text-[#FFE375] shadow-[0_0_calc(var(--design-unit)*10)_rgba(63,226,255,0.1),inset_0_1px_0_rgba(218,255,255,0.1)] transition-colors duration-200'
}
style={{
borderColor: statusBorderColor,
@@ -153,28 +153,31 @@ export function DesktopGameHistory() {
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-design-10 top-0 h-px opacity-80"
className="pointer-events-none absolute inset-x-design-8 top-0 h-px opacity-70"
style={{
background: `linear-gradient(90deg, transparent, ${statusColor}, transparent)`,
}}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute -right-design-28 -top-design-30 h-design-90 w-design-90 rounded-full blur-[28px]"
className="pointer-events-none absolute -right-design-24 -top-design-28 h-design-72 w-design-72 rounded-full blur-[24px]"
style={{
background: statusColor,
opacity: isWin ? 0.14 : 0.08,
}}
/>
<div
className="relative z-10 flex min-h-design-42 w-full items-center justify-center border-b px-design-12 py-design-7"
className="relative z-10 grid min-h-design-32 w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-design-8 border-b px-design-9 py-design-4"
style={{
background: statusBg,
borderColor: statusBorderColor,
}}
>
<span className="min-w-0 truncate text-design-12 font-medium text-[#8DBCC2]">
{item.createdAtLabel}
</span>
<span
className="inline-flex min-w-design-118 items-center justify-center gap-design-8 px-design-14 py-design-4 text-design-18 font-bold"
className="inline-flex min-w-design-72 items-center justify-center gap-design-5 text-design-15 font-bold"
style={{
borderColor: statusBorderColor,
color: statusColor,
@@ -183,41 +186,33 @@ export function DesktopGameHistory() {
>
<span
aria-hidden="true"
className="h-design-8 w-design-8 rounded-full shadow-[0_0_calc(var(--design-unit)*10)_currentColor]"
className="h-design-6 w-design-6 rounded-full shadow-[0_0_calc(var(--design-unit)*8)_currentColor]"
style={{ backgroundColor: statusColor }}
/>
{statusLabel}
</span>
<span className="min-w-0 truncate text-right text-design-12 font-medium text-[#C0E7EB]">
{t('gameDesktop.history.roundId')}: {item.periodNo}
</span>
</div>
<div
className={
'relative z-10 flex w-full flex-col gap-design-7 px-design-10 py-design-10 text-design-16'
'relative z-10 flex w-full flex-col gap-design-5 px-design-8 py-design-7 text-design-13'
}
>
<div className="flex min-h-design-34 items-center justify-between gap-design-10 rounded-[calc(var(--design-unit)*8)] border border-[rgba(94,212,230,0.14)] bg-[rgba(5,23,33,0.58)] px-design-10 py-design-6">
<span className={'shrink-0 text-[#84A2A2]'}>
{t('gameDesktop.history.roundId')}:{' '}
</span>
<span
className={
'min-w-0 text-right text-design-15 font-medium text-[#C0E7EB]'
}
>
{item.periodNo}
</span>
</div>
<div className="flex min-h-design-42 items-start justify-between gap-design-10 rounded-[calc(var(--design-unit)*8)] border border-[rgba(94,212,230,0.14)] bg-[rgba(5,23,33,0.58)] px-design-10 py-design-6">
<span className={'shrink-0 pt-design-7 text-[#84A2A2]'}>
{t('gameDesktop.history.numbers')}:{' '}
<div className="flex min-h-design-34 items-start justify-between gap-design-6 rounded-[calc(var(--design-unit)*6)] border border-[rgba(94,212,230,0.12)] bg-[rgba(5,23,33,0.52)] px-design-7 py-design-4">
<span className={'shrink-0 pt-design-5 text-[#84A2A2]'}>
{t('gameDesktop.history.numbers')}
</span>
{item.numbers.length === 0 ? (
<span className="pt-design-7 text-right">
<span className="pt-design-5 text-right">
{item.numbersLabel}
</span>
) : (
<span className="inline-flex min-w-0 flex-1 flex-wrap items-center justify-end gap-design-5 align-middle">
<span className="inline-flex min-w-0 flex-1 flex-wrap items-center justify-end gap-design-3 align-middle">
{item.numbers.map((number) => (
<HistoryRewardNumber
className="!h-design-28 !min-w-design-28 !w-design-28 !p-0"
key={`${item.id}-${number}`}
number={number}
/>
@@ -225,36 +220,36 @@ export function DesktopGameHistory() {
</span>
)}
</div>
<div className="flex min-h-design-34 items-center justify-between gap-design-10 rounded-[calc(var(--design-unit)*8)] border border-[rgba(255,227,117,0.16)] bg-[linear-gradient(90deg,rgba(5,23,33,0.58),rgba(84,57,8,0.16))] px-design-10 py-design-6">
<span className={'shrink-0 text-[#84A2A2]'}>
{t('gameDesktop.history.totalPoolAmount')}:{' '}
</span>
<span
className={
'text-right text-design-17 font-bold text-[#FFE375] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,227,117,0.22)]'
}
>
{item.amountLabel}
</span>
</div>
<div className="flex min-h-design-42 items-center justify-between gap-design-10 rounded-[calc(var(--design-unit)*8)] border border-[rgba(255,117,117,0.18)] bg-[linear-gradient(90deg,rgba(5,23,33,0.58),rgba(88,20,28,0.16))] px-design-10 py-design-6">
<span className={'shrink-0 text-[#84A2A2]'}>
{t('gameDesktop.history.winningResult')}:{' '}
</span>
{item.resultNumber === null ? (
<span
className={
'text-right font-bold text-[#FF7575] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,117,117,0.28)]'
}
>
{item.resultNumberLabel}
<div className="grid grid-cols-[1fr_1fr] gap-design-5">
<div className="flex min-h-design-28 items-center justify-between gap-design-6 rounded-[calc(var(--design-unit)*6)] border border-[rgba(255,227,117,0.14)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(84,57,8,0.14))] px-design-7 py-design-4">
<span className="shrink-0 text-[#84A2A2]">
{t('gameDesktop.history.payout')}
</span>
) : (
<HistoryRewardNumber
className="text-[#FF7575]"
number={item.resultNumber}
/>
)}
<span className="min-w-0 truncate text-right text-design-14 font-bold text-[#FFE375] [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(255,227,117,0.2)]">
{item.winAmountLabel}
</span>
</div>
<div className="flex min-h-design-28 items-center justify-between gap-design-6 rounded-[calc(var(--design-unit)*6)] border border-[rgba(255,117,117,0.16)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(88,20,28,0.14))] px-design-7 py-design-4">
<span className={'shrink-0 text-[#84A2A2]'}>
{t('gameDesktop.history.winningResult')}
</span>
<span className="flex min-w-0 items-center justify-end">
{item.resultNumber === null ? (
<span
className={
'text-right font-bold text-[#FF7575] [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(255,117,117,0.24)]'
}
>
{item.resultNumberLabel}
</span>
) : (
<HistoryRewardNumber
className="!h-design-28 !min-w-design-28 !w-design-28 !p-0 text-[#FF7575]"
number={item.resultNumber}
/>
)}
</span>
</div>
</div>
</div>
</div>

View File

@@ -3,13 +3,13 @@ import {
Mail,
Maximize,
Minimize,
Plus,
UserKey,
UserRoundPlus,
Volume2,
VolumeX,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import add from '@/assets/game/add.webp'
import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
@@ -195,8 +195,7 @@ export function DesktopHeader() {
<button
type="button"
onClick={onOpenProcedures}
className="group relative flex items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
className="group relative flex items-center justify-center transition-transform duration-150"
>
<SmartImage
src={diamond}
@@ -206,19 +205,17 @@ export function DesktopHeader() {
/>
<div
className={
'common-neon-inset text-design-16 !py-design-20 !pr-design-14 box-border flex h-design-36 w-design-180 items-center justify-end gap-design-8 transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end gap-design-12 transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
}
>
<span className="truncate">{currentUser?.coin || '--'}</span>
<div className={'common-neon-inset !p-design-3'}>
<Plus
aria-hidden="true"
className="shrink-0"
color="#57B8BF"
size={18}
strokeWidth={2.5}
/>
</div>
<div className="truncate">{currentUser?.coin || '--'}</div>
<SmartImage
onClick={onOpenProcedures}
className={'w-design-24 h-design-24 cursor-pointer'}
alt={'add'}
src={add}
/>
</div>
</button>
</div>

View File

@@ -1,244 +0,0 @@
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

@@ -1,3 +1,4 @@
import { motion, useReducedMotion } from 'motion/react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import streakBg from '@/assets/game/pc-streak.webp'
@@ -17,6 +18,7 @@ import { cn } from '@/lib/utils.ts'
export function DesktopStatusLine() {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const {
countdownMs,
limitLabel,
@@ -165,22 +167,63 @@ export function DesktopStatusLine() {
className={countdownClassName}
/>
</div>
<div
className={'flex-1 flex items-center justify-center gap-design-100'}
>
<div className={'flex items-center gap-2'}>
<div className={'flex items-center gap-2'}>
<div
className={'w-design-20 h-design-20 bg-[#78FF7F] rounded-[50%]'}
></div>
<div className={cn(phaseToneClassName, 'font-bold')}>
{phaseLabel}
</div>
<div className="flex flex-1 items-center justify-center gap-design-88">
<div className="flex items-center gap-design-9">
<span className="relative flex h-design-24 w-design-24 items-center justify-center">
<motion.span
aria-hidden="true"
className="absolute h-design-24 w-design-24 rounded-full bg-[rgba(120,255,127,0.22)] blur-[calc(var(--design-unit)*3)]"
animate={
prefersReducedMotion
? undefined
: { opacity: [0.36, 0.88, 0.42], scale: [0.9, 1.28, 0.98] }
}
transition={
prefersReducedMotion
? undefined
: {
duration: 1.25,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
}
/>
<motion.span
aria-hidden="true"
className="relative h-design-14 w-design-14 rounded-full bg-[#78FF7F] shadow-[0_0_calc(var(--design-unit)*8)_rgba(120,255,127,0.78),0_0_calc(var(--design-unit)*16)_rgba(120,255,127,0.34)]"
animate={
prefersReducedMotion
? undefined
: { opacity: [0.78, 1, 0.86], scale: [0.96, 1.12, 1] }
}
transition={
prefersReducedMotion
? undefined
: {
duration: 1.25,
ease: 'easeInOut',
repeat: Number.POSITIVE_INFINITY,
}
}
/>
</span>
<div
className={cn(
phaseToneClassName,
'font-black tracking-[0.08em] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(120,255,127,0.44)]',
)}
>
{phaseLabel}
</div>
</div>
<div className={'text-[#CBD3D5] font-bold'}>
{t('gameDesktop.status.roundId')}:{roundId}
<div className="flex items-baseline gap-design-7 font-bold leading-none">
<span className="text-design-20 tracking-[0.06em] text-[#9FBAC0]">
{t('gameDesktop.status.roundId')}:
</span>
<span className="text-design-24 font-black tracking-[0.06em] text-[#E7FFFF] [text-shadow:0_0_calc(var(--design-unit)*9)_rgba(75,255,254,0.38)]">
{roundId}
</span>
</div>
</div>
</SmartBackground>

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>
)
}

View File

@@ -1,4 +1,5 @@
import { MobileHeader } from '@/features/game/components/mobile/mobile-header.tsx'
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
export function MobileEntry() {
@@ -7,6 +8,7 @@ export function MobileEntry() {
return (
<>
<MobileHeader />
<RoundBettingStartAlert placement="fixed" />
</>
)
}

View File

@@ -2,7 +2,6 @@ import { DesktopHeader, EntryNoticeGateModal } from '@/features/game/components'
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
import DesktopRewardOverlay from '@/features/game/components/desktop/desktop-reward-overlay.tsx'
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
@@ -67,8 +66,6 @@ export function PcEntry() {
<DesktopProceduresModal />
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
<DesktopWithdrawTopupModal />
{/* 大奖/小奖动画展示 */}
<DesktopRewardOverlay />
{/* 强制弹窗 */}
<EntryNoticeGateModal />
</>

View File

@@ -22,7 +22,7 @@ function parseBalance(value: string | number | null | undefined) {
return Number.isFinite(parsed) ? parsed : 0
}
export type DesktopAnimalWarningType = 'balance' | 'limit'
export type DesktopAnimalWarningType = 'balance' | 'betLimit' | 'limit'
function getNextMarqueeId(ids: number[], currentId: number | null) {
if (ids.length === 0) {
@@ -60,6 +60,7 @@ export function useAnimalVm(
const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const roundId = useGameRoundStore((state) => state.round.id)
const roundPhase = useGameRoundStore((state) => state.round.phase)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
@@ -70,6 +71,9 @@ export function useAnimalVm(
const selections = useGameRoundStore((state) => state.selections)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const connection = useGameSessionStore((state) => state.connection)
const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax,
)
const requestRealtimeConnection = useGameSessionStore(
(state) => state.requestRealtimeConnection,
)
@@ -112,7 +116,8 @@ export function useAnimalVm(
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
const hasSubmittedCurrentRound =
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
const lockInteraction = showStandbyState || hasSubmittedCurrentRound
const lockInteraction =
showStandbyState || hasSubmittedCurrentRound || roundPhase !== 'betting'
const selectedCellCount = Object.keys(selectionByCell).length
useEffect(() => {
@@ -169,7 +174,7 @@ export function useAnimalVm(
}
const handleSelect = (animalId: number) => {
if (showStandbyState) {
if (roundPhase !== 'betting' || lockInteraction) {
return
}
@@ -193,6 +198,14 @@ export function useAnimalVm(
const nextBetAmount = (activeChip?.amount ?? 0) * activeBetQuantity
if (tableLimitMax > 0 && totalBetAmount + nextBetAmount > tableLimitMax) {
setCellWarning({
cellId: animalId,
type: 'betLimit',
})
return
}
if (totalBetAmount + nextBetAmount > balance) {
setCellWarning({
cellId: animalId,

View File

@@ -5,7 +5,11 @@ import { placeGameBet } from '@/features/game'
import type { BetSelection } from '@/features/game/shared'
import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store/auth'
import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
import {
useGameAutoHostingStore,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
function parseBalance(value: string | number | null | undefined) {
if (typeof value === 'number') {
@@ -87,6 +91,9 @@ export function useAutoHostingRunner() {
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const round = useGameRoundStore((state) => state.round)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax,
)
const lastSingleWinAmount = useGameAutoHostingStore(
(state) => state.lastSingleWinAmount,
)
@@ -171,6 +178,12 @@ export function useAutoHostingRunner() {
)
const balance = parseBalance(currentUser.coin)
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBetLimit'))
return
}
if (totalBetAmount > balance) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
@@ -250,6 +263,7 @@ export function useAutoHostingRunner() {
selections,
setCurrentUser,
stopHosting,
tableLimitMax,
t,
])
}

View File

@@ -11,7 +11,7 @@ import {
useGameSessionStore,
} from '@/store/game'
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'submitting'
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'limit' | 'submitting'
function formatChipDisplayValue(amount: number) {
if (Number.isInteger(amount)) {
@@ -86,6 +86,9 @@ export function useGameControlVm() {
const connectionStatus = useGameSessionStore(
(state) => state.connection.status,
)
const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax,
)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
@@ -122,14 +125,18 @@ export function useGameControlVm() {
const hasSubmittedCurrentRound =
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
const hasExceededBetLimit =
hasSelections && tableLimitMax > 0 && totalBetAmount > tableLimitMax
const confirmState: ConfirmState =
isSubmitting || isAutoHosting
? 'submitting'
: !hasSelections
? 'idle'
: hasInsufficientBalance
? 'insufficient'
: 'ready'
: hasExceededBetLimit
? 'limit'
: hasInsufficientBalance
? 'insufficient'
: 'ready'
const handleConfirm = useCallback(async () => {
if (confirmState === 'submitting' || !hasSelections) {
@@ -142,6 +149,11 @@ export function useGameControlVm() {
return
}
if (hasExceededBetLimit) {
notify.warning(t('commonUi.toast.betLimitExceeded'))
return
}
if (hasInsufficientBalance) {
notify.warning(t('commonUi.toast.insufficientBalance'))
return
@@ -214,6 +226,7 @@ export function useGameControlVm() {
confirmState,
currentUser,
hasInsufficientBalance,
hasExceededBetLimit,
hasSelections,
hasSubmittedCurrentRound,
round.id,
@@ -275,9 +288,11 @@ export function useGameControlVm() {
? t('gameDesktop.control.selectNumbers')
: confirmState === 'insufficient'
? t('gameDesktop.control.insufficientBalance')
: confirmState === 'submitting'
? t('gameDesktop.control.submitting')
: t('gameDesktop.control.confirm'),
: confirmState === 'limit'
? t('gameDesktop.control.betLimitExceeded')
: confirmState === 'submitting'
? t('gameDesktop.control.submitting')
: t('gameDesktop.control.confirm'),
confirmState,
isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
onChipSelect: selectChip,

View File

@@ -13,6 +13,7 @@ import {
selectSelectionTotal,
useGameAutoHostingStore,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
function parseAmount(value: string) {
@@ -43,6 +44,9 @@ function DesktopAutoSettingModal() {
const round = useGameRoundStore((state) => state.round)
const selections = useGameRoundStore((state) => state.selections)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax,
)
const startHosting = useGameAutoHostingStore((state) => state.startHosting)
const [balanceLimitEnabled, setBalanceLimitEnabled] = useState(false)
const [balanceLimitValue, setBalanceLimitValue] = useState('0')
@@ -69,6 +73,11 @@ function DesktopAutoSettingModal() {
const balance = parseBalance(currentUser?.coin)
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
notify.warning(t('commonUi.toast.betLimitExceeded'))
return
}
if (totalBetAmount > balance) {
notify.warning(t('commonUi.toast.insufficientBalance'))
return

View File

@@ -89,9 +89,12 @@ export default {
phases: {
betting: 'Betting',
locked: 'Locked',
revealing: 'Revealing',
settled: 'Settled',
},
roundBettingStart: {
title: 'Round {{roundId}}',
action: 'Start Betting',
},
actions: {
unifiedBetHint: 'Unified bet',
totalBet: 'Total bet',
@@ -253,6 +256,7 @@ export default {
inviteLinkCopyFailed:
'Failed to copy invite link. Please copy it manually.',
insufficientBalance: 'Insufficient balance. Please adjust your bet.',
betLimitExceeded: 'Single bet limit exceeded',
betUnavailable: 'Betting is not available for this round',
betPlaced: 'Bet placed successfully',
noRecentSuccessfulBet:
@@ -266,6 +270,8 @@ export default {
autoHostingStopped: 'Auto spin stopped',
autoHostingStoppedBalance:
'Balance condition reached. Auto spin has stopped.',
autoHostingStoppedBetLimit:
'Single bet limit exceeded. Auto spin has stopped.',
autoHostingStoppedWin:
'Single-win condition reached. Auto spin has stopped.',
autoHostingStoppedJackpot: 'Jackpot reached. Auto spin has stopped.',
@@ -378,6 +384,7 @@ export default {
confirm: 'Confirm',
selectNumbers: 'Select Numbers',
insufficientBalance: 'Insufficient Balance',
betLimitExceeded: 'Limit Exceeded',
submitting: 'Submitting...',
actions: {
clear: 'Clear',
@@ -404,7 +411,7 @@ export default {
description: '(Revealing Result)',
},
settled: {
label: 'Settled',
label: 'Drawing',
description: '(Round Complete)',
},
waiting: {
@@ -418,10 +425,12 @@ export default {
},
animal: {
insufficientBalanceRecharge: 'Insufficient balance, please top up',
betLimitExceeded: 'Single bet limit exceeded',
loading: 'Loading',
selectionLimitReached: 'Selection limit exceeded',
tapToEnter: 'Tap To Enter',
getStart: 'Get Start',
noBet: 'No Bet',
},
history: {
title: 'History',
@@ -431,6 +440,7 @@ export default {
orderNo: 'Order No.',
roundId: 'Round ID',
numbers: 'Bet Numbers',
createdAt: 'Time',
settledAt: 'Settled At',
totalPoolAmount: 'Bet Amount',
winningResult: 'Winning Result',

View File

@@ -88,9 +88,12 @@ export default {
phases: {
betting: 'Betting',
locked: 'Terkunci',
revealing: 'Mengungkap',
settled: 'Selesai',
},
roundBettingStart: {
title: 'Ronde {{roundId}}',
action: 'Mulai Bertaruh',
},
actions: {
unifiedBetHint: 'Bet seragam',
totalBet: 'Total bet',
@@ -252,6 +255,7 @@ export default {
inviteLinkCopyFailed:
'Gagal menyalin tautan undangan. Silakan salin secara manual.',
insufficientBalance: 'Saldo tidak cukup. Silakan sesuaikan taruhan.',
betLimitExceeded: 'Melebihi batas taruhan tunggal',
betUnavailable: 'Taruhan tidak tersedia untuk ronde ini',
betPlaced: 'Taruhan berhasil dikirim',
noRecentSuccessfulBet:
@@ -265,6 +269,8 @@ export default {
autoHostingStopped: 'Auto spin berhenti',
autoHostingStoppedBalance:
'Kondisi saldo tercapai. Auto spin telah berhenti.',
autoHostingStoppedBetLimit:
'Melebihi batas taruhan tunggal. Auto spin telah berhenti.',
autoHostingStoppedWin:
'Kondisi kemenangan tunggal tercapai. Auto spin telah berhenti.',
autoHostingStoppedJackpot: 'Jackpot tercapai. Auto spin telah berhenti.',
@@ -378,6 +384,7 @@ export default {
confirm: 'Konfirmasi',
selectNumbers: 'Pilih Nombor',
insufficientBalance: 'Saldo Tidak Cukup',
betLimitExceeded: 'Batas Terlampaui',
submitting: 'Mengirim...',
actions: {
clear: 'Hapus',
@@ -404,7 +411,7 @@ export default {
description: '(Mengungkap Hasil)',
},
settled: {
label: 'Selesai',
label: 'Drawing',
description: '(Ronde Selesai)',
},
waiting: {
@@ -418,10 +425,12 @@ export default {
},
animal: {
insufficientBalanceRecharge: 'Saldo tidak cukup, silakan isi ulang',
betLimitExceeded: 'Melebihi batas taruhan tunggal',
loading: 'Memuat',
selectionLimitReached: 'Melebihi pilihan yang diizinkan',
tapToEnter: 'Ketuk Untuk Masuk',
getStart: 'Mulai',
noBet: 'Belum Bertaruh',
},
history: {
title: 'Riwayat',
@@ -431,6 +440,7 @@ export default {
orderNo: 'No. Order',
roundId: 'ID Ronde',
numbers: 'Nomor Taruhan',
createdAt: 'Waktu',
settledAt: 'Waktu Selesai',
totalPoolAmount: 'Jumlah Taruhan',
winningResult: 'Hasil Menang',

View File

@@ -91,9 +91,12 @@ export default {
phases: {
betting: 'Taruhan',
locked: 'Dikunci',
revealing: 'Cabutan',
settled: 'Selesai',
},
roundBettingStart: {
title: 'Pusingan {{roundId}}',
action: 'Mula Bertaruh',
},
actions: {
unifiedBetHint: 'Taruhan seragam',
totalBet: 'Jumlah taruhan',
@@ -256,6 +259,7 @@ export default {
inviteLinkCopyFailed:
'Gagal menyalin pautan jemputan. Sila salin secara manual.',
insufficientBalance: 'Baki tidak mencukupi. Sila laraskan taruhan.',
betLimitExceeded: 'Melebihi had taruhan tunggal',
betUnavailable: 'Taruhan tidak tersedia untuk pusingan ini',
betPlaced: 'Taruhan berjaya dihantar',
noRecentSuccessfulBet:
@@ -269,6 +273,8 @@ export default {
autoHostingStopped: 'Putaran auto dihentikan',
autoHostingStoppedBalance:
'Syarat baki dicapai. Putaran auto telah dihentikan.',
autoHostingStoppedBetLimit:
'Melebihi had taruhan tunggal. Putaran auto telah dihentikan.',
autoHostingStoppedWin:
'Syarat kemenangan tunggal dicapai. Putaran auto telah dihentikan.',
autoHostingStoppedJackpot:
@@ -383,6 +389,7 @@ export default {
confirm: 'Sahkan',
selectNumbers: 'Pilih Nombor',
insufficientBalance: 'Baki Tidak Mencukupi',
betLimitExceeded: 'Melebihi Had',
submitting: 'Menghantar...',
actions: {
clear: 'Kosongkan',
@@ -409,7 +416,7 @@ export default {
description: '(Mendedahkan Hasil)',
},
settled: {
label: 'Selesai',
label: 'Cabutan',
description: '(Pusingan Tamat)',
},
waiting: {
@@ -423,10 +430,12 @@ export default {
},
animal: {
insufficientBalanceRecharge: 'Baki tidak mencukupi, sila tambah nilai',
betLimitExceeded: 'Melebihi had taruhan tunggal',
loading: 'Memuatkan',
selectionLimitReached: 'Melebihi pilihan aksara yang dibenarkan',
tapToEnter: 'Ketik Untuk Masuk',
getStart: 'Mula',
noBet: 'Belum Bertaruh',
},
history: {
title: 'Sejarah',
@@ -436,6 +445,7 @@ export default {
orderNo: 'No. Pesanan',
roundId: 'ID Pusingan',
numbers: 'Nombor Pertaruhan',
createdAt: 'Masa',
settledAt: 'Masa Selesai',
totalPoolAmount: 'Jumlah Pertaruhan',
winningResult: 'Keputusan Menang',

View File

@@ -87,9 +87,12 @@ export default {
phases: {
betting: '下注中',
locked: '已封盘',
revealing: '开奖中',
settled: '已结算',
},
roundBettingStart: {
title: '{{roundId}}期',
action: '开始下注',
},
actions: {
unifiedBetHint: '统一下注额',
totalBet: '总下注',
@@ -246,6 +249,7 @@ export default {
inviteLinkCopied: '邀请链接已复制',
inviteLinkCopyFailed: '邀请链接复制失败,请手动复制',
insufficientBalance: '余额不足,请调整下注金额',
betLimitExceeded: '超过单次投注限额',
betUnavailable: '当前期不可下注',
betPlaced: '下注成功',
noRecentSuccessfulBet: '暂无上一局成功下注记录',
@@ -256,6 +260,7 @@ export default {
autoHostingStarted: '自动托管已开始',
autoHostingStopped: '自动托管已停止',
autoHostingStoppedBalance: '余额低于条件,自动托管已停止',
autoHostingStoppedBetLimit: '超过单次投注限额,自动托管已停止',
autoHostingStoppedWin: '单次盈利达到条件,自动托管已停止',
autoHostingStoppedJackpot: '出现 Jackpot 大奖,自动托管已停止',
autoHostingSubmitFailed: '自动托管下注失败,已停止托管',
@@ -365,6 +370,7 @@ export default {
confirm: '确认',
selectNumbers: '请选择号码',
insufficientBalance: '余额不足',
betLimitExceeded: '超过限额',
submitting: '提交中...',
actions: {
clear: '清空',
@@ -391,7 +397,7 @@ export default {
description: '(正在开奖)',
},
settled: {
label: '已结算',
label: '开奖中',
description: '(本轮结束)',
},
waiting: {
@@ -405,10 +411,12 @@ export default {
},
animal: {
insufficientBalanceRecharge: '余额不足,请充值',
betLimitExceeded: '超过单次投注限额',
loading: '加载中',
selectionLimitReached: '超过可选择字花',
tapToEnter: '点击进入',
getStart: '开始游戏',
noBet: '未下注',
},
history: {
title: '历史记录',
@@ -418,6 +426,7 @@ export default {
orderNo: '订单号',
roundId: '期号',
numbers: '下注号码',
createdAt: '时间',
settledAt: '结算时间',
totalPoolAmount: '下注金额',
winningResult: '中奖字花',