From 87e8aca97d2b32158f24d93905917ef9681a4dc3 Mon Sep 17 00:00:00 2001 From: JiaJun <2394389886@qq.com> Date: Sat, 30 May 2026 17:22:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=9B=B4=E6=96=B0=E9=A9=AC?= =?UTF-8?q?=E6=9D=A5=E8=A5=BF=E4=BA=9A=E6=89=8B=E6=9C=BA=E5=8F=B7=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=92=8C=E6=B8=B8=E6=88=8F=E7=95=8C=E9=9D=A2=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改认证模块手机号验证规则适配马来西亚号码格式 - 添加新的投注限制提示文本支持多语言 - 重命名结算阶段标签为Drawing统一显示 - 新增桌面版动物游戏遮罩组件分离功能 - 添加回合开始投注提醒弹窗组件 - 优化开奖动画流程和视觉效果 - 添加奖励动画显示和投注汇总展示 - 新增多种投注限制和状态提示信息 --- src/assets/game/add.webp | Bin 3070 -> 3774 bytes src/assets/game/reduce.webp | Bin 3774 -> 3070 bytes src/features/auth/schema/auth-schema.ts | 4 +- .../desktop/desktop-animal-overlay.tsx | 500 ++++++++++++++++++ .../components/desktop/desktop-animal.tsx | 188 +++---- .../components/desktop/desktop-control.tsx | 24 +- .../desktop/desktop-game-history.tsx | 105 ++-- .../components/desktop/desktop-header.tsx | 25 +- .../desktop/desktop-reward-overlay.tsx | 244 --------- .../components/desktop/desktop-status.tsx | 69 ++- .../shared/round-betting-start-alert.tsx | 208 ++++++++ src/features/game/entry/mobile-entry.tsx | 2 + src/features/game/entry/pc-entry.tsx | 3 - src/features/game/hooks/use-animal-vm.ts | 19 +- .../game/hooks/use-auto-hosting-runner.ts | 16 +- .../game/hooks/use-game-control-vm.ts | 29 +- .../desktop/desktop-auto-setting-modal.tsx | 9 + src/locales/en-US/common.ts | 14 +- src/locales/id-ID/common.ts | 14 +- src/locales/ms-MY/common.ts | 14 +- src/locales/zh-CN/common.ts | 13 +- 21 files changed, 1008 insertions(+), 492 deletions(-) create mode 100644 src/features/game/components/desktop/desktop-animal-overlay.tsx delete mode 100644 src/features/game/components/desktop/desktop-reward-overlay.tsx create mode 100644 src/features/game/components/shared/round-betting-start-alert.tsx diff --git a/src/assets/game/add.webp b/src/assets/game/add.webp index 550c5048f34a4f2d58af8fc1e96f900f339a0b43..22b88585cc241c7f158823a427f7b813836e3c8e 100644 GIT binary patch delta 3317 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 delta 2497 zcmV;y2|o6|9sU;vQb|Te_6xBF>;ZoV3IG5vNx%~j4Rs-+{{c_hwk>XE+uHfOA=%E% z!OYCeqdHnJYNJ2dhf%{OF!QJ~Gc$wzNwy;FD( z`s?%03@fN}BMRXzsG1>YqC2Fo=xR{#JDqppKv zt1FFaD(h?iKbYI^)q3~iA5VWi>v`yle^uY}iW&d_z?Jr;_kJip;qRxT>l~xv=6=D+ zbq||*4(l&kr#<;;Aq{0Wu_$E^000000000000000xDlz)W>FYznAQ=~R0r)jo9o}z zWxhWC=f_jqlW)7<;fMT}{iS{boG-oi`_`j>-p+mGc(rC&mnHwWtjT|WzqQ-f>nxw0 zr!dZBRmlheu_2uP*0}r+)F@$$xwC;N6$0Uqkcx zxBfirpk2JEwa4fC{eQpR#ecrO9sRfK&B$I6_DXmZnLBKo%1vwEdA$Anem1t_y z@_KOISe9TsL~O(lp;E@*oUsENBd)(dD^d>YQ0`~=jUIyee~sry#N{E10Vjn zb-44v;(m44Kd-lKzrOos`VSwz@`u~+oX;s!5@lBu0$@w?RRwN7F~N-G5Hh60xxiW(W6MP<*z@!>h_E3YZ^Yi8A$eSGU|J-M958A2%` zuFrSGih`JybIh#AU1z=jU4G1-e7?@{r*pQRuS%3gK`DR4ZV7ochK`lAwCgqBuX9*G zXV>{@7dNwI2t)yZ-BuK%ZL@ZHI=x>Q(vD%KeV7)fREAQvwDa0#m9i<9|E{HRrg@x} zr)k#0r5x?P7R}1PlN--zp3>%~q7^Dd&JwMix7ZCCq2;|$wbY`Ri3>H@gE2>f(sxN) zOp4S#oJLejP+{Z9rg<;Z=8zE*1nm4sf@%@!?A$n$LlzoB-HT&JgQSzSedh9bQvd?) zZBS=2-AS0%UDhh4z9ulk;u>8ioq>d8KI4NYVbq7{&c)~yPxl?kmBMG1kx zoTm*#Iqfob3Dvbq2!z_wEiO7y6InX742P*@s)KMZ<9-{^EKSrhl!ib_l?}ia>y3(- zp|yw-4qFLAQIz@I`C!S`V}>_eZyq<*T0@sff3?P~fFdl_f$G9&Sv6KW`W=%9bZRbd z-o$yCy1edk^{zXwz2drQZMrm-joX!Y(dq&mH-30reB<9cKl|s-&{(QOQ7LBXrcGZS zJL8p?(~C~qok8{Z!M=FMHdc? z6n|~}>gMpT9;7;i*n0Sx0{l?Rp!J~3e^>vx_mRIo)G%%u0uiFzsHGVaN+b%3Ixl}e zmVewt6N^w3af?FSLn;cEsc5bD^1Z+Bm7yhaSK=qh@}vro;Jz1-?A?gQoj2v5Jep!-fDwNLN6h~9Uf2t*5j+LOKLD0_6rwoP#MB~ZTH zzk3!ie^AxtJ|waz#6ILp5+;DejX6{l;gW*euqtiD{UGTj_C zyqA6&NB??__1Ye@4+necc1aZ^s9o5wXcb6kP2p_nWNkwlTh@2U*Y0NeAO8Poz3aSa zS#vY`N3~kpBggWXod;XJp(Z{8pmc!>L zn{aZfWjQ=J?6PchB1E)iw>#0&YM8>9>Q7@#H6-vW^g%8UH_e}4`+xO(E*~>_7RjB6 zH`z{3$%fR~)N<1$ilwyXo`<^BgxawgYN&xKO+R_w=5o8+dGUhJf1dUs{`TRs|IhpN zvkrPH+EXdJ=%f@ajEg|DuEmJ6WiXkzyoXDbfJC6wAbnigOHp^~qi0`!P6ZHu`|#Pn zm;dHD8k0RW_*JbnG` z1%G}sQhS)SG4@`Uf0Ows?J1P)E#&<^c$X+HT9+&V@=4r`-AkHFi@)>ghcBM}y#fXC z*LNR$d({`8b#LzN`Eg`jTgReuoD*_Z3oW5QGEhOJc{gZ6BsC0@zs#$gqvh1;@O{<|M+!VX^zy>jMihEbVTe9YqMHow=nywOC$}{q&@0a#^+4F#BurV z$)le?r*adNpFDlo{~X_!j{bPsn*Uu!E%tR}u8h#MwX6alAk9lbl&RYOm-sI0OWl{> L2j4szy4nB$u^a>+ diff --git a/src/assets/game/reduce.webp b/src/assets/game/reduce.webp index 22b88585cc241c7f158823a427f7b813836e3c8e..550c5048f34a4f2d58af8fc1e96f900f339a0b43 100644 GIT binary patch delta 2497 zcmV;y2|o6|9sU;vQb|Te_6xBF>;ZoV3IG5vNx%~j4Rs-+{{c_hwk>XE+uHfOA=%E% z!OYCeqdHnJYNJ2dhf%{OF!QJ~Gc$wzNwy;FD( z`s?%03@fN}BMRXzsG1>YqC2Fo=xR{#JDqppKv zt1FFaD(h?iKbYI^)q3~iA5VWi>v`yle^uY}iW&d_z?Jr;_kJip;qRxT>l~xv=6=D+ zbq||*4(l&kr#<;;Aq{0Wu_$E^000000000000000xDlz)W>FYznAQ=~R0r)jo9o}z zWxhWC=f_jqlW)7<;fMT}{iS{boG-oi`_`j>-p+mGc(rC&mnHwWtjT|WzqQ-f>nxw0 zr!dZBRmlheu_2uP*0}r+)F@$$xwC;N6$0Uqkcx zxBfirpk2JEwa4fC{eQpR#ecrO9sRfK&B$I6_DXmZnLBKo%1vwEdA$Anem1t_y z@_KOISe9TsL~O(lp;E@*oUsENBd)(dD^d>YQ0`~=jUIyee~sry#N{E10Vjn zb-44v;(m44Kd-lKzrOos`VSwz@`u~+oX;s!5@lBu0$@w?RRwN7F~N-G5Hh60xxiW(W6MP<*z@!>h_E3YZ^Yi8A$eSGU|J-M958A2%` zuFrSGih`JybIh#AU1z=jU4G1-e7?@{r*pQRuS%3gK`DR4ZV7ochK`lAwCgqBuX9*G zXV>{@7dNwI2t)yZ-BuK%ZL@ZHI=x>Q(vD%KeV7)fREAQvwDa0#m9i<9|E{HRrg@x} zr)k#0r5x?P7R}1PlN--zp3>%~q7^Dd&JwMix7ZCCq2;|$wbY`Ri3>H@gE2>f(sxN) zOp4S#oJLejP+{Z9rg<;Z=8zE*1nm4sf@%@!?A$n$LlzoB-HT&JgQSzSedh9bQvd?) zZBS=2-AS0%UDhh4z9ulk;u>8ioq>d8KI4NYVbq7{&c)~yPxl?kmBMG1kx zoTm*#Iqfob3Dvbq2!z_wEiO7y6InX742P*@s)KMZ<9-{^EKSrhl!ib_l?}ia>y3(- zp|yw-4qFLAQIz@I`C!S`V}>_eZyq<*T0@sff3?P~fFdl_f$G9&Sv6KW`W=%9bZRbd z-o$yCy1edk^{zXwz2drQZMrm-joX!Y(dq&mH-30reB<9cKl|s-&{(QOQ7LBXrcGZS zJL8p?(~C~qok8{Z!M=FMHdc? z6n|~}>gMpT9;7;i*n0Sx0{l?Rp!J~3e^>vx_mRIo)G%%u0uiFzsHGVaN+b%3Ixl}e zmVewt6N^w3af?FSLn;cEsc5bD^1Z+Bm7yhaSK=qh@}vro;Jz1-?A?gQoj2v5Jep!-fDwNLN6h~9Uf2t*5j+LOKLD0_6rwoP#MB~ZTH zzk3!ie^AxtJ|waz#6ILp5+;DejX6{l;gW*euqtiD{UGTj_C zyqA6&NB??__1Ye@4+necc1aZ^s9o5wXcb6kP2p_nWNkwlTh@2U*Y0NeAO8Poz3aSa zS#vY`N3~kpBggWXod;XJp(Z{8pmc!>L zn{aZfWjQ=J?6PchB1E)iw>#0&YM8>9>Q7@#H6-vW^g%8UH_e}4`+xO(E*~>_7RjB6 zH`z{3$%fR~)N<1$ilwyXo`<^BgxawgYN&xKO+R_w=5o8+dGUhJf1dUs{`TRs|IhpN zvkrPH+EXdJ=%f@ajEg|DuEmJ6WiXkzyoXDbfJC6wAbnigOHp^~qi0`!P6ZHu`|#Pn zm;dHD8k0RW_*JbnG` z1%G}sQhS)SG4@`Uf0Ows?J1P)E#&<^c$X+HT9+&V@=4r`-AkHFi@)>ghcBM}y#fXC z*LNR$d({`8b#LzN`Eg`jTgReuoD*_Z3oW5QGEhOJc{gZ6BsC0@zs#$gqvh1;@O{<|M+!VX^zy>jMihEbVTe9YqMHow=nywOC$}{q&@0a#^+4F#BurV z$)le?r*adNpFDlo{~X_!j{bPsn*Uu!E%tR}u8h#MwX6alAk9lbl&RYOm-sI0OWl{> L2j4szy4nB$u^a>+ delta 3317 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 diff --git a/src/features/auth/schema/auth-schema.ts b/src/features/auth/schema/auth-schema.ts index baf136f..1260af1 100644 --- a/src/features/auth/schema/auth-schema.ts +++ b/src/features/auth/schema/auth-schema.ts @@ -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() diff --git a/src/features/game/components/desktop/desktop-animal-overlay.tsx b/src/features/game/components/desktop/desktop-animal-overlay.tsx new file mode 100644 index 0000000..62de547 --- /dev/null +++ b/src/features/game/components/desktop/desktop-animal-overlay.tsx @@ -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 ( +
+ {noBetText} +
+ ) + } + + return ( +
+ {items.map((item) => ( +
+
+ {item.imageUrl ? ( + + ) : null} +
+
+
+ {String(item.cellId).padStart(2, '0')} +
+
+ + + {formatStopBetAmount(item.amount)} + +
+
+
+ ))} +
+ ) +} + +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('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 ( +
+ + ) + } + + if (revealPhase === 'result' && hasRenderedReward) { + return ( + diff --git a/src/features/game/components/desktop/desktop-reward-overlay.tsx b/src/features/game/components/desktop/desktop-reward-overlay.tsx deleted file mode 100644 index 8660128..0000000 --- a/src/features/game/components/desktop/desktop-reward-overlay.tsx +++ /dev/null @@ -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('hidden') - const [displayRewardAmount, setDisplayRewardAmount] = useState('0') - const rewardAmountMeta = useMemo( - () => getAmountMeta(rewardAmount), - [rewardAmount], - ) - const source = useMemo(() => { - 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 ( - -
- - -
- {displayRewardAmount} -
-
-
-
- ) -} - -export default DesktopRewardOverlay diff --git a/src/features/game/components/desktop/desktop-status.tsx b/src/features/game/components/desktop/desktop-status.tsx index 52b37e6..e75d2f1 100644 --- a/src/features/game/components/desktop/desktop-status.tsx +++ b/src/features/game/components/desktop/desktop-status.tsx @@ -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} />
-
-
-
-
-
- {phaseLabel} -
+
+
+ + +
+ {phaseLabel}
-
- {t('gameDesktop.status.roundId')}:{roundId} +
+ + {t('gameDesktop.status.roundId')}: + + + {roundId} +
diff --git a/src/features/game/components/shared/round-betting-start-alert.tsx b/src/features/game/components/shared/round-betting-start-alert.tsx new file mode 100644 index 0000000..8ecfbd9 --- /dev/null +++ b/src/features/game/components/shared/round-betting-start-alert.tsx @@ -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(null) + const [visibleRoundId, setVisibleRoundId] = useState(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 ( + + {visibleRoundId ? ( + + + + + + ) : null} + + ) +} diff --git a/src/features/game/entry/mobile-entry.tsx b/src/features/game/entry/mobile-entry.tsx index 0348e70..ec0936b 100644 --- a/src/features/game/entry/mobile-entry.tsx +++ b/src/features/game/entry/mobile-entry.tsx @@ -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 ( <> + ) } diff --git a/src/features/game/entry/pc-entry.tsx b/src/features/game/entry/pc-entry.tsx index fc34316..aea7873 100644 --- a/src/features/game/entry/pc-entry.tsx +++ b/src/features/game/entry/pc-entry.tsx @@ -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() { {/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */} - {/* 大奖/小奖动画展示 */} - {/* 强制弹窗 */} diff --git a/src/features/game/hooks/use-animal-vm.ts b/src/features/game/hooks/use-animal-vm.ts index 45ecefc..5c904a3 100644 --- a/src/features/game/hooks/use-animal-vm.ts +++ b/src/features/game/hooks/use-animal-vm.ts @@ -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, diff --git a/src/features/game/hooks/use-auto-hosting-runner.ts b/src/features/game/hooks/use-auto-hosting-runner.ts index b4f7fdb..d97469c 100644 --- a/src/features/game/hooks/use-auto-hosting-runner.ts +++ b/src/features/game/hooks/use-auto-hosting-runner.ts @@ -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, ]) } diff --git a/src/features/game/hooks/use-game-control-vm.ts b/src/features/game/hooks/use-game-control-vm.ts index 0f21b5d..8a32dde 100644 --- a/src/features/game/hooks/use-game-control-vm.ts +++ b/src/features/game/hooks/use-game-control-vm.ts @@ -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, diff --git a/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx b/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx index f367d36..817a274 100644 --- a/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx +++ b/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx @@ -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 diff --git a/src/locales/en-US/common.ts b/src/locales/en-US/common.ts index a505e3b..2644ffe 100644 --- a/src/locales/en-US/common.ts +++ b/src/locales/en-US/common.ts @@ -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', diff --git a/src/locales/id-ID/common.ts b/src/locales/id-ID/common.ts index 795507b..8418b8f 100644 --- a/src/locales/id-ID/common.ts +++ b/src/locales/id-ID/common.ts @@ -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', diff --git a/src/locales/ms-MY/common.ts b/src/locales/ms-MY/common.ts index 05d2db1..59bc99d 100644 --- a/src/locales/ms-MY/common.ts +++ b/src/locales/ms-MY/common.ts @@ -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', diff --git a/src/locales/zh-CN/common.ts b/src/locales/zh-CN/common.ts index 883a9a2..e21951e 100644 --- a/src/locales/zh-CN/common.ts +++ b/src/locales/zh-CN/common.ts @@ -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: '中奖字花',