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 { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import diamondIcon from '@/assets/system/diamond.webp' import diamondIcon from '@/assets/system/diamond.webp'
@@ -5,7 +7,11 @@ import { SmartImage } from '@/components/smart-image'
import { notify } from '@/lib/notify' import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAudioStore, useAuthStore, useModalStore } from '@/store' 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', { const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true, eager: true,
@@ -44,17 +50,22 @@ function getNextMarqueeId(currentId: number | null) {
return nextId return nextId
} }
function formatSelectedLog( function parseBalance(value: string | number | null | undefined) {
selectionByCell: Record<number, { amount: number; count: number }>, if (typeof value === 'number') {
) { return Number.isFinite(value) ? value : 0
return Object.entries(selectionByCell) }
.map(([cellId, value]) => ({
字花: String(cellId).padStart(2, '0'), if (typeof value !== 'string') {
筹码: value.amount, return 0
})) }
.sort((left, right) => Number(left.) - Number(right.))
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
} }
type CellWarningType = 'balance' | 'limit'
interface DesktopAnimalProps { interface DesktopAnimalProps {
activeId?: number | null activeId?: number | null
className?: string className?: string
@@ -72,6 +83,7 @@ export function DesktopAnimal({
}: DesktopAnimalProps) { }: DesktopAnimalProps) {
const { t } = useTranslation() const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status) const authStatus = useAuthStore((state) => state.status)
const currentUser = useAuthStore((state) => state.currentUser)
const markSoundPlaybackUnlocked = useAudioStore( const markSoundPlaybackUnlocked = useAudioStore(
(state) => state.markSoundPlaybackUnlocked, (state) => state.markSoundPlaybackUnlocked,
) )
@@ -87,6 +99,7 @@ export function DesktopAnimal({
(state) => state.removeSelectionsForCell, (state) => state.removeSelectionsForCell,
) )
const selections = useGameRoundStore((state) => state.selections) const selections = useGameRoundStore((state) => state.selections)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const connection = useGameSessionStore((state) => state.connection) const connection = useGameSessionStore((state) => state.connection)
const requestRealtimeConnection = useGameSessionStore( const requestRealtimeConnection = useGameSessionStore(
(state) => state.requestRealtimeConnection, (state) => state.requestRealtimeConnection,
@@ -97,10 +110,15 @@ export function DesktopAnimal({
const [marqueeId, setMarqueeId] = useState<number | null>(() => const [marqueeId, setMarqueeId] = useState<number | null>(() =>
getNextMarqueeId(null), getNextMarqueeId(null),
) )
const [cellWarning, setCellWarning] = useState<{
cellId: number
type: CellWarningType
} | null>(null)
const activeChip = useMemo( const activeChip = useMemo(
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null, () => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
[activeChipId, chips], [activeChipId, chips],
) )
const balance = parseBalance(currentUser?.coin)
const selectionByCell = useMemo(() => { const selectionByCell = useMemo(() => {
return selections.reduce<Record<number, { amount: number; count: number }>>( return selections.reduce<Record<number, { amount: number; count: number }>>(
(accumulator, selection) => { (accumulator, selection) => {
@@ -150,30 +168,48 @@ export function DesktopAnimal({
} }
if (isSelectedCell(animalId)) { if (isSelectedCell(animalId)) {
const nextSelectionByCell = { ...selectionByCell }
delete nextSelectionByCell[animalId]
console.log('已选', formatSelectedLog(nextSelectionByCell))
removeSelectionsForCell(animalId) removeSelectionsForCell(animalId)
return return
} }
if (selectedCellCount >= maxSelectionCount) { if (selectedCellCount >= maxSelectionCount) {
setCellWarning({
cellId: animalId,
type: 'limit',
})
return
}
if (totalBetAmount + (activeChip?.amount ?? 0) > balance) {
setCellWarning({
cellId: animalId,
type: 'balance',
})
return return
} }
console.log(
'已选',
formatSelectedLog({
...selectionByCell,
[animalId]: {
amount: activeChip?.amount ?? 0,
count: 1,
},
}),
)
placeBet(animalId) 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(() => { useEffect(() => {
if (!showStandbyState) { if (!showStandbyState) {
setMarqueeId(null) setMarqueeId(null)
@@ -208,13 +244,41 @@ export function DesktopAnimal({
const hasPlacedSelection = Boolean(selectionMeta) const hasPlacedSelection = Boolean(selectionMeta)
const isActive = item.id === activeId || hasPlacedSelection const isActive = item.id === activeId || hasPlacedSelection
const isMarqueeActive = showStandbyState && item.id === marqueeId 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 ( return (
<button <motion.button
key={item.id} key={item.id}
type="button" type="button"
disabled={lockInteraction} disabled={lockInteraction}
onClick={() => handleSelect(item.id)} 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( 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', '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 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)]', '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 && 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)]', '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', !showStandbyState && !hasPlacedSelection && 'opacity-95',
itemClassName, itemClassName,
)} )}
> >
<span <motion.span
aria-hidden="true" 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( className={cn(
'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150', 'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
isMarqueeActive && 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)]', '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 && 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', '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 ? ( {!showStandbyState && !hasPlacedSelection ? (
@@ -252,6 +335,43 @@ export function DesktopAnimal({
imageClassName, 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 ? ( {hasPlacedSelection ? (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"> <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)]"> <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>
</span> </span>
) : null} ) : 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 arrow from '@/assets/game/arrow.webp'
import chipBg from '@/assets/game/chip-bg.webp' import chipBg from '@/assets/game/chip-bg.webp'
import chipLineBg from '@/assets/game/chip-line-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 confirmBg from '@/assets/game/confirm-bg.webp'
import confirmRedBg from '@/assets/game/confirm-red-bg.png' import confirmRedBg from '@/assets/game/confirm-red-bg.png'
import controlBg from '@/assets/game/control-bg.png' import controlBg from '@/assets/game/control-bg.png'
@@ -20,6 +21,8 @@ import { cn } from '@/lib/utils'
export function DesktopControl() { export function DesktopControl() {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
acceptingBets,
actionsEnabled,
canClear, canClear,
chips, chips,
confirmLabel, confirmLabel,
@@ -29,6 +32,7 @@ export function DesktopControl() {
onChipSelect, onChipSelect,
onConfirm, onConfirm,
onClearSelections, onClearSelections,
onOpenAutoSetting,
onRepeatSelections, onRepeatSelections,
selectedChipAmountLabel, selectedChipAmountLabel,
selectedChipId, selectedChipId,
@@ -40,11 +44,19 @@ export function DesktopControl() {
const [confirmClicked, setConfirmClicked] = useState(false) const [confirmClicked, setConfirmClicked] = useState(false)
const handleChipClick = (chipId: string) => { const handleChipClick = (chipId: string) => {
if (!acceptingBets) {
return
}
onChipSelect(chipId) onChipSelect(chipId)
} }
const handleActionClick = useCallback( const handleActionClick = useCallback(
(id: string) => { (id: string) => {
if (!actionsEnabled) {
return
}
if (id === 'clear' && canClear) { if (id === 'clear' && canClear) {
onClearSelections() onClearSelections()
} }
@@ -53,6 +65,10 @@ export function DesktopControl() {
onRepeatSelections() onRepeatSelections()
} }
if (id === 'auto-spin') {
onOpenAutoSetting()
}
setClickedId(id) setClickedId(id)
setTimeout(() => { setTimeout(() => {
setClickedId(null) setClickedId(null)
@@ -62,7 +78,13 @@ export function DesktopControl() {
}, 180) }, 180)
}, 200) }, 200)
}, },
[canClear, onClearSelections, onRepeatSelections], [
actionsEnabled,
canClear,
onClearSelections,
onOpenAutoSetting,
onRepeatSelections,
],
) )
const handleConfirmClick = useCallback(() => { const handleConfirmClick = useCallback(() => {
@@ -117,6 +139,7 @@ export function DesktopControl() {
> >
{chips.map((chip) => { {chips.map((chip) => {
const isSelected = chip.id === selectedChipId const isSelected = chip.id === selectedChipId
const showLockedState = !acceptingBets
return ( return (
<motion.button <motion.button
@@ -124,7 +147,8 @@ export function DesktopControl() {
layout layout
type="button" type="button"
onClick={() => handleChipClick(chip.id)} onClick={() => handleChipClick(chip.id)}
whileTap={{ scale: 0.94 }} disabled={showLockedState}
whileTap={showLockedState ? undefined : { scale: 0.94 }}
transition={{ transition={{
layout: { layout: {
type: 'spring', type: 'spring',
@@ -134,12 +158,20 @@ export function DesktopControl() {
duration: 0.26, duration: 0.26,
}} }}
className={ 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 <motion.span
animate={ animate={
isSelected isSelected && !showLockedState
? { ? {
opacity: [0.22, 0.5, 0.22], opacity: [0.22, 0.5, 0.22],
scaleX: [0.82, 1.02, 0.82], scaleX: [0.82, 1.02, 0.82],
@@ -166,7 +198,7 @@ export function DesktopControl() {
/> />
<motion.span <motion.span
animate={ animate={
isSelected isSelected && !showLockedState
? { ? {
opacity: [0.72, 1, 0.72], opacity: [0.72, 1, 0.72],
scale: [0.96, 1.04, 0.96], scale: [0.96, 1.04, 0.96],
@@ -198,7 +230,7 @@ export function DesktopControl() {
<motion.div <motion.div
layout layout
animate={ animate={
isSelected isSelected && !showLockedState
? { ? {
y: [-1, -4, -1], y: [-1, -4, -1],
scale: [1.04, 1.1, 1.04], scale: [1.04, 1.1, 1.04],
@@ -211,8 +243,9 @@ export function DesktopControl() {
: { : {
y: 0, y: 0,
scale: 1, scale: 1,
filter: filter: showLockedState
'drop-shadow(0 6px 10px rgba(0,0,0,0.34)) drop-shadow(0 2px 4px rgba(255,255,255,0.08))', ? '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 }} transition={{ type: 'spring', stiffness: 380, damping: 24 }}
@@ -224,6 +257,14 @@ export function DesktopControl() {
draggable={false} draggable={false}
className={'h-design-70 w-design-70 object-contain'} 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 <span
className={ 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]' '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( 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', '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 }) => { {ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
const isClicked = clickedId === id const isClicked = clickedId === id
const isHiding = hidingId === id const isHiding = hidingId === id
const showBg = isClicked || isHiding const showBg = actionsEnabled && (isClicked || isHiding)
return ( return (
<motion.button <motion.button
key={id} key={id}
type="button" type="button"
disabled={!actionsEnabled}
onClick={() => handleActionClick(id)} onClick={() => handleActionClick(id)}
whileHover={{ y: -1, scale: 1.01 }} whileHover={actionsEnabled ? { y: -1, scale: 1.01 } : undefined}
whileTap={{ scale: 0.96 }} whileTap={actionsEnabled ? { scale: 0.96 } : undefined}
className={cn( 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' }, { '-translate-x-1.5': id === 'auto-spin' },
)} )}
> >

View File

@@ -19,6 +19,7 @@ interface DesktopCountdownProps {
initialMs?: number initialMs?: number
initialSeconds?: number initialSeconds?: number
onComplete?: () => void onComplete?: () => void
onRemainingMsChange?: (remainingMs: number) => void
} }
export function DesktopCountdown({ export function DesktopCountdown({
@@ -26,6 +27,7 @@ export function DesktopCountdown({
initialMs, initialMs,
initialSeconds, initialSeconds,
onComplete, onComplete,
onRemainingMsChange,
}: DesktopCountdownProps) { }: DesktopCountdownProps) {
const initialCountdownMs = useMemo(() => { const initialCountdownMs = useMemo(() => {
if (typeof initialMs === 'number') { if (typeof initialMs === 'number') {
@@ -43,7 +45,8 @@ export function DesktopCountdown({
useEffect(() => { useEffect(() => {
setRemainingMs(initialCountdownMs) setRemainingMs(initialCountdownMs)
}, [initialCountdownMs]) onRemainingMsChange?.(initialCountdownMs)
}, [initialCountdownMs, onRemainingMsChange])
useEffect(() => { useEffect(() => {
if (initialCountdownMs <= 0) { if (initialCountdownMs <= 0) {
@@ -58,6 +61,7 @@ export function DesktopCountdown({
const nextRemainingMs = Math.max(0, initialCountdownMs - elapsedMs) const nextRemainingMs = Math.max(0, initialCountdownMs - elapsedMs)
setRemainingMs(nextRemainingMs) setRemainingMs(nextRemainingMs)
onRemainingMsChange?.(nextRemainingMs)
if (nextRemainingMs === 0) { if (nextRemainingMs === 0) {
window.clearInterval(timer) window.clearInterval(timer)
@@ -68,12 +72,12 @@ export function DesktopCountdown({
return () => { return () => {
window.clearInterval(timer) window.clearInterval(timer)
} }
}, [initialCountdownMs, onComplete]) }, [initialCountdownMs, onComplete, onRemainingMsChange])
return ( return (
<div <div
className={cn( 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, className,
)} )}
> >

View File

@@ -80,23 +80,23 @@ export function DesktopGameHistory() {
} }
> >
<div <div
className={ className="common-neon-inset w-full !rounded-b-none text-center text-design-20 font-bold tracking-[0.08em]"
'common-neon-inset w-full !rounded-b-none text-center text-design-20' 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>
<div <div
className={ className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16' '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> <div>
<span className={'text-[#84A2A2]'}> <span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.roundId')}:{' '} {t('gameDesktop.history.roundId')}:{' '}
@@ -109,12 +109,6 @@ export function DesktopGameHistory() {
</span> </span>
<span>{item.numbersLabel}</span> <span>{item.numbersLabel}</span>
</div> </div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.settledAt')}:{' '}
</span>
<span>{item.createdAtLabel}</span>
</div>
<div> <div>
<span className={'text-[#84A2A2]'}> <span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.totalPoolAmount')}:{' '} {t('gameDesktop.history.totalPoolAmount')}:{' '}
@@ -131,12 +125,6 @@ export function DesktopGameHistory() {
{item.resultNumberLabel} {item.resultNumberLabel}
</span> </span>
</div> </div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.payout')}:{' '}
</span>
<span>{item.winAmountLabel}</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -173,6 +173,12 @@ export function DesktopHeader() {
const connection = useGameSessionStore((state) => state.connection) const connection = useGameSessionStore((state) => state.connection)
const setModalOpen = useModalStore((state) => state.setModalOpen) const setModalOpen = useModalStore((state) => state.setModalOpen)
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage() const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
const handleOpenUserInfo = () => {
setModalOpen('desktopUserInfo', true)
}
const handleOpenProcedures = () => {
setModalOpen('desktopProcedures', true)
}
const serverClockOffsetMs = useMemo(() => { const serverClockOffsetMs = useMemo(() => {
if ( if (
@@ -368,7 +374,11 @@ export function DesktopHeader() {
'flex items-center justify-center gap-design-30 pl-design-30 pr-design-10' '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 <SmartImage
src={avatar} src={avatar}
alt="avatar" alt="avatar"
@@ -377,14 +387,18 @@ export function DesktopHeader() {
/> />
<div <div
className={ 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 || '--'} {currentUser?.username || '--'}
</div> </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 <SmartImage
src={diamond} src={diamond}
alt="diamond" alt="diamond"
@@ -393,12 +407,12 @@ export function DesktopHeader() {
/> />
<div <div
className={ 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 || '--'} {currentUser?.coin || '--'}
</div> </div>
</div> </button>
</div> </div>
) : ( ) : (
<div <div

View File

@@ -1,6 +1,9 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import down5Animation from '@/assets/lottie/down5.json'
import statusCenter from '@/assets/system/status-center.webp' import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp' import statusLine from '@/assets/system/status-line.webp'
import { LottiePlayer } from '@/components/lottie-player.tsx'
import { SmartBackground } from '@/components/smart-background.tsx' import { SmartBackground } from '@/components/smart-background.tsx'
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx' import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx' import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
@@ -18,6 +21,15 @@ export function DesktopStatusLine() {
roundId, roundId,
streakLabel, streakLabel,
} = useGameStatusVm() } = 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 ( return (
<div className={'relative w-full flex flex-col text-design-22'}> <div className={'relative w-full flex flex-col text-design-22'}>
@@ -39,18 +51,38 @@ export function DesktopStatusLine() {
{t('gameDesktop.status.limit')}: {limitLabel} {t('gameDesktop.status.limit')}: {limitLabel}
</div> </div>
</div> </div>
<SmartBackground <div className="relative flex h-[105px] w-design-360 items-center justify-center">
src={statusCenter} <SmartBackground
className="w-design-360 h-[105px] font-countdown bg-no-repeat bg-center bg-contain flex items-center justify-center" src={statusCenter}
size="contain" className="pointer-events-none absolute inset-0 z-0 bg-no-repeat bg-center bg-contain transition-opacity duration-500 ease-out"
> size="contain"
<DesktopCountdown style={{
initialMs={countdownMs} opacity: showWarningCountdown ? 0.18 : 1,
onComplete={() => { transform: showWarningCountdown ? 'scale(0.985)' : 'scale(1)',
console.log('countdown finished')
}} }}
/> />
</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 className={'flex-1 flex items-center justify-center gap-10'}>
<div> <div>
{t('gameDesktop.status.roundId')}:{roundId} {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 { placeGameBet } from '@/features/game'
import { notify } from '@/lib/notify' import { notify } from '@/lib/notify'
import { useAuthStore, useModalStore } from '@/store' import { useAuthStore, useModalStore } from '@/store'
import { selectSelectionTotal, useGameRoundStore } from '@/store/game' import {
selectSelectionTotal,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'submitting' type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'submitting'
@@ -71,6 +75,12 @@ export function useGameControlVm() {
) )
const selectChip = useGameRoundStore((state) => state.selectChip) const selectChip = useGameRoundStore((state) => state.selectChip)
const totalBetAmount = useGameRoundStore(selectSelectionTotal) const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const connectionStatus = useGameSessionStore(
(state) => state.connection.status,
)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const authStatus = useAuthStore((state) => state.status) const authStatus = useAuthStore((state) => state.status)
const currentUser = useAuthStore((state) => state.currentUser) const currentUser = useAuthStore((state) => state.currentUser)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser) const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
@@ -99,6 +109,8 @@ export function useGameControlVm() {
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
const balance = parseBalance(currentUser?.coin) const balance = parseBalance(currentUser?.coin)
const hasSelections = selections.length > 0 const hasSelections = selections.length > 0
const hasEnteredGame =
shouldConnectRealtime && connectionStatus === 'connected'
const hasInsufficientBalance = hasSelections && totalBetAmount > balance const hasInsufficientBalance = hasSelections && totalBetAmount > balance
const confirmState: ConfirmState = isSubmitting const confirmState: ConfirmState = isSubmitting
? 'submitting' ? 'submitting'
@@ -235,7 +247,13 @@ export function useGameControlVm() {
notify.success(t('commonUi.toast.repeatSelectionsRestored')) notify.success(t('commonUi.toast.repeatSelectionsRestored'))
}, [restoreRecentSuccessfulSelections, round.phase, t]) }, [restoreRecentSuccessfulSelections, round.phase, t])
const handleOpenAutoSetting = useCallback(() => {
setModalOpen('desktopAutoSetting', true)
}, [setModalOpen])
return { return {
acceptingBets: round.phase === 'betting',
actionsEnabled: hasEnteredGame,
canClear: selections.length > 0, canClear: selections.length > 0,
confirmLabel: confirmLabel:
confirmState === 'idle' confirmState === 'idle'
@@ -250,6 +268,7 @@ export function useGameControlVm() {
onChipSelect: selectChip, onChipSelect: selectChip,
onConfirm: handleConfirm, onConfirm: handleConfirm,
onClearSelections: clearSelections, onClearSelections: clearSelections,
onOpenAutoSetting: handleOpenAutoSetting,
onRepeatSelections: handleRepeatSelections, onRepeatSelections: handleRepeatSelections,
maxSelectionCountLabel: maxSelectionCount, maxSelectionCountLabel: maxSelectionCount,
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--', selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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