feat(game): 添加游戏大厅音频控制和用户协议功能
- 实现音频资源配置和音频商店状态管理 - 添加用户协议和游戏规则的多语言支持 - 集成音频播放解锁机制和声音开关功能 - 更新API客户端以支持根路径候选 - 优化游戏历史记录组件的滚动加载逻辑 - 添加桌面端控制按钮的动画效果和交互反馈 - 实现语言切换和音效控制的UI组件 - 增加下注相关的状态管理和错误提示 - 完善应用偏好设置的存储和持久化逻辑
This commit is contained in:
55
src/features/game/hooks/use-app-language.ts
Normal file
55
src/features/game/hooks/use-app-language.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
39
src/features/game/hooks/use-protocol-agreement.ts
Normal file
39
src/features/game/hooks/use-protocol-agreement.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user