feat(game): 添加游戏桌面组件的警告提示和动画效果
- 在动物游戏中添加余额不足和选择限制的警告提示 - 为警告状态添加震动动画和视觉反馈效果 - 实现倒计时警告状态的动画和样式变化 - 添加胜利和失败状态的历史记录显示 - 为筹码和操作按钮添加禁用状态的视觉效果 - 实现用户信息和流程按钮的交互功能 - 添加自动设置弹窗的打开功能 - 优化游戏阶段切换时的选择清空逻辑 - 添加剩余时间变化的回调函数支持 - 为历史记录添加中奖状态的颜色标识
This commit is contained in:
BIN
src/assets/game/chip-lock.webp
Normal file
BIN
src/assets/game/chip-lock.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
769
src/assets/lottie/down5.json
Normal file
769
src/assets/lottie/down5.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ?? '--',
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -352,12 +352,16 @@ export default {
|
||||
announcement: '公告栏',
|
||||
},
|
||||
animal: {
|
||||
insufficientBalanceRecharge: '余额不足,请充值',
|
||||
loading: '加载中',
|
||||
selectionLimitReached: '超过可选择字花',
|
||||
tapToEnter: '点击进入',
|
||||
getStart: '开始游戏',
|
||||
},
|
||||
history: {
|
||||
title: '历史记录',
|
||||
win: 'WIN',
|
||||
lost: 'LOST',
|
||||
orderNo: '订单号',
|
||||
roundId: '期号',
|
||||
numbers: '下注号码',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user