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,42 @@
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import { AUDIO_PREFERENCES_STORAGE_KEY } from '@/constants'
interface AudioPreferenceState {
hasUnlockedSoundPlayback: boolean
markSoundPlaybackUnlocked: () => void
isSoundEnabled: boolean
setSoundEnabled: (enabled: boolean) => void
toggleSoundEnabled: () => void
}
export const useAudioStore = create<AudioPreferenceState>()(
persist(
(set) => ({
hasUnlockedSoundPlayback: false,
isSoundEnabled: true,
markSoundPlaybackUnlocked: () => {
set({ hasUnlockedSoundPlayback: true })
},
setSoundEnabled: (enabled) => {
set({ isSoundEnabled: enabled })
},
toggleSoundEnabled: () => {
set((state) => ({ isSoundEnabled: !state.isSoundEnabled }))
},
}),
{
name: AUDIO_PREFERENCES_STORAGE_KEY,
storage: createJSONStorage(() => localStorage),
merge: (persistedState, currentState) => ({
...currentState,
...(persistedState as Partial<AudioPreferenceState>),
hasUnlockedSoundPlayback: false,
}),
partialize: (state) => ({
isSoundEnabled: state.isSoundEnabled,
}),
},
),
)

1
src/store/audio/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './audio-store'

View File

