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

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