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

@@ -0,0 +1,55 @@
import { useLocation } from '@tanstack/react-router'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { LANGUAGE_OPTIONS } from '@/constants'
import { type AppLanguage, supportedLanguages } from '@/i18n'
const languagePrefixPattern = new RegExp(
`^/(${supportedLanguages.join('|')})(?=/|$)`,
)
function resolveNextPathname(pathname: string, language: AppLanguage) {
if (languagePrefixPattern.test(pathname)) {
return pathname.replace(languagePrefixPattern, `/${language}`)
}
return `/${language}${pathname.startsWith('/') ? pathname : `/${pathname}`}`
}
export function useAppLanguage() {
const { i18n, t } = useTranslation()
const location = useLocation()
const currentLanguage = (i18n.resolvedLanguage ??
i18n.language ??
'zh-CN') as AppLanguage
const currentLanguageOption = useMemo(
() =>
LANGUAGE_OPTIONS.find((option) => option.code === currentLanguage) ??
LANGUAGE_OPTIONS[0],
[currentLanguage],
)
const selectLanguage = async (language: AppLanguage) => {
if (language === currentLanguage) {
return
}
await i18n.changeLanguage(language)
const nextPathname = resolveNextPathname(location.pathname, language)
window.location.assign(
`${nextPathname}${window.location.search}${window.location.hash}`,
)
}
return {
currentLanguage,
currentLanguageLabel: t(currentLanguageOption.labelKey),
currentLanguageOption,
languageOptions: LANGUAGE_OPTIONS,
selectLanguage,
}
}

View File

