feat(game): 添加游戏桌面组件的警告提示和动画效果

- 在动物游戏中添加余额不足和选择限制的警告提示
- 为警告状态添加震动动画和视觉反馈效果
- 实现倒计时警告状态的动画和样式变化
- 添加胜利和失败状态的历史记录显示
- 为筹码和操作按钮添加禁用状态的视觉效果
- 实现用户信息和流程按钮的交互功能
- 添加自动设置弹窗的打开功能
- 优化游戏阶段切换时的选择清空逻辑
- 添加剩余时间变化的回调函数支持
- 为历史记录添加中奖状态的颜色标识
This commit is contained in:
JiaJun
2026-05-18 18:01:33 +08:00
parent 85b4d9481f
commit 6ac42cf35e
15 changed files with 1125 additions and 91 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,5 @@
import { TriangleAlert } from 'lucide-react'
import { motion } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import diamondIcon from '@/assets/system/diamond.webp'
@@ -5,7 +7,11 @@ import { SmartImage } from '@/components/smart-image'
import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils'
import { useAudioStore, useAuthStore, useModalStore } from '@/store'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
import {
selectSelectionTotal,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
@@ -44,17 +50,22 @@ function getNextMarqueeId(currentId: number | null) {
return nextId
}
function formatSelectedLog(
selectionByCell: Record<number, { amount: number; count: number }>,
) {
return Object.entries(selectionByCell)
.map(([cellId, value]) => ({
字花: String(cellId).padStart(2, '0'),
筹码: value.amount,
}))
.sort((left, right) => Number(left.) - Number(right.))
function parseBalance(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== 'string') {
return 0
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
type CellWarningType = 'balance' | 'limit'
interface DesktopAnimalProps {
activeId?: number | null
className?: string
@@ -72,6 +83,7 @@ export function DesktopAnimal({
}: DesktopAnimalProps) {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const currentUser = useAuthStore((state) => state.currentUser)
const markSoundPlaybackUnlocked = useAudioStore(
(state) => state.markSoundPlaybackUnlocked,
)
@@ -87,6 +99,7 @@ export function DesktopAnimal({
(state) => state.removeSelectionsForCell,
)
const selections = useGameRoundStore((state) => state.selections)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const connection = useGameSessionStore((state) => state.connection)
const requestRealtimeConnection = useGameSessionStore(
(state) => state.requestRealtimeConnection,
@@ -97,10 +110,15 @@ export function DesktopAnimal({
const [marqueeId, setMarqueeId] = useState<number | null>(() =>
getNextMarqueeId(null),
)
const [cellWarning, setCellWarning] = useState<{
cellId: number
type: CellWarningType
} | null>(null)
const activeChip = useMemo(
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
[activeChipId, chips],
)
const balance = parseBalance(currentUser?.coin)
const selectionByCell = useMemo(() => {
return selections.reduce<Record<number, { amount: number; count: number }>>(
(accumulator, selection) => {
@@ -150,30 +168,48 @@ export function DesktopAnimal({
}
if (isSelectedCell(animalId)) {
const nextSelectionByCell = { ...selectionByCell }
delete nextSelectionByCell[animalId]
console.log('已选', formatSelectedLog(nextSelectionByCell))
removeSelectionsForCell(animalId)
return
}
if (selectedCellCount >= maxSelectionCount) {
setCellWarning({
cellId: animalId,
type: 'limit',
})
return
}
if (totalBetAmount + (activeChip?.amount ?? 0) > balance) {
setCellWarning({
cellId: animalId,
type: 'balance',
})
return
}
console.log(
'已选',
formatSelectedLog({
...selectionByCell,
[animalId]: {
amount: activeChip?.amount ?? 0,
count: 1,
},
}),
)
placeBet(animalId)
}
useEffect(() => {
if (cellWarning === null) {
return
}
const timerId = window.setTimeout(() => {
setCellWarning((currentWarning) =>
currentWarning?.cellId === cellWarning.cellId &&
currentWarning.type === cellWarning.type
? null
: currentWarning,
)
}, 1200)
return () => {
window.clearTimeout(timerId)
}
}, [cellWarning])
useEffect(() => {
if (!showStandbyState) {
setMarqueeId(null)
@@ -208,13 +244,41 @@ export function DesktopAnimal({
const hasPlacedSelection = Boolean(selectionMeta)
const isActive = item.id === activeId || hasPlacedSelection
const isMarqueeActive = showStandbyState && item.id === marqueeId
const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null
const warningLabel =
warningType === 'balance'
? t('gameDesktop.animal.insufficientBalanceRecharge')
: t('gameDesktop.animal.selectionLimitReached')
return (
<button
<motion.button
key={item.id}
type="button"
disabled={lockInteraction}
onClick={() => handleSelect(item.id)}
animate={
showCellWarning
? {
rotate: [0, -1.8, 1.4, -1, 0.6, 0],
scale: [1, 1.015, 0.992, 1.01, 1],
x: [0, -2, 2, -1, 1, 0],
}
: {
rotate: 0,
scale: 1,
x: 0,
}
}
transition={
showCellWarning
? {
duration: 0.46,
ease: 'easeInOut',
}
: { duration: 0.16, ease: 'easeOut' }
}
className={cn(
'relative flex flex-col items-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
lockInteraction
@@ -224,18 +288,37 @@ export function DesktopAnimal({
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
isActive &&
'border-[rgba(255,187,61,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,175,52,0.82),0_0_calc(var(--design-unit)*30)_rgba(255,151,15,0.46),inset_0_0_calc(var(--design-unit)*20)_rgba(255,177,70,0.58)]',
showCellWarning &&
'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]',
!showStandbyState && !hasPlacedSelection && 'opacity-95',
itemClassName,
)}
>
<span
<motion.span
aria-hidden="true"
animate={
showCellWarning
? {
opacity: [0.45, 1, 0.6, 1, 0.82],
}
: undefined
}
transition={
showCellWarning
? {
duration: 0.52,
ease: 'easeInOut',
}
: undefined
}
className={cn(
'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
isMarqueeActive &&
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
isActive &&
'bg-[radial-gradient(circle_at_center,rgba(255,207,116,0.42)_0%,rgba(255,181,61,0.16)_42%,transparent_74%)] opacity-100',
showCellWarning &&
'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100',
)}
/>
{!showStandbyState && !hasPlacedSelection ? (
@@ -252,6 +335,43 @@ export function DesktopAnimal({
imageClassName,
)}
/>
{showCellWarning ? (
<motion.span
initial={{ opacity: 0, scale: 0.94 }}
animate={{
opacity: [0.2, 1, 0.92],
scale: [0.96, 1.02, 1],
boxShadow: [
'inset 0 0 calc(var(--design-unit)*10) rgba(255,108,108,0.16), 0 0 calc(var(--design-unit)*8) rgba(255,60,60,0.12)',
'inset 0 0 calc(var(--design-unit)*20) rgba(255,108,108,0.28), 0 0 calc(var(--design-unit)*20) rgba(255,60,60,0.28)',
'inset 0 0 calc(var(--design-unit)*16) rgba(255,108,108,0.22), 0 0 calc(var(--design-unit)*18) rgba(255,60,60,0.2)',
],
}}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="pointer-events-none absolute inset-[calc(var(--design-unit)*3)] z-30 flex flex-col items-center justify-center gap-design-8 rounded-[calc(var(--design-unit)*15)] border border-[rgba(255,126,126,0.9)] bg-[rgba(61,0,0,0.58)] px-design-10 py-design-10 text-center"
>
<motion.span
animate={{
opacity: [0.72, 1, 0.92],
scale: [0.94, 1.08, 1],
}}
transition={{ duration: 0.38, ease: 'easeOut' }}
className="flex h-design-28 w-design-28 items-center justify-center"
>
<TriangleAlert className="h-design-28 w-design-28 text-[#FFD0D0] drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(255,92,92,0.45)]" />
</motion.span>
<motion.span
animate={{
opacity: [0.7, 1, 0.96],
scale: [0.98, 1.03, 1],
}}
transition={{ duration: 0.42, ease: 'easeOut' }}
className="text-design-16 font-bold leading-tight tracking-[0.04em] text-[#FFE0E0] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,132,132,0.42)]"
>
{warningLabel}
</motion.span>
</motion.span>
) : null}
{hasPlacedSelection ? (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="flex min-w-design-96 items-center justify-center gap-design-4 rounded-full border border-[rgba(162,242,255,0.48)] bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-10 py-design-6 shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.18)]">
@@ -266,7 +386,7 @@ export function DesktopAnimal({
</span>
</span>
) : null}
</button>
</motion.button>
)
})}

View File

@@ -5,6 +5,7 @@ import add 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'
import chipLock from '@/assets/game/chip-lock.webp'
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'
@@ -20,6 +21,8 @@ import { cn } from '@/lib/utils'
export function DesktopControl() {
const { t } = useTranslation()
const {
acceptingBets,
actionsEnabled,
canClear,
chips,
confirmLabel,
@@ -29,6 +32,7 @@ export function DesktopControl() {
onChipSelect,
onConfirm,
onClearSelections,
onOpenAutoSetting,
onRepeatSelections,
selectedChipAmountLabel,
selectedChipId,
@@ -40,11 +44,19 @@ export function DesktopControl() {
const [confirmClicked, setConfirmClicked] = useState(false)
const handleChipClick = (chipId: string) => {
if (!acceptingBets) {
return
}
onChipSelect(chipId)
}
const handleActionClick = useCallback(
(id: string) => {
if (!actionsEnabled) {
return
}
if (id === 'clear' && canClear) {
onClearSelections()
}
@@ -53,6 +65,10 @@ export function DesktopControl() {
onRepeatSelections()
}
if (id === 'auto-spin') {
onOpenAutoSetting()
}
setClickedId(id)
setTimeout(() => {
setClickedId(null)
@@ -62,7 +78,13 @@ export function DesktopControl() {
}, 180)
}, 200)
},
[canClear, onClearSelections, onRepeatSelections],
[
actionsEnabled,
canClear,
onClearSelections,
onOpenAutoSetting,
onRepeatSelections,
],
)
const handleConfirmClick = useCallback(() => {
@@ -117,6 +139,7 @@ export function DesktopControl() {
>
{chips.map((chip) => {
const isSelected = chip.id === selectedChipId
const showLockedState = !acceptingBets
return (
<motion.button
@@ -124,7 +147,8 @@ export function DesktopControl() {
layout
type="button"
onClick={() => handleChipClick(chip.id)}
whileTap={{ scale: 0.94 }}
disabled={showLockedState}
whileTap={showLockedState ? undefined : { scale: 0.94 }}
transition={{
layout: {
type: 'spring',
@@ -134,12 +158,20 @@ export function DesktopControl() {
duration: 0.26,
}}
className={
'relative flex h-design-70 w-design-70 shrink-0 cursor-pointer items-center justify-center rounded-full'
'relative flex h-design-70 w-design-70 shrink-0 items-center justify-center rounded-full'
}
style={
showLockedState
? {
WebkitFilter: 'grayscale(100%)',
filter: 'grayscale(100%)',
}
: undefined
}
>
<motion.span
animate={
isSelected
isSelected && !showLockedState
? {
opacity: [0.22, 0.5, 0.22],
scaleX: [0.82, 1.02, 0.82],
@@ -166,7 +198,7 @@ export function DesktopControl() {
/>
<motion.span
animate={
isSelected
isSelected && !showLockedState
? {
opacity: [0.72, 1, 0.72],
scale: [0.96, 1.04, 0.96],
@@ -198,7 +230,7 @@ export function DesktopControl() {
<motion.div
layout
animate={
isSelected
isSelected && !showLockedState
? {
y: [-1, -4, -1],
scale: [1.04, 1.1, 1.04],
@@ -211,8 +243,9 @@ export function DesktopControl() {
: {
y: 0,
scale: 1,
filter:
'drop-shadow(0 6px 10px rgba(0,0,0,0.34)) drop-shadow(0 2px 4px rgba(255,255,255,0.08))',
filter: showLockedState
? 'none'
: 'drop-shadow(0 6px 10px rgba(0,0,0,0.34)) drop-shadow(0 2px 4px rgba(255,255,255,0.08))',
}
}
transition={{ type: 'spring', stiffness: 380, damping: 24 }}
@@ -224,6 +257,14 @@ export function DesktopControl() {
draggable={false}
className={'h-design-70 w-design-70 object-contain'}
/>
{showLockedState && (
<img
src={chipLock}
alt="chip-locked"
draggable={false}
className="pointer-events-none absolute left-1/2 top-1/2 z-[12] h-design-54 w-design-54 -translate-x-1/2 -translate-y-1/2 object-contain"
/>
)}
<span
className={
'pointer-events-none absolute inset-x-0 top-1/2 z-[8] -translate-y-[calc(50%-1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(96,54,0,0.85)] blur-[1px]'
@@ -304,21 +345,31 @@ export function DesktopControl() {
className={cn(
'desktop-control-actions relative z-10 flex h-full w-design-385 shrink-0 items-center bg-center bg-no-repeat pl-design-15',
)}
style={
!actionsEnabled
? {
WebkitFilter: 'grayscale(100%)',
filter: 'grayscale(100%)',
}
: undefined
}
>
{ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
const isClicked = clickedId === id
const isHiding = hidingId === id
const showBg = isClicked || isHiding
const showBg = actionsEnabled && (isClicked || isHiding)
return (
<motion.button
key={id}
type="button"
disabled={!actionsEnabled}
onClick={() => handleActionClick(id)}
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.96 }}
whileHover={actionsEnabled ? { y: -1, scale: 1.01 } : undefined}
whileTap={actionsEnabled ? { scale: 0.96 } : undefined}
className={cn(
'relative flex h-full flex-1 cursor-pointer items-center justify-center overflow-hidden',
'relative flex h-full flex-1 items-center justify-center overflow-hidden',
actionsEnabled ? 'cursor-pointer' : 'cursor-not-allowed',
{ '-translate-x-1.5': id === 'auto-spin' },
)}
>

View File

@@ -19,6 +19,7 @@ interface DesktopCountdownProps {
initialMs?: number
initialSeconds?: number
onComplete?: () => void
onRemainingMsChange?: (remainingMs: number) => void
}
export function DesktopCountdown({
@@ -26,6 +27,7 @@ export function DesktopCountdown({
initialMs,
initialSeconds,
onComplete,
onRemainingMsChange,
}: DesktopCountdownProps) {
const initialCountdownMs = useMemo(() => {
if (typeof initialMs === 'number') {
@@ -43,7 +45,8 @@ export function DesktopCountdown({
useEffect(() => {
setRemainingMs(initialCountdownMs)
}, [initialCountdownMs])
onRemainingMsChange?.(initialCountdownMs)
}, [initialCountdownMs, onRemainingMsChange])
useEffect(() => {
if (initialCountdownMs <= 0) {
@@ -58,6 +61,7 @@ export function DesktopCountdown({
const nextRemainingMs = Math.max(0, initialCountdownMs - elapsedMs)
setRemainingMs(nextRemainingMs)
onRemainingMsChange?.(nextRemainingMs)
if (nextRemainingMs === 0) {
window.clearInterval(timer)
@@ -68,12 +72,12 @@ export function DesktopCountdown({
return () => {
window.clearInterval(timer)
}
}, [initialCountdownMs, onComplete])
}, [initialCountdownMs, onComplete, onRemainingMsChange])
return (
<div
className={cn(
'font-countdown text-design-48 leading-none tracking-[0.08em] text-[#4BFFFE]',
'relative z-10 flex items-center justify-center font-countdown text-design-48 leading-none tracking-[0.08em] text-[#4BFFFE]',
className,
)}
>

View File

@@ -80,23 +80,23 @@ export function DesktopGameHistory() {
}
>
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
className="common-neon-inset w-full !rounded-b-none text-center text-design-20 font-bold tracking-[0.08em]"
style={{
color: item.isWin ? '#FFE375' : '#8DFF98',
textShadow: item.isWin
? '0 0 calc(var(--design-unit)*10) #FFE375, 0 0 calc(var(--design-unit)*22) rgba(255,227,117,0.48)'
: '0 0 calc(var(--design-unit)*10) #8DFF98, 0 0 calc(var(--design-unit)*22) rgba(141,255,152,0.48)',
}}
>
{item.statusLabel}
{item.isWin
? t('gameDesktop.history.win')
: t('gameDesktop.history.lost')}
</div>
<div
className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
}
>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.orderNo')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>{item.orderNo}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.roundId')}:{' '}
@@ -109,12 +109,6 @@ export function DesktopGameHistory() {
</span>
<span>{item.numbersLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.settledAt')}:{' '}
</span>
<span>{item.createdAtLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.totalPoolAmount')}:{' '}
@@ -131,12 +125,6 @@ export function DesktopGameHistory() {
{item.resultNumberLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.payout')}:{' '}
</span>
<span>{item.winAmountLabel}</span>
</div>
</div>
</div>
</div>

View File

@@ -173,6 +173,12 @@ export function DesktopHeader() {
const connection = useGameSessionStore((state) => state.connection)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
const handleOpenUserInfo = () => {
setModalOpen('desktopUserInfo', true)
}
const handleOpenProcedures = () => {
setModalOpen('desktopProcedures', true)
}
const serverClockOffsetMs = useMemo(() => {
if (
@@ -368,7 +374,11 @@ export function DesktopHeader() {
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10'
}
>
<div className={'relative flex items-center justify-center'}>
<button
type="button"
onClick={handleOpenUserInfo}
className="group relative flex items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
>
<SmartImage
src={avatar}
alt="avatar"
@@ -377,14 +387,18 @@ export function DesktopHeader() {
/>
<div
className={
'common-neon-inset text-design-16 !py-design-20 flex h-design-36 w-design-180 items-center justify-end'
'common-neon-inset text-design-16 !py-design-20 flex h-design-36 w-design-180 items-center justify-end transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
}
>
{currentUser?.username || '--'}
</div>
</div>
</button>
<div className={'relative flex items-center justify-center'}>
<button
type="button"
onClick={handleOpenProcedures}
className="group relative flex items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
>
<SmartImage
src={diamond}
alt="diamond"
@@ -393,12 +407,12 @@ export function DesktopHeader() {
/>
<div
className={
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end'
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-design-180 items-center justify-end transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
}
>
{currentUser?.coin || '--'}
</div>
</div>
</button>
</div>
) : (
<div

View File

@@ -1,6 +1,9 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import down5Animation from '@/assets/lottie/down5.json'
import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp'
import { LottiePlayer } from '@/components/lottie-player.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
@@ -18,6 +21,15 @@ export function DesktopStatusLine() {
roundId,
streakLabel,
} = useGameStatusVm()
const [remainingMs, setRemainingMs] = useState(countdownMs)
const showWarningCountdown = remainingMs <= 5000 && remainingMs > 0
const countdownClassName = useMemo(
() =>
showWarningCountdown
? 'text-[#FF5A5A] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,90,90,0.85),0_0_calc(var(--design-unit)*22)_rgba(255,90,90,0.32)]'
: 'text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(75,255,254,0.85),0_0_calc(var(--design-unit)*22)_rgba(75,255,254,0.32)]',
[showWarningCountdown],
)
return (
<div className={'relative w-full flex flex-col text-design-22'}>
@@ -39,18 +51,38 @@ export function DesktopStatusLine() {
{t('gameDesktop.status.limit')}: {limitLabel}
</div>
</div>
<SmartBackground
src={statusCenter}
className="w-design-360 h-[105px] font-countdown bg-no-repeat bg-center bg-contain flex items-center justify-center"
size="contain"
>
<DesktopCountdown
initialMs={countdownMs}
onComplete={() => {
console.log('countdown finished')
<div className="relative flex h-[105px] w-design-360 items-center justify-center">
<SmartBackground
src={statusCenter}
className="pointer-events-none absolute inset-0 z-0 bg-no-repeat bg-center bg-contain transition-opacity duration-500 ease-out"
size="contain"
style={{
opacity: showWarningCountdown ? 0.18 : 1,
transform: showWarningCountdown ? 'scale(0.985)' : 'scale(1)',
}}
/>
</SmartBackground>
<div
className="pointer-events-none absolute inset-0 z-0 overflow-visible transition-all duration-500 ease-out"
style={{
opacity: showWarningCountdown ? 1 : 0,
transform: showWarningCountdown
? 'scale(1.1) translateY(calc(var(--design-unit)*1))'
: 'scale(1.02) translateY(0)',
}}
>
<LottiePlayer
animationData={down5Animation}
className="h-full w-full"
loop
autoplay
/>
</div>
<DesktopCountdown
initialMs={countdownMs}
onRemainingMsChange={setRemainingMs}
className={countdownClassName}
/>
</div>
<div className={'flex-1 flex items-center justify-center gap-10'}>
<div>
{t('gameDesktop.status.roundId')}:{roundId}

View File

@@ -4,7 +4,11 @@ import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
import { placeGameBet } from '@/features/game'
import { notify } from '@/lib/notify'
import { useAuthStore, useModalStore } from '@/store'
import { selectSelectionTotal, useGameRoundStore } from '@/store/game'
import {
selectSelectionTotal,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'submitting'
@@ -71,6 +75,12 @@ export function useGameControlVm() {
)
const selectChip = useGameRoundStore((state) => state.selectChip)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const connectionStatus = useGameSessionStore(
(state) => state.connection.status,
)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const authStatus = useAuthStore((state) => state.status)
const currentUser = useAuthStore((state) => state.currentUser)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
@@ -99,6 +109,8 @@ export function useGameControlVm() {
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
const balance = parseBalance(currentUser?.coin)
const hasSelections = selections.length > 0
const hasEnteredGame =
shouldConnectRealtime && connectionStatus === 'connected'
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
const confirmState: ConfirmState = isSubmitting
? 'submitting'
@@ -235,7 +247,13 @@ export function useGameControlVm() {
notify.success(t('commonUi.toast.repeatSelectionsRestored'))
}, [restoreRecentSuccessfulSelections, round.phase, t])
const handleOpenAutoSetting = useCallback(() => {
setModalOpen('desktopAutoSetting', true)
}, [setModalOpen])
return {
acceptingBets: round.phase === 'betting',
actionsEnabled: hasEnteredGame,
canClear: selections.length > 0,
confirmLabel:
confirmState === 'idle'
@@ -250,6 +268,7 @@ export function useGameControlVm() {
onChipSelect: selectChip,
onConfirm: handleConfirm,
onClearSelections: clearSelections,
onOpenAutoSetting: handleOpenAutoSetting,
onRepeatSelections: handleRepeatSelections,
maxSelectionCountLabel: maxSelectionCount,
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',

View File

@@ -69,14 +69,17 @@ export function useGameHistoryVm() {
i18n.resolvedLanguage ?? 'en-US',
),
id: entry.order_no,
isWin:
entry.result_number !== null &&
entry.numbers.includes(entry.result_number),
numbersLabel: formatNumbers(entry.numbers),
numbers: entry.numbers,
orderNo: entry.order_no,
periodNo: entry.period_no,
resultNumberLabel:
entry.result_number === null
? '--'
: String(entry.result_number).padStart(2, '0'),
statusLabel: entry.status,
winAmountLabel: entry.win_amount,
})),
),

View File

@@ -362,12 +362,16 @@ export default {
announcement: 'Announcement',
},
animal: {
insufficientBalanceRecharge: 'Insufficient balance, please top up',
loading: 'Loading',
selectionLimitReached: 'Selection limit exceeded',
tapToEnter: 'Tap To Enter',
getStart: 'Get Start',
},
history: {
title: 'History',
win: 'WIN',
lost: 'LOST',
orderNo: 'Order No.',
roundId: 'Round ID',
numbers: 'Bet Numbers',

View File

@@ -361,12 +361,16 @@ export default {
announcement: 'Pengumuman',
},
animal: {
insufficientBalanceRecharge: 'Saldo tidak cukup, silakan isi ulang',
loading: 'Memuat',
selectionLimitReached: 'Melebihi pilihan yang diizinkan',
tapToEnter: 'Ketuk Untuk Masuk',
getStart: 'Mulai',
},
history: {
title: 'Riwayat',
win: 'WIN',
lost: 'LOST',
orderNo: 'No. Order',
roundId: 'ID Ronde',
numbers: 'Nomor Taruhan',

View File

@@ -365,12 +365,16 @@ export default {
announcement: 'Pengumuman',
},
animal: {
insufficientBalanceRecharge: 'Baki tidak mencukupi, sila tambah nilai',
loading: 'Memuatkan',
selectionLimitReached: 'Melebihi pilihan aksara yang dibenarkan',
tapToEnter: 'Ketik Untuk Masuk',
getStart: 'Mula',
},
history: {
title: 'Sejarah',
win: 'WIN',
lost: 'LOST',
orderNo: 'No. Pesanan',
roundId: 'ID Pusingan',
numbers: 'Nombor Pertaruhan',

View File

@@ -352,12 +352,16 @@ export default {
announcement: '公告栏',
},
animal: {
insufficientBalanceRecharge: '余额不足,请充值',
loading: '加载中',
selectionLimitReached: '超过可选择字花',
tapToEnter: '点击进入',
getStart: '开始游戏',
},
history: {
title: '历史记录',
win: 'WIN',
lost: 'LOST',
orderNo: '订单号',
roundId: '期号',
numbers: '下注号码',

View File

@@ -203,20 +203,38 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
})
},
setPhase: (phase) => {
set((state) => ({
round: {
...state.round,
phase,
},
}))
set((state) => {
const nextState: Partial<GameRoundStoreState> = {
round: {
...state.round,
phase,
},
}
if (phase === 'settled') {
nextState.selections = []
}
return nextState
})
},
syncRound: (round) => {
set((state) => ({
round: {
set((state) => {
const nextRound = {
...state.round,
...round,
},
}))
}
const nextState: Partial<GameRoundStoreState> = {
round: nextRound,
}
if (nextRound.phase === 'settled') {
nextState.selections = []
}
return nextState
})
},
upsertSelections: (selections) => {
set({ selections })