@@ -50,6 +50,7 @@ interface PersistedAuthState {
interface PersistedAppPreferenceState {
appLanguage: string | null
deviceId: string | null
hasAcceptedProtocol: boolean
}
interface AuthState extends PersistedAuthState {
@@ -217,8 +218,11 @@ export const useAuthStore = create<AuthState>()(
)
interface AppPreferenceStoreState extends PersistedAppPreferenceState {
finishHydration: () => void
getOrCreateDeviceId: () => string
isHydrated: boolean
setAppLanguage: (language: string) => void
setProtocolAccepted: (accepted: boolean) => void
}
export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
@@ -226,6 +230,11 @@ export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
(set, get) => ({
appLanguage: null,
deviceId: null,
hasAcceptedProtocol: false,
isHydrated: false,
finishHydration: () => {
set({ isHydrated: true })
},
getOrCreateDeviceId: () => {
const deviceId = get().deviceId
@@ -242,6 +251,9 @@ export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
setAppLanguage: (language) => {
set({ appLanguage: language })
},
setProtocolAccepted: (accepted) => {
set({ hasAcceptedProtocol: accepted })
},
}),
{
name: APP_PREFERENCES_STORAGE_KEY,
@@ -249,7 +261,11 @@ export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
partialize: (state) => ({
appLanguage: state.appLanguage,
deviceId: state.deviceId,
hasAcceptedProtocol: state.hasAcceptedProtocol,
}),
onRehydrateStorage: () => (state) => {
state?.finishHydration()
},
},
),
)
@@ -265,3 +281,11 @@ export function getStoredAppLanguage() {
export function setStoredAppLanguage(language: string) {
useAppPreferenceStore.getState().setAppLanguage(language)
}
export function getStoredProtocolAccepted() {
return useAppPreferenceStore.getState().hasAcceptedProtocol
}
export function setStoredProtocolAccepted(accepted: boolean) {
useAppPreferenceStore.getState().setProtocolAccepted(accepted)
}

View File

@@ -12,7 +12,7 @@ import type {
} from '@/features/game/shared'
import {
buildGameCellViewModels,
createMockGameBootstrapSnapshot,
createEmptyGameBootstrapSnapshot,
DEFAULT_ACTIVE_CHIP_ID,
getChipById,
getRecentWinningCellIds,
@@ -31,29 +31,54 @@ type GameRoundSlice = Pick<
| 'trends'
>
function resolveRecentActiveChipId(
chips: Chip[],
selections: BetSelection[],
fallbackChipId: string,
) {
for (let index = selections.length - 1; index >= 0; index -= 1) {
const chipId = selections[index]?.chipId
if (chipId && getChipById(chips, chipId)) {
return chipId
}
}
return getChipById(chips, fallbackChipId)
? fallbackChipId
: (chips.find((chip) => chip.isDefault)?.id ??
chips[0]?.id ??
DEFAULT_ACTIVE_CHIP_ID)
}
export interface GameRoundStoreState extends GameRoundSlice {
activeChipId: string
clearSelections: () => void
hydrateRound: (snapshot: GameRoundSlice) => void
placeBet: (cellId: number) => void
recentSuccessfulSelections: BetSelection[]
removeSelectionsForCell: (cellId: number) => void
restoreRecentSuccessfulSelections: () => boolean
setRecentSuccessfulSelections: (selections: BetSelection[]) => void
selectChip: (chipId: string) => void
setPhase: (phase: RoundPhase) => void
syncRound: (round: Partial<RoundSnapshot>) => void
upsertSelections: (selections: BetSelection[]) => void
}
function createInitialRoundState(): GameRoundSlice & { activeChipId: string } {
const snapshot = createMockGameBootstrapSnapshot()
function createInitialRoundState(): GameRoundSlice & {
activeChipId: string
recentSuccessfulSelections: BetSelection[]
} {
const snapshot = createEmptyGameBootstrapSnapshot()
return {
activeChipId:
snapshot.chips.find((chip) => chip.isDefault)?.id ??
DEFAULT_ACTIVE_CHIP_ID,
activeChipId: DEFAULT_ACTIVE_CHIP_ID,
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount,
recentSuccessfulSelections: [],
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
@@ -118,6 +143,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
}
})
},
recentSuccessfulSelections: [],
removeSelectionsForCell: (cellId) => {
set((state) => ({
selections: state.selections.filter(
@@ -125,6 +151,48 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
),
}))
},
restoreRecentSuccessfulSelections: () => {
const state = useGameRoundStore.getState()
if (
state.round.phase !== 'betting' ||
state.recentSuccessfulSelections.length === 0
) {
return false
}
const nextSelections = state.recentSuccessfulSelections
.filter((selection) => getChipById(state.chips, selection.chipId))
.slice(0, state.maxSelectionCount)
.map((selection, index) => ({
...selection,
id: `bet-repeat-${selection.cellId}-${index + 1}-${Date.now()}`,
placedAt: new Date().toISOString(),
source: 'local' as const,
}))
if (nextSelections.length === 0) {
return false
}
set({
activeChipId: resolveRecentActiveChipId(
state.chips,
nextSelections,
state.activeChipId,
),
selections: nextSelections,
})
return true
},
setRecentSuccessfulSelections: (selections) => {
set({
recentSuccessfulSelections: selections.map((selection) => ({
...selection,
})),
})
},
selectChip: (chipId) => {
set((state) => {
if (!getChipById(state.chips, chipId)) {

View File

@@ -8,7 +8,7 @@ import type {
GameBootstrapSnapshot,
} from '@/features/game/shared'
import {
createMockGameBootstrapSnapshot,
createEmptyGameBootstrapSnapshot,
getUnreadAnnouncementCount,
getVisibleAnnouncements,
} from '@/features/game/shared'
@@ -32,7 +32,7 @@ export interface GameSessionStoreState extends GameSessionSlice {
}
function createInitialSessionState(): GameSessionSlice {
const snapshot = createMockGameBootstrapSnapshot()
const snapshot = createEmptyGameBootstrapSnapshot()
return {
announcements: snapshot.announcements,

View File

@@ -1,3 +1,4 @@
export * from './audio'
export * from './auth'
export * from './game'
export * from './modal'

View File

@@ -6,6 +6,12 @@ export const MODAL_KEYS = [
'desktopLogin',
/**@description 桌面端注册弹窗*/
'desktopRegister',
/**@description 桌面端多语言弹窗*/
'desktopLanguage',
/**@description 桌面端协议弹窗*/
'desktopProtocol',
/**@description 桌面端规则弹窗*/
'desktopRules',
/**@description 桌面端用户信息弹窗*/
'desktopUserInfo',
/**@description 桌面端公告弹窗*/
@@ -25,6 +31,9 @@ type ModalVisibilityMap = Record<ModalKey, boolean>
const INITIAL_MODAL_VISIBILITY: ModalVisibilityMap = {
desktopLogin: false,
desktopRegister: false,
desktopLanguage: false,
desktopProtocol: false,
desktopRules: false,
desktopUserInfo: false,
desktopNotice: false,
desktopAutoSetting: false,