@@ -1,7 +1,13 @@
import { useMemo } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
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'
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'submitting'
function formatChipDisplayValue(amount: number) {
if (Number.isInteger(amount)) {
return String(amount)
@@ -10,16 +16,66 @@ function formatChipDisplayValue(amount: number) {
return amount.toFixed(2).replace(/\.?0+$/, '')
}
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
}
function createIdempotencyKey() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return `bet-${crypto.randomUUID()}`
}
return `bet-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function toBetId(chipId: string) {
const match = chipId.match(/^chip-(\d+)$/)
if (!match) {
return null
}
const betId = Number(match[1])
return Number.isInteger(betId) && betId >= 1 && betId <= 6 ? betId : null
}
export function useGameControlVm() {
const { t } = useTranslation()
const chips = useGameRoundStore((state) => state.chips)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const round = useGameRoundStore((state) => state.round)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
const selections = useGameRoundStore((state) => state.selections)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const restoreRecentSuccessfulSelections = useGameRoundStore(
(state) => state.restoreRecentSuccessfulSelections,
)
const setRecentSuccessfulSelections = useGameRoundStore(
(state) => state.setRecentSuccessfulSelections,
)
const selectChip = useGameRoundStore((state) => state.selectChip)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const authStatus = useAuthStore((state) => state.status)
const currentUser = useAuthStore((state) => state.currentUser)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const [isSubmitting, setIsSubmitting] = useState(false)
const chipItems = useMemo(() => {
const items = chips.map((chip) => ({
@@ -41,11 +97,160 @@ export function useGameControlVm() {
const selectedChip =
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
const balance = parseBalance(currentUser?.coin)
const hasSelections = selections.length > 0
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
const confirmState: ConfirmState = isSubmitting
? 'submitting'
: !hasSelections
? 'idle'
: hasInsufficientBalance
? 'insufficient'
: 'ready'
const handleConfirm = useCallback(async () => {
if (confirmState === 'submitting' || !hasSelections) {
return
}
if (authStatus !== 'authenticated') {
notify.warning(t('commonUi.toast.loginRequired'))
setModalOpen('desktopLogin', true)
return
}
if (hasInsufficientBalance) {
notify.warning(t('commonUi.toast.insufficientBalance'))
return
}
if (round.phase !== 'betting' || !round.id) {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
const groupedSelections = selections.reduce<
Map<string, { betId: number; numbers: number[] }>
>((accumulator, selection) => {
const betId = toBetId(selection.chipId)
if (betId === null) {
return accumulator
}
const groupKey = String(betId)
const current = accumulator.get(groupKey)
if (current) {
current.numbers.push(selection.cellId)
return accumulator
}
accumulator.set(groupKey, {
betId,
numbers: [selection.cellId],
})
return accumulator
}, new Map())
if (groupedSelections.size === 0) {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
setIsSubmitting(true)
try {
let latestBalance = currentUser?.coin ?? '0'
let latestStreak = currentUser?.currentStreak ?? 0
for (const group of groupedSelections.values()) {
const uniqueNumbers = [...new Set(group.numbers)].sort(
(left, right) => left - right,
)
const result = await placeGameBet({
bet_id: group.betId,
idempotency_key: createIdempotencyKey(),
numbers: uniqueNumbers.join(','),
period_no: round.id,
})
if (result.status !== 'accepted') {
throw new Error(t('commonUi.toast.betRejected'))
}
latestBalance = result.balance_after
latestStreak = result.current_streak
}
if (currentUser) {
setCurrentUser({
...currentUser,
coin: latestBalance,
currentStreak: latestStreak,
lastBetPeriodNo: round.id,
})
}
setRecentSuccessfulSelections(selections)
clearSelections()
notify.success(t('commonUi.toast.betPlaced'))
} catch (error) {
notify.error(t('commonUi.toast.betPlaceFailed'), {
description: error instanceof Error ? error.message : undefined,
})
} finally {
setIsSubmitting(false)
}
}, [
authStatus,
clearSelections,
confirmState,
currentUser,
hasInsufficientBalance,
hasSelections,
round.id,
round.phase,
selections,
setRecentSuccessfulSelections,
setCurrentUser,
setModalOpen,
t,
])
const handleRepeatSelections = useCallback(() => {
if (round.phase !== 'betting') {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
const restored = restoreRecentSuccessfulSelections()
if (!restored) {
notify.warning(t('commonUi.toast.noRecentSuccessfulBet'))
return
}
notify.success(t('commonUi.toast.repeatSelectionsRestored'))
}, [restoreRecentSuccessfulSelections, round.phase, t])
return {
canClear: selections.length > 0,
confirmLabel:
confirmState === 'idle'
? t('gameDesktop.control.selectNumbers')
: confirmState === 'insufficient'
? t('gameDesktop.control.insufficientBalance')
: confirmState === 'submitting'
? t('gameDesktop.control.submitting')
: t('gameDesktop.control.confirm'),
confirmState,
isConfirmClickable: confirmState === 'ready',
onChipSelect: selectChip,
onConfirm: handleConfirm,
onClearSelections: clearSelections,
onRepeatSelections: handleRepeatSelections,
maxSelectionCountLabel: maxSelectionCount,
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
selectedChipId: activeChipId,

View File

@@ -1,9 +1,10 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getGameBetMyOrders } from '@/features/game/api/game-api'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'
const GAME_HISTORY_PAGE_SIZE = 20
@@ -36,6 +37,9 @@ export function useGameHistoryVm() {
const { i18n, t } = useTranslation()
const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status)
const roundId = useGameRoundStore((state) => state.round.id)
const winningCellId = useGameRoundStore((state) => state.round.winningCellId)
const lastOpenedRoundRef = useRef<string | null>(null)
const query = useInfiniteQuery({
queryKey: ['game', 'bet-my-orders', accessToken],
@@ -79,6 +83,47 @@ export function useGameHistoryVm() {
[i18n.resolvedLanguage, query.data?.pages],
)
useEffect(() => {
const openedRoundKey =
winningCellId === null || roundId.length === 0
? null
: `${roundId}:${winningCellId}`
if (openedRoundKey === null) {
return
}
if (lastOpenedRoundRef.current === null) {
lastOpenedRoundRef.current = openedRoundKey
return
}
if (lastOpenedRoundRef.current === openedRoundKey) {
return
}
lastOpenedRoundRef.current = openedRoundKey
if (
authStatus !== 'authenticated' ||
items.length >= GAME_HISTORY_PAGE_SIZE ||
query.isFetching ||
query.isLoading
) {
return
}
void query.refetch()
}, [
authStatus,
items.length,
query.isFetching,
query.isLoading,
query.refetch,
roundId,
winningCellId,
])
return {
emptyText: t('gameDesktop.history.empty'),
endText: t('gameDesktop.history.end'),

View File

@@ -0,0 +1,39 @@
import { useEffect } from 'react'
import { useAppPreferenceStore, useModalStore } from '@/store'
export function useProtocolAgreement() {
const isHydrated = useAppPreferenceStore((state) => state.isHydrated)
const hasAcceptedProtocol = useAppPreferenceStore(
(state) => state.hasAcceptedProtocol,
)
const setProtocolAccepted = useAppPreferenceStore(
(state) => state.setProtocolAccepted,
)
const open = useModalStore((state) => state.modals.desktopProtocol)
const setModalOpen = useModalStore((state) => state.setModalOpen)
useEffect(() => {
if (!isHydrated) {
return
}
if (!hasAcceptedProtocol) {
setModalOpen('desktopProtocol', true)
return
}
setModalOpen('desktopProtocol', false)
}, [hasAcceptedProtocol, isHydrated, setModalOpen])
const acceptProtocol = () => {
setProtocolAccepted(true)
setModalOpen('desktopProtocol', false)
}
return {
acceptProtocol,
hasAcceptedProtocol,
isHydrated,
open,
}
}