feat(game): 添加游戏大厅音频控制和用户协议功能

- 实现音频资源配置和音频商店状态管理
- 添加用户协议和游戏规则的多语言支持
- 集成音频播放解锁机制和声音开关功能
- 更新API客户端以支持根路径候选
- 优化游戏历史记录组件的滚动加载逻辑
- 添加桌面端控制按钮的动画效果和交互反馈
- 实现语言切换和音效控制的UI组件
- 增加下注相关的状态管理和错误提示
- 完善应用偏好设置的存储和持久化逻辑
This commit is contained in:
JiaJun
2026-05-16 18:02:59 +08:00
parent 5dd4e31db4
commit 85b4d9481f
46 changed files with 1500 additions and 362 deletions

View File

@@ -4,7 +4,7 @@ import diamondIcon from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image'
import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store'
import { useAudioStore, useAuthStore, useModalStore } from '@/store'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
@@ -72,6 +72,9 @@ export function DesktopAnimal({
}: DesktopAnimalProps) {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const markSoundPlaybackUnlocked = useAudioStore(
(state) => state.markSoundPlaybackUnlocked,
)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const chips = useGameRoundStore((state) => state.chips)
@@ -132,6 +135,7 @@ export function DesktopAnimal({
}
clearSelections()
markSoundPlaybackUnlocked()
requestRealtimeConnection()
}

View File

@@ -5,7 +5,8 @@ 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 confirmBg from '@/assets/game/confirm-bg.png'
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'
import leftBottomBg from '@/assets/game/left-bg.webp'
import reduce from '@/assets/game/reduce.webp'
@@ -21,9 +22,14 @@ export function DesktopControl() {
const {
canClear,
chips,
confirmLabel,
confirmState,
isConfirmClickable,
maxSelectionCountLabel,
onChipSelect,
onConfirm,
onClearSelections,
onRepeatSelections,
selectedChipAmountLabel,
selectedChipId,
selectedCountLabel,
@@ -43,6 +49,10 @@ export function DesktopControl() {
onClearSelections()
}
if (id === 'repeat') {
onRepeatSelections()
}
setClickedId(id)
setTimeout(() => {
setClickedId(null)
@@ -52,15 +62,21 @@ export function DesktopControl() {
}, 180)
}, 200)
},
[canClear, onClearSelections],
[canClear, onClearSelections, onRepeatSelections],
)
const handleConfirmClick = useCallback(() => {
if (!isConfirmClickable) {
void onConfirm()
return
}
setConfirmClicked(true)
setTimeout(() => {
setConfirmClicked(false)
}, 200)
}, [])
void onConfirm()
}, [isConfirmClickable, onConfirm])
return (
<div
@@ -363,14 +379,46 @@ export function DesktopControl() {
</SmartBackground>
<SmartBackground
as={motion.button}
src={confirmBg}
src={confirmState === 'insufficient' ? confirmRedBg : confirmBg}
size="100% 100%"
type="button"
onClick={handleConfirmClick}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.96 }}
whileHover={isConfirmClickable ? { scale: 1.01 } : undefined}
whileTap={isConfirmClickable ? { scale: 0.96 } : undefined}
animate={
confirmState === 'ready'
? {
scale: [1, 1.035, 1],
filter: [
'drop-shadow(0 0 0 rgba(0,0,0,0))',
'drop-shadow(0 0 18px rgba(245,200,107,0.85))',
'drop-shadow(0 0 0 rgba(0,0,0,0))',
],
}
: confirmState === 'idle'
? { scale: 1, filter: 'grayscale(.95)' }
: { scale: 1, filter: 'none' }
}
transition={
confirmState === 'ready'
? {
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}
: { duration: 0.2, ease: 'easeOut' }
}
style={
confirmState === 'idle'
? {
WebkitFilter: 'grayscale(.95)',
filter: 'grayscale(.95)',
}
: undefined
}
className={cn(
'relative z-10 h-full w-design-260 shrink-0 cursor-pointer bg-center bg-no-repeat flex items-center justify-center text-design-32 font-bold',
'relative z-10 flex h-full w-design-260 shrink-0 items-center justify-center bg-center bg-no-repeat text-design-32 font-bold',
isConfirmClickable ? 'cursor-pointer' : 'cursor-not-allowed',
)}
>
{confirmClicked && (
@@ -381,16 +429,46 @@ export function DesktopControl() {
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="pointer-events-none absolute inset-0 bg-center bg-no-repeat"
src={confirmBg}
src={confirmState === 'insufficient' ? confirmRedBg : confirmBg}
size="100% 100%"
/>
)}
<motion.span
animate={confirmClicked ? { opacity: 0, y: 2 } : { opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
className="relative"
animate={
confirmState === 'ready'
? {
opacity: confirmClicked ? 0 : 1,
y: confirmClicked ? 2 : [0, -1, 0],
textShadow: [
'0 0 12px rgba(255,238,173,0.72), 0 0 22px rgba(255,214,96,0.4)',
'0 0 18px rgba(255,252,220,0.95), 0 0 34px rgba(255,214,96,0.8)',
'0 0 12px rgba(255,238,173,0.72), 0 0 22px rgba(255,214,96,0.4)',
],
}
: {
opacity: confirmClicked ? 0 : 1,
y: confirmClicked ? 2 : 0,
textShadow:
confirmState === 'insufficient'
? '0 0 10px rgba(255,206,206,0.45)'
: 'none',
}
}
transition={
confirmState === 'ready'
? {
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}
: { duration: 0.15 }
}
className={cn(
'relative',
confirmState === 'insufficient' && 'text-[#FFF1F1]',
)}
>
{t('gameDesktop.control.confirm')}
{confirmLabel}
</motion.span>
</SmartBackground>
</div>

View File

@@ -1,5 +1,4 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useRef } from 'react'
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import historyBg from '@/assets/system/history-bg.png'
import { SmartBackground } from '@/components/smart-background.tsx'
@@ -20,35 +19,20 @@ export function DesktopGameHistory() {
} = useGameHistoryVm()
const parentRef = useRef<HTMLDivElement | null>(null)
const rowCount = hasNextPage ? items.length + 1 : items.length
const virtualizer = useVirtualizer({
count: rowCount,
estimateSize: () => 196,
getScrollElement: () => parentRef.current,
overscan: 4,
})
const handleScroll = useCallback(() => {
const element = parentRef.current
useEffect(() => {
const virtualItems = virtualizer.getVirtualItems()
const lastItem = virtualItems[virtualItems.length - 1]
if (
!lastItem ||
!hasNextPage ||
isFetchingNextPage ||
lastItem.index < items.length - 1
) {
if (!element || !hasNextPage || isFetchingNextPage) {
return
}
void fetchNextPage()
}, [
fetchNextPage,
hasNextPage,
isFetchingNextPage,
items.length,
virtualizer,
])
const distanceToBottom =
element.scrollHeight - element.scrollTop - element.clientHeight
if (distanceToBottom <= 120) {
void fetchNextPage()
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
return (
<SmartBackground
@@ -65,6 +49,7 @@ export function DesktopGameHistory() {
</div>
<div
ref={parentRef}
onScroll={handleScroll}
className={
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
}
@@ -86,98 +71,80 @@ export function DesktopGameHistory() {
{emptyText}
</div>
) : (
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index]
return (
<>
{items.map((item) => (
<div key={item.id} className="w-full pb-design-12 last:pb-0">
<div
key={item?.id ?? `loader-${virtualRow.index}`}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
className={
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
{item ? (
<div
className={
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
>
{item.statusLabel}
</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')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>
{item.periodNo}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.numbers')}:{' '}
</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')}:{' '}
</span>
<span className={'text-[#FFE375]'}>
{item.amountLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.winningResult')}:{' '}
</span>
<span className={'text-[#FF7575]'}>
{item.resultNumberLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.payout')}:{' '}
</span>
<span>{item.winAmountLabel}</span>
</div>
</div>
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
>
{item.statusLabel}
</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 className="flex h-[calc(var(--design-unit)*60)] items-center justify-center text-design-16 text-[#84A2A2]">
{isFetchingNextPage ? loadingText : endText}
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.roundId')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>{item.periodNo}</span>
</div>
)}
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.numbers')}:{' '}
</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')}:{' '}
</span>
<span className={'text-[#FFE375]'}>
{item.amountLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.winningResult')}:{' '}
</span>
<span className={'text-[#FF7575]'}>
{item.resultNumberLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.payout')}:{' '}
</span>
<span>{item.winAmountLabel}</span>
</div>
</div>
</div>
)
})}
</div>
</div>
))}
<div className="flex min-h-[calc(var(--design-unit)*40)] items-center justify-center text-design-16 text-[#84A2A2]">
{isFetchingNextPage ? loadingText : hasNextPage ? '' : endText}
</div>
</>
)}
</div>
</SmartBackground>

View File

@@ -1,16 +1,29 @@
import { CircleAlert, Mail, Maximize, Minimize, Volume2 } from 'lucide-react'
import {
CircleAlert,
Mail,
Maximize,
Minimize,
Volume2,
VolumeX,
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
import {
isDesktopFullscreen,
subscribeDesktopFullscreenChange,
toggleDesktopFullscreen,
} from '@/lib/utils'
import { useAuthStore, useGameSessionStore, useModalStore } from '@/store'
import {
useAudioStore,
useAuthStore,
useGameSessionStore,
useModalStore,
} from '@/store'
type BrowserNetworkInformation = {
addEventListener?: (type: 'change', listener: () => void) => void
@@ -155,8 +168,11 @@ export function DesktopHeader() {
)
const currentUser = useAuthStore((state) => state.currentUser)
const authStatus = useAuthStore((state) => state.status)
const isSoundEnabled = useAudioStore((state) => state.isSoundEnabled)
const toggleSoundEnabled = useAudioStore((state) => state.toggleSoundEnabled)
const connection = useGameSessionStore((state) => state.connection)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
const serverClockOffsetMs = useMemo(() => {
if (
@@ -286,24 +302,50 @@ export function DesktopHeader() {
</div>
<div className="flex h-full flex-1 items-center justify-around gap-design-10 border-r border-[rgba(128,223,231,0.65)] px-design-20">
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<button
type="button"
onClick={() => setModalOpen('desktopRules', true)}
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.rules')}</div>
</div>
</button>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<Mail color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.message')}</div>
</div>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<Volume2 color={'#57B8BF'} size={16} />
<button
type="button"
onClick={toggleSoundEnabled}
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
>
{isSoundEnabled ? (
<Volume2 color={'#57B8BF'} size={16} />
) : (
<VolumeX color={'#57B8BF'} size={16} />
)}
<div>{t('gameDesktop.header.bgm')}</div>
</div>
</button>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.id')}</div>
<div className={'flex items-center justify-center'}>
<button
type="button"
onClick={() => setModalOpen('desktopLanguage', true)}
className={
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-fit items-center justify-between gap-design-8 !px-design-20 transition-opacity hover:opacity-85'
}
>
<div className="flex items-center gap-design-14">
<SmartImage
src={currentLanguageOption.icon}
alt={currentLanguageLabel}
className="h-design-24 w-design-24 rounded-[2px] object-cover"
/>
<div className="truncate">{currentLanguageLabel}</div>
</div>
</button>
</div>
<button

View File

@@ -1,2 +1,4 @@
export { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx'
export type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal'