feat(game): 添加游戏大厅音频控制和用户协议功能
- 实现音频资源配置和音频商店状态管理 - 添加用户协议和游戏规则的多语言支持 - 集成音频播放解锁机制和声音开关功能 - 更新API客户端以支持根路径候选 - 优化游戏历史记录组件的滚动加载逻辑 - 添加桌面端控制按钮的动画效果和交互反馈 - 实现语言切换和音效控制的UI组件 - 增加下注相关的状态管理和错误提示 - 完善应用偏好设置的存储和持久化逻辑
|
Before Width: | Height: | Size: 202 KiB |
BIN
src/assets/game/confirm-bg.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src/assets/game/confirm-red-bg.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
src/assets/game/confirm-red-bg.webp
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
src/assets/music/hall-music.mp3
Normal file
BIN
src/assets/system/en-US.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src/assets/system/id-ID.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/system/ms-MY.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/system/zh-CN.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
122
src/components/fullscreen-lottie-overlay.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import type {
|
||||||
|
AnimationDirection,
|
||||||
|
AnimationEventCallback,
|
||||||
|
AnimationEvents,
|
||||||
|
RendererType,
|
||||||
|
} from 'lottie-web'
|
||||||
|
import { type ReactNode, useEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
|
||||||
|
import { LottiePlayer } from '@/components/lottie-player.tsx'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface FullscreenLottieOverlayProps {
|
||||||
|
open: boolean
|
||||||
|
source: FullscreenLottieSource | null
|
||||||
|
animationKey?: string
|
||||||
|
zIndex?: number
|
||||||
|
renderer?: RendererType
|
||||||
|
loop?: boolean | number
|
||||||
|
autoplay?: boolean
|
||||||
|
speed?: number
|
||||||
|
direction?: AnimationDirection
|
||||||
|
lockBodyScroll?: boolean
|
||||||
|
closeOnBackdrop?: boolean
|
||||||
|
backdropClassName?: string
|
||||||
|
viewportClassName?: string
|
||||||
|
playerClassName?: string
|
||||||
|
onRequestClose?: () => void
|
||||||
|
onComplete?: AnimationEventCallback<AnimationEvents['complete']>
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 全屏 Lottie 播放容器。
|
||||||
|
* 通过外部传入不同的 `source` 和 `animationKey`,可在同一个容器里切换多种开奖/结算动画。
|
||||||
|
*/
|
||||||
|
export function FullscreenLottieOverlay({
|
||||||
|
open,
|
||||||
|
source,
|
||||||
|
animationKey,
|
||||||
|
zIndex = 80,
|
||||||
|
renderer = 'svg',
|
||||||
|
loop,
|
||||||
|
autoplay,
|
||||||
|
speed,
|
||||||
|
direction,
|
||||||
|
lockBodyScroll = true,
|
||||||
|
closeOnBackdrop = false,
|
||||||
|
backdropClassName,
|
||||||
|
viewportClassName,
|
||||||
|
playerClassName,
|
||||||
|
onRequestClose,
|
||||||
|
onComplete,
|
||||||
|
children,
|
||||||
|
}: FullscreenLottieOverlayProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !lockBodyScroll || typeof document === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow
|
||||||
|
}
|
||||||
|
}, [lockBodyScroll, open])
|
||||||
|
|
||||||
|
if (!open || !source || typeof document === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerKey = `${animationKey ?? source.id}-${source.id}`
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 flex items-center justify-center overflow-hidden',
|
||||||
|
backdropClassName,
|
||||||
|
)}
|
||||||
|
style={{ zIndex }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close animation overlay"
|
||||||
|
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(49,208,255,0.12),rgba(3,7,15,0.92)_58%,rgba(2,4,10,0.98)_100%)] backdrop-blur-[2px]"
|
||||||
|
onClick={closeOnBackdrop ? onRequestClose : undefined}
|
||||||
|
disabled={!closeOnBackdrop}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 flex h-screen w-screen items-center justify-center px-4 py-4 sm:px-6 sm:py-6',
|
||||||
|
viewportClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative h-full w-full max-w-[min(100vw,1920px)] max-h-[100vh]">
|
||||||
|
<LottiePlayer
|
||||||
|
key={playerKey}
|
||||||
|
animationData={source.animationData}
|
||||||
|
path={source.path}
|
||||||
|
renderer={source.renderer ?? renderer}
|
||||||
|
loop={source.loop ?? loop ?? false}
|
||||||
|
autoplay={source.autoplay ?? autoplay ?? true}
|
||||||
|
speed={source.speed ?? speed ?? 1}
|
||||||
|
direction={source.direction ?? direction ?? 1}
|
||||||
|
onComplete={onComplete}
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full [&>svg]:h-full [&>svg]:w-full [&>svg]:object-contain [&_canvas]:h-full [&_canvas]:w-full',
|
||||||
|
playerClassName,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{children ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/components/fullscreen-lottie-overlay.types.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type {
|
||||||
|
AnimationConfigWithData,
|
||||||
|
AnimationConfigWithPath,
|
||||||
|
AnimationDirection,
|
||||||
|
RendererType,
|
||||||
|
} from 'lottie-web'
|
||||||
|
|
||||||
|
export interface FullscreenLottieSource {
|
||||||
|
id: string
|
||||||
|
animationData?: AnimationConfigWithData['animationData']
|
||||||
|
path?: AnimationConfigWithPath['path']
|
||||||
|
loop?: boolean | number
|
||||||
|
autoplay?: boolean
|
||||||
|
speed?: number
|
||||||
|
direction?: AnimationDirection
|
||||||
|
renderer?: RendererType
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ import chip6 from '@/assets/game/chip6.webp'
|
|||||||
import controlLeft from '@/assets/game/control-left.webp'
|
import controlLeft from '@/assets/game/control-left.webp'
|
||||||
import controlMid from '@/assets/game/control-mid.webp'
|
import controlMid from '@/assets/game/control-mid.webp'
|
||||||
import controlRight from '@/assets/game/control-right.webp'
|
import controlRight from '@/assets/game/control-right.webp'
|
||||||
|
import enUS from '@/assets/system/en-US.png'
|
||||||
|
import idID from '@/assets/system/id-ID.webp'
|
||||||
|
import msMY from '@/assets/system/ms-MY.png'
|
||||||
|
import zhCN from '@/assets/system/zh-CN.png'
|
||||||
|
import type { AppLanguage } from '@/i18n'
|
||||||
|
|
||||||
/** @description 应用启动阶段使用的根节点常量。 */
|
/** @description 应用启动阶段使用的根节点常量。 */
|
||||||
export const APP_ROOT_ELEMENT_ID = 'root'
|
export const APP_ROOT_ELEMENT_ID = 'root'
|
||||||
@@ -25,6 +30,9 @@ export const AUTH_STORAGE_KEY = 'auth-session'
|
|||||||
/** @description 应用偏好持久化到浏览器时使用的存储键。 */
|
/** @description 应用偏好持久化到浏览器时使用的存储键。 */
|
||||||
export const APP_PREFERENCES_STORAGE_KEY = 'app-preferences'
|
export const APP_PREFERENCES_STORAGE_KEY = 'app-preferences'
|
||||||
|
|
||||||
|
/** @description 音频偏好持久化到浏览器时使用的存储键。 */
|
||||||
|
export const AUDIO_PREFERENCES_STORAGE_KEY = 'audio-preferences'
|
||||||
|
|
||||||
/** @description 接口请求的默认超时时间,单位为毫秒。 */
|
/** @description 接口请求的默认超时时间,单位为毫秒。 */
|
||||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
@@ -96,3 +104,34 @@ export const ACTION_OPTIONS = [
|
|||||||
bg: controlRight,
|
bg: controlRight,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const LANGUAGE_OPTIONS: Array<{
|
||||||
|
code: AppLanguage
|
||||||
|
icon: string
|
||||||
|
labelKey: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
code: 'zh-CN',
|
||||||
|
icon: zhCN,
|
||||||
|
labelKey: 'language.zhCN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'en-US',
|
||||||
|
icon: enUS,
|
||||||
|
labelKey: 'language.enUS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ms-MY',
|
||||||
|
icon: msMY,
|
||||||
|
labelKey: 'language.msMY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'id-ID',
|
||||||
|
icon: idID,
|
||||||
|
labelKey: 'language.idID',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const LANGUAGE_ICON_MAP = new Map(
|
||||||
|
LANGUAGE_OPTIONS.map((language) => [language.code, language.icon] as const),
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
TrendEntry,
|
TrendEntry,
|
||||||
} from '../shared'
|
} from '../shared'
|
||||||
import {
|
import {
|
||||||
createMockGameBootstrapSnapshot,
|
createEmptyGameBootstrapSnapshot,
|
||||||
DEFAULT_GAME_CHIP_COLORS,
|
DEFAULT_GAME_CHIP_COLORS,
|
||||||
deriveTrendEntries,
|
deriveTrendEntries,
|
||||||
GAME_GRID_COLUMNS,
|
GAME_GRID_COLUMNS,
|
||||||
@@ -33,8 +33,9 @@ import type {
|
|||||||
GameBootstrapDto,
|
GameBootstrapDto,
|
||||||
GameCellDto,
|
GameCellDto,
|
||||||
GameLobbyInitDto,
|
GameLobbyInitDto,
|
||||||
GameLobbyPeriodDto,
|
|
||||||
GamePeriodTickDto,
|
GamePeriodTickDto,
|
||||||
|
GamePlaceBetDto,
|
||||||
|
GamePlaceBetRequestDto,
|
||||||
GameRoundFeedDto,
|
GameRoundFeedDto,
|
||||||
HistoryEntryDto,
|
HistoryEntryDto,
|
||||||
NoticeConfirmDto,
|
NoticeConfirmDto,
|
||||||
@@ -80,11 +81,13 @@ function assertLobbyInitDto(
|
|||||||
export const GAME_API_ENDPOINTS = {
|
export const GAME_API_ENDPOINTS = {
|
||||||
announcements: 'game/announcements',
|
announcements: 'game/announcements',
|
||||||
betMyOrders: 'api/game/betMyOrders',
|
betMyOrders: 'api/game/betMyOrders',
|
||||||
|
betPlaceLegacy: 'api/game/betPlace',
|
||||||
bootstrap: 'game/bootstrap',
|
bootstrap: 'game/bootstrap',
|
||||||
lobbyInit: 'api/game/lobbyInit',
|
lobbyInit: 'api/game/lobbyInit',
|
||||||
noticeConfirm: 'api/notice/noticeConfirm',
|
noticeConfirm: 'api/notice/noticeConfirm',
|
||||||
noticeDetail: 'api/notice/noticeDetail',
|
noticeDetail: 'api/notice/noticeDetail',
|
||||||
noticeList: 'api/notice/noticeList',
|
noticeList: 'api/notice/noticeList',
|
||||||
|
placeBet: 'api/game/placeBet',
|
||||||
roundFeed: 'game/round-feed',
|
roundFeed: 'game/round-feed',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -271,38 +274,6 @@ function normalizeLobbyCells(dictionary: GameLobbyInitDto['dictionary']) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLobbyRound(
|
|
||||||
lobbyInit: Pick<
|
|
||||||
GameLobbyInitDto,
|
|
||||||
'period' | 'runtime_enabled' | 'server_time'
|
|
||||||
>,
|
|
||||||
) {
|
|
||||||
if (!lobbyInit.period) {
|
|
||||||
return {
|
|
||||||
bettingClosesAt: toIsoFromUnixSeconds(lobbyInit.server_time),
|
|
||||||
id: '',
|
|
||||||
phase: 'waiting',
|
|
||||||
revealingAt: toIsoFromUnixSeconds(lobbyInit.server_time),
|
|
||||||
settledAt: null,
|
|
||||||
startedAt: toIsoFromUnixSeconds(lobbyInit.server_time),
|
|
||||||
winningCellId: null,
|
|
||||||
} satisfies RoundSnapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
bettingClosesAt: toIsoFromUnixSeconds(lobbyInit.period.lock_at),
|
|
||||||
id: lobbyInit.period.period_no,
|
|
||||||
phase: normalizeLobbyRoundPhase(
|
|
||||||
lobbyInit.period.status,
|
|
||||||
lobbyInit.runtime_enabled,
|
|
||||||
),
|
|
||||||
revealingAt: toIsoFromUnixSeconds(lobbyInit.period.open_at),
|
|
||||||
settledAt: toIsoFromUnixSeconds(lobbyInit.period.open_at),
|
|
||||||
startedAt: toIsoFromUnixSeconds(lobbyInit.server_time),
|
|
||||||
winningCellId: null,
|
|
||||||
} satisfies RoundSnapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePeriodTickRound(
|
export function normalizePeriodTickRound(
|
||||||
period: GamePeriodTickDto,
|
period: GamePeriodTickDto,
|
||||||
previousRound?: Pick<RoundSnapshot, 'id' | 'startedAt'> | null,
|
previousRound?: Pick<RoundSnapshot, 'id' | 'startedAt'> | null,
|
||||||
@@ -332,17 +303,12 @@ export function normalizePeriodTickRound(
|
|||||||
|
|
||||||
export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
|
export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
|
||||||
const baseIso = toIsoFromUnixSeconds(dto.server_time)
|
const baseIso = toIsoFromUnixSeconds(dto.server_time)
|
||||||
const template = createMockGameBootstrapSnapshot(baseIso)
|
const template = createEmptyGameBootstrapSnapshot(baseIso)
|
||||||
const cells = normalizeLobbyCells(dto.dictionary)
|
const cells = normalizeLobbyCells(dto.dictionary)
|
||||||
const chips = normalizeLobbyChips(
|
const chips = normalizeLobbyChips(
|
||||||
dto.bet_config.chips,
|
dto.bet_config.chips,
|
||||||
dto.bet_config.default_bet_chip_id,
|
dto.bet_config.default_bet_chip_id,
|
||||||
)
|
)
|
||||||
const round = normalizeLobbyRound({
|
|
||||||
period: null,
|
|
||||||
runtime_enabled: dto.runtime_enabled,
|
|
||||||
server_time: dto.server_time,
|
|
||||||
})
|
|
||||||
const trends = deriveTrendEntries([])
|
const trends = deriveTrendEntries([])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -355,12 +321,6 @@ export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
|
|||||||
chips: chips.length > 0 ? chips : template.chips,
|
chips: chips.length > 0 ? chips : template.chips,
|
||||||
connection: {
|
connection: {
|
||||||
...template.connection,
|
...template.connection,
|
||||||
connectedAt: null,
|
|
||||||
lastError: null,
|
|
||||||
lastMessageAt: null,
|
|
||||||
latencyMs: null,
|
|
||||||
reconnectAttempt: 0,
|
|
||||||
status: 'idle',
|
|
||||||
transport: 'polling',
|
transport: 'polling',
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
@@ -378,7 +338,7 @@ export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
|
|||||||
dto.bet_config.pick_max_number_count > 0
|
dto.bet_config.pick_max_number_count > 0
|
||||||
? Math.min(36, Math.floor(dto.bet_config.pick_max_number_count))
|
? Math.min(36, Math.floor(dto.bet_config.pick_max_number_count))
|
||||||
: GAME_MAX_SELECTION_CELLS,
|
: GAME_MAX_SELECTION_CELLS,
|
||||||
round,
|
round: template.round,
|
||||||
selections: [],
|
selections: [],
|
||||||
trends,
|
trends,
|
||||||
} satisfies GameBootstrapSnapshot
|
} satisfies GameBootstrapSnapshot
|
||||||
@@ -534,10 +494,17 @@ export async function getGameBetMyOrders(params: {
|
|||||||
return dto
|
return dto
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMockGameBootstrap(latencyMs = 120) {
|
export async function placeGameBet(payload: GamePlaceBetRequestDto) {
|
||||||
await new Promise((resolve) => {
|
const response = await api.post<GamePlaceBetDto>(
|
||||||
setTimeout(resolve, latencyMs)
|
GAME_API_ENDPOINTS.placeBet,
|
||||||
})
|
{
|
||||||
|
json: payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const dto = unwrapGameEnvelope(
|
||||||
|
response as ApiResponse<GamePlaceBetDto>,
|
||||||
|
'Failed to place game bet',
|
||||||
|
)
|
||||||
|
|
||||||
return createMockGameBootstrapSnapshot()
|
return dto
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,23 @@ export interface GameBetOrdersDto {
|
|||||||
pagination: GameBetOrdersPaginationDto
|
pagination: GameBetOrdersPaginationDto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GamePlaceBetRequestDto {
|
||||||
|
bet_id: number
|
||||||
|
idempotency_key: string
|
||||||
|
numbers: string
|
||||||
|
period_no: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePlaceBetDto {
|
||||||
|
balance_after: string
|
||||||
|
current_streak: number
|
||||||
|
locked_balance?: string
|
||||||
|
numbers_count: number
|
||||||
|
order_no: string
|
||||||
|
period_no: string
|
||||||
|
status: 'accepted' | 'rejected' | (string & {})
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AnnouncementState,
|
AnnouncementState,
|
||||||
Chip,
|
Chip,
|
||||||
|
|||||||
19
src/features/game/audio/audio-config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import hallMusic from '@/assets/music/hall-music.mp3'
|
||||||
|
|
||||||
|
export type AudioAssetId = 'hall-bgm'
|
||||||
|
|
||||||
|
export type AudioAssetDefinition = {
|
||||||
|
id: AudioAssetId
|
||||||
|
loop?: boolean
|
||||||
|
src: string
|
||||||
|
volume?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUDIO_ASSET_DEFINITIONS: AudioAssetDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'hall-bgm',
|
||||||
|
src: hallMusic,
|
||||||
|
loop: true,
|
||||||
|
volume: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
103
src/features/game/audio/global-audio-controller.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUDIO_ASSET_DEFINITIONS,
|
||||||
|
type AudioAssetDefinition,
|
||||||
|
} from '@/features/game/audio/audio-config'
|
||||||
|
import { useAudioStore } from '@/store'
|
||||||
|
|
||||||
|
function createAudioInstance(definition: AudioAssetDefinition) {
|
||||||
|
const audio = new Audio(definition.src)
|
||||||
|
audio.preload = 'auto'
|
||||||
|
audio.loop = definition.loop ?? false
|
||||||
|
audio.volume = definition.volume ?? 1
|
||||||
|
|
||||||
|
return audio
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalAudioController() {
|
||||||
|
const hasUnlockedSoundPlayback = useAudioStore(
|
||||||
|
(state) => state.hasUnlockedSoundPlayback,
|
||||||
|
)
|
||||||
|
const isSoundEnabled = useAudioStore((state) => state.isSoundEnabled)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audioEntries = AUDIO_ASSET_DEFINITIONS.map((definition) => ({
|
||||||
|
audio: createAudioInstance(definition),
|
||||||
|
definition,
|
||||||
|
}))
|
||||||
|
|
||||||
|
let isDisposed = false
|
||||||
|
let detachResumeListeners: (() => void) | null = null
|
||||||
|
|
||||||
|
const stopAllAudio = () => {
|
||||||
|
audioEntries.forEach(({ audio }) => {
|
||||||
|
audio.pause()
|
||||||
|
audio.currentTime = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const playEnabledAudio = async () => {
|
||||||
|
const audioState = useAudioStore.getState()
|
||||||
|
|
||||||
|
if (
|
||||||
|
isDisposed ||
|
||||||
|
!audioState.hasUnlockedSoundPlayback ||
|
||||||
|
!audioState.isSoundEnabled
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const playResults = await Promise.allSettled(
|
||||||
|
audioEntries.map(async ({ audio }) => {
|
||||||
|
audio.currentTime = 0
|
||||||
|
await audio.play()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasBlockedAudio = playResults.some(
|
||||||
|
(result) => result.status === 'rejected',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasBlockedAudio || detachResumeListeners) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumePlayback = () => {
|
||||||
|
detachResumeListeners?.()
|
||||||
|
detachResumeListeners = null
|
||||||
|
void playEnabledAudio()
|
||||||
|
}
|
||||||
|
|
||||||
|
const events: Array<keyof WindowEventMap> = [
|
||||||
|
'pointerdown',
|
||||||
|
'keydown',
|
||||||
|
'touchstart',
|
||||||
|
]
|
||||||
|
|
||||||
|
events.forEach((eventName) => {
|
||||||
|
window.addEventListener(eventName, resumePlayback, { once: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
detachResumeListeners = () => {
|
||||||
|
events.forEach((eventName) => {
|
||||||
|
window.removeEventListener(eventName, resumePlayback)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnlockedSoundPlayback && isSoundEnabled) {
|
||||||
|
void playEnabledAudio()
|
||||||
|
} else {
|
||||||
|
stopAllAudio()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isDisposed = true
|
||||||
|
detachResumeListeners?.()
|
||||||
|
stopAllAudio()
|
||||||
|
}
|
||||||
|
}, [hasUnlockedSoundPlayback, isSoundEnabled])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import diamondIcon from '@/assets/system/diamond.webp'
|
|||||||
import { SmartImage } from '@/components/smart-image'
|
import { SmartImage } from '@/components/smart-image'
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuthStore, useModalStore } from '@/store'
|
import { useAudioStore, useAuthStore, useModalStore } from '@/store'
|
||||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||||
|
|
||||||
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
|
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
|
||||||
@@ -72,6 +72,9 @@ export function DesktopAnimal({
|
|||||||
}: DesktopAnimalProps) {
|
}: DesktopAnimalProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const authStatus = useAuthStore((state) => state.status)
|
const authStatus = useAuthStore((state) => state.status)
|
||||||
|
const markSoundPlaybackUnlocked = useAudioStore(
|
||||||
|
(state) => state.markSoundPlaybackUnlocked,
|
||||||
|
)
|
||||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||||
const chips = useGameRoundStore((state) => state.chips)
|
const chips = useGameRoundStore((state) => state.chips)
|
||||||
@@ -132,6 +135,7 @@ export function DesktopAnimal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearSelections()
|
clearSelections()
|
||||||
|
markSoundPlaybackUnlocked()
|
||||||
requestRealtimeConnection()
|
requestRealtimeConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import add from '@/assets/game/add.webp'
|
|||||||
import arrow from '@/assets/game/arrow.webp'
|
import arrow from '@/assets/game/arrow.webp'
|
||||||
import chipBg from '@/assets/game/chip-bg.webp'
|
import chipBg from '@/assets/game/chip-bg.webp'
|
||||||
import chipLineBg from '@/assets/game/chip-line-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 controlBg from '@/assets/game/control-bg.png'
|
||||||
import leftBottomBg from '@/assets/game/left-bg.webp'
|
import leftBottomBg from '@/assets/game/left-bg.webp'
|
||||||
import reduce from '@/assets/game/reduce.webp'
|
import reduce from '@/assets/game/reduce.webp'
|
||||||
@@ -21,9 +22,14 @@ export function DesktopControl() {
|
|||||||
const {
|
const {
|
||||||
canClear,
|
canClear,
|
||||||
chips,
|
chips,
|
||||||
|
confirmLabel,
|
||||||
|
confirmState,
|
||||||
|
isConfirmClickable,
|
||||||
maxSelectionCountLabel,
|
maxSelectionCountLabel,
|
||||||
onChipSelect,
|
onChipSelect,
|
||||||
|
onConfirm,
|
||||||
onClearSelections,
|
onClearSelections,
|
||||||
|
onRepeatSelections,
|
||||||
selectedChipAmountLabel,
|
selectedChipAmountLabel,
|
||||||
selectedChipId,
|
selectedChipId,
|
||||||
selectedCountLabel,
|
selectedCountLabel,
|
||||||
@@ -43,6 +49,10 @@ export function DesktopControl() {
|
|||||||
onClearSelections()
|
onClearSelections()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (id === 'repeat') {
|
||||||
|
onRepeatSelections()
|
||||||
|
}
|
||||||
|
|
||||||
setClickedId(id)
|
setClickedId(id)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setClickedId(null)
|
setClickedId(null)
|
||||||
@@ -52,15 +62,21 @@ export function DesktopControl() {
|
|||||||
}, 180)
|
}, 180)
|
||||||
}, 200)
|
}, 200)
|
||||||
},
|
},
|
||||||
[canClear, onClearSelections],
|
[canClear, onClearSelections, onRepeatSelections],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleConfirmClick = useCallback(() => {
|
const handleConfirmClick = useCallback(() => {
|
||||||
|
if (!isConfirmClickable) {
|
||||||
|
void onConfirm()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setConfirmClicked(true)
|
setConfirmClicked(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setConfirmClicked(false)
|
setConfirmClicked(false)
|
||||||
}, 200)
|
}, 200)
|
||||||
}, [])
|
void onConfirm()
|
||||||
|
}, [isConfirmClickable, onConfirm])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -363,14 +379,46 @@ export function DesktopControl() {
|
|||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
as={motion.button}
|
as={motion.button}
|
||||||
src={confirmBg}
|
src={confirmState === 'insufficient' ? confirmRedBg : confirmBg}
|
||||||
size="100% 100%"
|
size="100% 100%"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleConfirmClick}
|
onClick={handleConfirmClick}
|
||||||
whileHover={{ scale: 1.01 }}
|
whileHover={isConfirmClickable ? { scale: 1.01 } : undefined}
|
||||||
whileTap={{ scale: 0.96 }}
|
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(
|
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 && (
|
{confirmClicked && (
|
||||||
@@ -381,16 +429,46 @@ export function DesktopControl() {
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="pointer-events-none absolute inset-0 bg-center bg-no-repeat"
|
className="pointer-events-none absolute inset-0 bg-center bg-no-repeat"
|
||||||
src={confirmBg}
|
src={confirmState === 'insufficient' ? confirmRedBg : confirmBg}
|
||||||
size="100% 100%"
|
size="100% 100%"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<motion.span
|
<motion.span
|
||||||
animate={confirmClicked ? { opacity: 0, y: 2 } : { opacity: 1, y: 0 }}
|
animate={
|
||||||
transition={{ duration: 0.15 }}
|
confirmState === 'ready'
|
||||||
className="relative"
|
? {
|
||||||
|
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>
|
</motion.span>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
import { useCallback, useRef } from 'react'
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import historyBg from '@/assets/system/history-bg.png'
|
import historyBg from '@/assets/system/history-bg.png'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
@@ -20,35 +19,20 @@ export function DesktopGameHistory() {
|
|||||||
} = useGameHistoryVm()
|
} = useGameHistoryVm()
|
||||||
const parentRef = useRef<HTMLDivElement | null>(null)
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const rowCount = hasNextPage ? items.length + 1 : items.length
|
const handleScroll = useCallback(() => {
|
||||||
const virtualizer = useVirtualizer({
|
const element = parentRef.current
|
||||||
count: rowCount,
|
|
||||||
estimateSize: () => 196,
|
|
||||||
getScrollElement: () => parentRef.current,
|
|
||||||
overscan: 4,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (!element || !hasNextPage || isFetchingNextPage) {
|
||||||
const virtualItems = virtualizer.getVirtualItems()
|
|
||||||
const lastItem = virtualItems[virtualItems.length - 1]
|
|
||||||
|
|
||||||
if (
|
|
||||||
!lastItem ||
|
|
||||||
!hasNextPage ||
|
|
||||||
isFetchingNextPage ||
|
|
||||||
lastItem.index < items.length - 1
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const distanceToBottom =
|
||||||
|
element.scrollHeight - element.scrollTop - element.clientHeight
|
||||||
|
|
||||||
|
if (distanceToBottom <= 120) {
|
||||||
void fetchNextPage()
|
void fetchNextPage()
|
||||||
}, [
|
}
|
||||||
fetchNextPage,
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
items.length,
|
|
||||||
virtualizer,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
@@ -65,6 +49,7 @@ export function DesktopGameHistory() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
className={
|
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'
|
'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,20 +71,9 @@ export function DesktopGameHistory() {
|
|||||||
{emptyText}
|
{emptyText}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<>
|
||||||
className="relative w-full"
|
{items.map((item) => (
|
||||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
<div key={item.id} className="w-full pb-design-12 last:pb-0">
|
||||||
>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
||||||
const item = items[virtualRow.index]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item?.id ?? `loader-${virtualRow.index}`}
|
|
||||||
className="absolute left-0 top-0 w-full"
|
|
||||||
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
|
||||||
>
|
|
||||||
{item ? (
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
||||||
@@ -121,17 +95,13 @@ export function DesktopGameHistory() {
|
|||||||
<span className={'text-[#84A2A2]'}>
|
<span className={'text-[#84A2A2]'}>
|
||||||
{t('gameDesktop.history.orderNo')}:{' '}
|
{t('gameDesktop.history.orderNo')}:{' '}
|
||||||
</span>
|
</span>
|
||||||
<span className={'text-[#C0E7EB]'}>
|
<span className={'text-[#C0E7EB]'}>{item.orderNo}</span>
|
||||||
{item.orderNo}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className={'text-[#84A2A2]'}>
|
<span className={'text-[#84A2A2]'}>
|
||||||
{t('gameDesktop.history.roundId')}:{' '}
|
{t('gameDesktop.history.roundId')}:{' '}
|
||||||
</span>
|
</span>
|
||||||
<span className={'text-[#C0E7EB]'}>
|
<span className={'text-[#C0E7EB]'}>{item.periodNo}</span>
|
||||||
{item.periodNo}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className={'text-[#84A2A2]'}>
|
<span className={'text-[#84A2A2]'}>
|
||||||
@@ -169,15 +139,12 @@ export function DesktopGameHistory() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex h-[calc(var(--design-unit)*60)] items-center justify-center text-design-16 text-[#84A2A2]">
|
|
||||||
{isFetchingNextPage ? loadingText : endText}
|
|
||||||
</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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import avatar from '@/assets/system/avatar.webp'
|
import avatar from '@/assets/system/avatar.webp'
|
||||||
import diamond from '@/assets/system/diamond.webp'
|
import diamond from '@/assets/system/diamond.webp'
|
||||||
import logo from '@/assets/system/logo.webp'
|
import logo from '@/assets/system/logo.webp'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
|
||||||
import {
|
import {
|
||||||
isDesktopFullscreen,
|
isDesktopFullscreen,
|
||||||
subscribeDesktopFullscreenChange,
|
subscribeDesktopFullscreenChange,
|
||||||
toggleDesktopFullscreen,
|
toggleDesktopFullscreen,
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
import { useAuthStore, useGameSessionStore, useModalStore } from '@/store'
|
import {
|
||||||
|
useAudioStore,
|
||||||
|
useAuthStore,
|
||||||
|
useGameSessionStore,
|
||||||
|
useModalStore,
|
||||||
|
} from '@/store'
|
||||||
|
|
||||||
type BrowserNetworkInformation = {
|
type BrowserNetworkInformation = {
|
||||||
addEventListener?: (type: 'change', listener: () => void) => void
|
addEventListener?: (type: 'change', listener: () => void) => void
|
||||||
@@ -155,8 +168,11 @@ export function DesktopHeader() {
|
|||||||
)
|
)
|
||||||
const currentUser = useAuthStore((state) => state.currentUser)
|
const currentUser = useAuthStore((state) => state.currentUser)
|
||||||
const authStatus = useAuthStore((state) => state.status)
|
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 connection = useGameSessionStore((state) => state.connection)
|
||||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
|
||||||
|
|
||||||
const serverClockOffsetMs = useMemo(() => {
|
const serverClockOffsetMs = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
@@ -286,24 +302,50 @@ export function DesktopHeader() {
|
|||||||
</div>
|
</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="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} />
|
<CircleAlert color={'#57B8BF'} size={16} />
|
||||||
<div>{t('gameDesktop.header.rules')}</div>
|
<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">
|
<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} />
|
<Mail color={'#57B8BF'} size={16} />
|
||||||
<div>{t('gameDesktop.header.message')}</div>
|
<div>{t('gameDesktop.header.message')}</div>
|
||||||
</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">
|
<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} />
|
<Volume2 color={'#57B8BF'} size={16} />
|
||||||
|
) : (
|
||||||
|
<VolumeX color={'#57B8BF'} size={16} />
|
||||||
|
)}
|
||||||
<div>{t('gameDesktop.header.bgm')}</div>
|
<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">
|
<div className={'flex items-center justify-center'}>
|
||||||
<CircleAlert color={'#57B8BF'} size={16} />
|
<button
|
||||||
<div>{t('gameDesktop.header.id')}</div>
|
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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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 { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
|
||||||
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal'
|
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal'
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { DesktopControl } from '@/features/game/components/desktop/desktop-contr
|
|||||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||||
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
||||||
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
|
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
|
||||||
|
import DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language-modal.tsx'
|
||||||
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
|
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
|
||||||
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
|
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
|
||||||
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
|
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
|
||||||
|
import DesktopProtocolModal from '@/features/game/modal/desktop/desktop-protocol-modal.tsx'
|
||||||
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
|
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
|
||||||
|
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
|
||||||
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
|
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
|
||||||
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
||||||
|
|
||||||
@@ -43,12 +46,25 @@ export function PcEntry() {
|
|||||||
>
|
>
|
||||||
<DesktopControl />
|
<DesktopControl />
|
||||||
</div>
|
</div>
|
||||||
|
{/* 桌面端登录弹窗:用于未登录用户进入登录流程 */}
|
||||||
<DesktopLoginModal />
|
<DesktopLoginModal />
|
||||||
|
{/* 桌面端注册弹窗:用于新用户注册账号 */}
|
||||||
<DesktopRegisterModal />
|
<DesktopRegisterModal />
|
||||||
|
{/* 桌面端语言切换弹窗:用于选择当前站点展示语言 */}
|
||||||
|
<DesktopLanguageModal />
|
||||||
|
{/* 桌面端协议弹窗:首次进入站点时强制同意协议后才可继续 */}
|
||||||
|
<DesktopProtocolModal />
|
||||||
|
{/* 桌面端规则弹窗:展示当前游戏玩法、下注与结算规则 */}
|
||||||
|
<DesktopRulesModal />
|
||||||
|
{/* 桌面端用户信息弹窗:展示个人资料与站内消息 */}
|
||||||
<DesktopUserInfoModal />
|
<DesktopUserInfoModal />
|
||||||
|
{/* 桌面端公告弹窗:展示活动公告或运营通知内容 */}
|
||||||
<DesktopNoticeModal />
|
<DesktopNoticeModal />
|
||||||
|
{/* 桌面端自动托管弹窗:配置自动托管相关条件 */}
|
||||||
<DesktopAutoSettingModal />
|
<DesktopAutoSettingModal />
|
||||||
|
{/* 桌面端充值/提现前置选择弹窗:先选择进入充值还是提现 */}
|
||||||
<DesktopProceduresModal />
|
<DesktopProceduresModal />
|
||||||
|
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
|
||||||
<DesktopWithdrawTopupModal />
|
<DesktopWithdrawTopupModal />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
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 { 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'
|
import { selectSelectionTotal, useGameRoundStore } from '@/store/game'
|
||||||
|
|
||||||
|
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'submitting'
|
||||||
|
|
||||||
function formatChipDisplayValue(amount: number) {
|
function formatChipDisplayValue(amount: number) {
|
||||||
if (Number.isInteger(amount)) {
|
if (Number.isInteger(amount)) {
|
||||||
return String(amount)
|
return String(amount)
|
||||||
@@ -10,16 +16,66 @@ function formatChipDisplayValue(amount: number) {
|
|||||||
return amount.toFixed(2).replace(/\.?0+$/, '')
|
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() {
|
export function useGameControlVm() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const chips = useGameRoundStore((state) => state.chips)
|
const chips = useGameRoundStore((state) => state.chips)
|
||||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||||
|
const round = useGameRoundStore((state) => state.round)
|
||||||
const maxSelectionCount = useGameRoundStore(
|
const maxSelectionCount = useGameRoundStore(
|
||||||
(state) => state.maxSelectionCount,
|
(state) => state.maxSelectionCount,
|
||||||
)
|
)
|
||||||
const selections = useGameRoundStore((state) => state.selections)
|
const selections = useGameRoundStore((state) => state.selections)
|
||||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
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 selectChip = useGameRoundStore((state) => state.selectChip)
|
||||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
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 chipItems = useMemo(() => {
|
||||||
const items = chips.map((chip) => ({
|
const items = chips.map((chip) => ({
|
||||||
@@ -41,11 +97,160 @@ export function useGameControlVm() {
|
|||||||
|
|
||||||
const selectedChip =
|
const selectedChip =
|
||||||
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
|
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 {
|
return {
|
||||||
canClear: selections.length > 0,
|
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,
|
onChipSelect: selectChip,
|
||||||
|
onConfirm: handleConfirm,
|
||||||
onClearSelections: clearSelections,
|
onClearSelections: clearSelections,
|
||||||
|
onRepeatSelections: handleRepeatSelections,
|
||||||
maxSelectionCountLabel: maxSelectionCount,
|
maxSelectionCountLabel: maxSelectionCount,
|
||||||
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
|
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
|
||||||
selectedChipId: activeChipId,
|
selectedChipId: activeChipId,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { getGameBetMyOrders } from '@/features/game/api/game-api'
|
import { getGameBetMyOrders } from '@/features/game/api/game-api'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useGameRoundStore } from '@/store/game'
|
||||||
|
|
||||||
const GAME_HISTORY_PAGE_SIZE = 20
|
const GAME_HISTORY_PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -36,6 +37,9 @@ export function useGameHistoryVm() {
|
|||||||
const { i18n, t } = useTranslation()
|
const { i18n, t } = useTranslation()
|
||||||
const accessToken = useAuthStore((state) => state.accessToken)
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
const authStatus = useAuthStore((state) => state.status)
|
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({
|
const query = useInfiniteQuery({
|
||||||
queryKey: ['game', 'bet-my-orders', accessToken],
|
queryKey: ['game', 'bet-my-orders', accessToken],
|
||||||
@@ -79,6 +83,47 @@ export function useGameHistoryVm() {
|
|||||||
[i18n.resolvedLanguage, query.data?.pages],
|
[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 {
|
return {
|
||||||
emptyText: t('gameDesktop.history.empty'),
|
emptyText: t('gameDesktop.history.empty'),
|
||||||
endText: t('gameDesktop.history.end'),
|
endText: t('gameDesktop.history.end'),
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/features/game/modal/desktop/desktop-language-modal.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
function DesktopLanguageModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopLanguage)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const { currentLanguage, languageOptions, selectLanguage } = useAppLanguage()
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setModalOpen('desktopLanguage', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectLanguage = async (
|
||||||
|
language: (typeof languageOptions)[number]['code'],
|
||||||
|
) => {
|
||||||
|
await selectLanguage(language)
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenterModal
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={
|
||||||
|
<div className={'modal-title-glow text-design-30'}>
|
||||||
|
{t('language.label')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
titleAlign="center"
|
||||||
|
className="h-design-560 w-design-620"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col px-design-24 pb-design-28 pt-design-10">
|
||||||
|
<div className="grid flex-1 grid-cols-2 gap-design-16">
|
||||||
|
{languageOptions.map((option: (typeof languageOptions)[number]) => {
|
||||||
|
const isActive = option.code === currentLanguage
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSelectLanguage(option.code)}
|
||||||
|
className={cn(
|
||||||
|
'group relative flex h-full min-h-design-150 w-full flex-col justify-between overflow-hidden rounded-[18px] border px-design-18 py-design-18 text-left transition-all duration-200',
|
||||||
|
isActive
|
||||||
|
? 'border-[#8BF5FF] bg-[linear-gradient(180deg,rgba(22,64,80,0.94),rgba(7,21,31,0.96))] shadow-[inset_0_0_18px_rgba(128,223,231,0.55),0_0_22px_rgba(66,227,255,0.2)]'
|
||||||
|
: 'border-[#62BFC8]/45 bg-[linear-gradient(180deg,rgba(10,30,43,0.92),rgba(4,13,21,0.94))] shadow-[inset_0_0_14px_rgba(128,223,231,0.18)] hover:border-[#86EFFF]/80 hover:shadow-[inset_0_0_18px_rgba(128,223,231,0.3),0_0_18px_rgba(66,227,255,0.12)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-200',
|
||||||
|
isActive
|
||||||
|
? 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.22),transparent_42%)] opacity-100'
|
||||||
|
: 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.14),transparent_42%)] group-hover:opacity-100',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex items-start justify-between gap-design-12">
|
||||||
|
<SmartImage
|
||||||
|
src={option.icon}
|
||||||
|
alt={t(option.labelKey)}
|
||||||
|
className="h-design-32 w-design-32 shrink-0 rounded-[10px] object-cover shadow-[0_8px_18px_rgba(0,0,0,0.28)]"
|
||||||
|
/>
|
||||||
|
{isActive ? (
|
||||||
|
<div className="rounded-full border border-[#8BF5FF]/55 bg-[#8BF5FF]/18 px-design-12 py-design-6 text-design-14 font-semibold uppercase tracking-[0.14em] text-[#C9FCFF] shadow-[0_0_14px_rgba(66,227,255,0.18)]">
|
||||||
|
{t('gameDesktop.control.selected')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-design-18">
|
||||||
|
<div className="text-design-24 font-semibold text-[#F3FFFF]">
|
||||||
|
{t(option.labelKey)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-design-8 text-design-15 uppercase tracking-[0.2em] text-[#7EDAE3]">
|
||||||
|
{option.code}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-design-16 h-px w-full bg-[linear-gradient(90deg,rgba(128,223,231,0),rgba(128,223,231,0.65),rgba(128,223,231,0))]" />
|
||||||
|
|
||||||
|
<div className="relative mt-design-12 flex items-center justify-between text-design-15 text-[#98D6DC]">
|
||||||
|
<span>{t('language.label')}</span>
|
||||||
|
<span className="text-[#D8FDFF]">{option.code}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DesktopLanguageModal
|
||||||
76
src/features/game/modal/desktop/desktop-protocol-modal.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||||
|
import rightImg from '@/assets/system/right.webp'
|
||||||
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { useProtocolAgreement } from '@/features/game/hooks/use-protocol-agreement'
|
||||||
|
|
||||||
|
function DesktopProtocolModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { acceptProtocol, isHydrated, open } = useProtocolAgreement()
|
||||||
|
const [isChecked, setIsChecked] = useState(false)
|
||||||
|
|
||||||
|
if (!isHydrated) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenterModal
|
||||||
|
open={open}
|
||||||
|
title={
|
||||||
|
<div className={'modal-title-glow text-design-28'}>
|
||||||
|
{t('game.modals.protocol.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
titleAlign="center"
|
||||||
|
isShowClose={false}
|
||||||
|
className={'w-design-980 h-design-680'}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col gap-design-24 px-design-28 pb-design-30 pt-design-10">
|
||||||
|
<div className="flex-1 rounded-[12px] bg-black/35 p-design-18 text-design-18 leading-[1.8] text-[#B9E7EA]">
|
||||||
|
<div className="h-full overflow-y-auto whitespace-pre-line">
|
||||||
|
{t('game.modals.protocol.content')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsChecked((value) => !value)}
|
||||||
|
className="flex items-center justify-center gap-design-14 self-center text-design-20 text-white"
|
||||||
|
>
|
||||||
|
<span className="flex h-design-34 w-design-34 items-center justify-center rounded-[6px] border border-[#80DFE7] bg-slate-950/60">
|
||||||
|
{isChecked ? (
|
||||||
|
<SmartImage
|
||||||
|
src={rightImg}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-design-22 w-design-28 object-contain"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span>{t('game.modals.protocol.agreeLabel')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
src={lengthBlueBtn}
|
||||||
|
size="100% 90%"
|
||||||
|
repeat="no-repeat"
|
||||||
|
position="center"
|
||||||
|
onClick={isChecked ? acceptProtocol : undefined}
|
||||||
|
className="modal-title-glow flex h-design-72 w-design-270 items-center justify-center pb-design-5 text-design-20 font-bold disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
disabled={!isChecked}
|
||||||
|
>
|
||||||
|
{t('game.modals.protocol.confirm')}
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DesktopProtocolModal
|
||||||
52
src/features/game/modal/desktop/desktop-rules-modal.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||||
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
function DesktopRulesModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopRules)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setModalOpen('desktopRules', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenterModal
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={
|
||||||
|
<div className={'modal-title-glow text-design-28'}>
|
||||||
|
{t('game.modals.rules.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
titleAlign="center"
|
||||||
|
className={'w-design-1040 h-design-720'}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col gap-design-24 px-design-28 pb-design-30 pt-design-10">
|
||||||
|
<div className="flex-1 overflow-y-auto rounded-[12px] bg-black/35 p-design-20 text-design-18 leading-[1.8] text-[#B9E7EA] whitespace-pre-line">
|
||||||
|
{t('game.modals.rules.content')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
src={lengthBlueBtn}
|
||||||
|
size="100% 90%"
|
||||||
|
repeat="no-repeat"
|
||||||
|
position="center"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="modal-title-glow flex h-design-72 w-design-270 items-center justify-center pb-design-5 text-design-20 font-bold"
|
||||||
|
>
|
||||||
|
{t('game.modals.rules.confirm')}
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DesktopRulesModal
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export * from './constants'
|
export * from './constants'
|
||||||
export * from './mock-data'
|
export * from './initial-state'
|
||||||
export * from './selectors'
|
export * from './selectors'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|||||||
81
src/features/game/shared/initial-state.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { DEFAULT_CHIP_AMOUNTS } from '@/constants'
|
||||||
|
import { DEFAULT_GAME_CHIP_COLORS, GAME_MAX_SELECTION_CELLS } from './constants'
|
||||||
|
import type {
|
||||||
|
AnnouncementState,
|
||||||
|
Chip,
|
||||||
|
ConnectionState,
|
||||||
|
DashboardState,
|
||||||
|
GameBootstrapSnapshot,
|
||||||
|
RoundSnapshot,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
function createEmptyRoundSnapshot(nowIso: string): RoundSnapshot {
|
||||||
|
return {
|
||||||
|
bettingClosesAt: nowIso,
|
||||||
|
id: '',
|
||||||
|
phase: 'waiting',
|
||||||
|
revealingAt: nowIso,
|
||||||
|
settledAt: null,
|
||||||
|
startedAt: nowIso,
|
||||||
|
winningCellId: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyAnnouncementState(): AnnouncementState {
|
||||||
|
return {
|
||||||
|
activeAnnouncementId: null,
|
||||||
|
items: [],
|
||||||
|
lastUpdatedAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyConnectionState(): ConnectionState {
|
||||||
|
return {
|
||||||
|
connectedAt: null,
|
||||||
|
lastError: null,
|
||||||
|
lastMessageAt: null,
|
||||||
|
latencyMs: null,
|
||||||
|
reconnectAttempt: 0,
|
||||||
|
status: 'idle',
|
||||||
|
transport: 'offline',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyDashboardState(nowIso: string): DashboardState {
|
||||||
|
return {
|
||||||
|
countdownMs: 0,
|
||||||
|
featuredCellId: null,
|
||||||
|
onlinePlayers: 0,
|
||||||
|
tableLimitMax: 0,
|
||||||
|
tableLimitMin: 0,
|
||||||
|
totalPoolAmount: 0,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultChips(): Chip[] {
|
||||||
|
return DEFAULT_CHIP_AMOUNTS.map((chip, index) => ({
|
||||||
|
amount: chip.amount,
|
||||||
|
color: DEFAULT_GAME_CHIP_COLORS[index] ?? DEFAULT_GAME_CHIP_COLORS[0],
|
||||||
|
id: chip.id,
|
||||||
|
isDefault: chip.id === 'chip-5',
|
||||||
|
label: String(chip.amount),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyGameBootstrapSnapshot(
|
||||||
|
nowIso = new Date().toISOString(),
|
||||||
|
): GameBootstrapSnapshot {
|
||||||
|
return {
|
||||||
|
announcements: createEmptyAnnouncementState(),
|
||||||
|
cells: [],
|
||||||
|
chips: createDefaultChips(),
|
||||||
|
connection: createEmptyConnectionState(),
|
||||||
|
dashboard: createEmptyDashboardState(nowIso),
|
||||||
|
history: [],
|
||||||
|
maxSelectionCount: GAME_MAX_SELECTION_CELLS,
|
||||||
|
round: createEmptyRoundSnapshot(nowIso),
|
||||||
|
selections: [],
|
||||||
|
trends: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { DEFAULT_CHIP_AMOUNTS } from '@/constants'
|
|
||||||
import {
|
|
||||||
DEFAULT_ACTIVE_CHIP_ID,
|
|
||||||
DEFAULT_ANNOUNCEMENT_TTL_MS,
|
|
||||||
DEFAULT_GAME_CHIP_COLORS,
|
|
||||||
GAME_GRID_COLUMNS,
|
|
||||||
GAME_MAX_SELECTION_CELLS,
|
|
||||||
GAME_TOTAL_CELLS,
|
|
||||||
} from './constants'
|
|
||||||
import { deriveTrendEntries, getRoundCountdownMs } from './selectors'
|
|
||||||
import type {
|
|
||||||
AnnouncementState,
|
|
||||||
BetSelection,
|
|
||||||
Chip,
|
|
||||||
ConnectionState,
|
|
||||||
DashboardState,
|
|
||||||
GameBootstrapSnapshot,
|
|
||||||
GameCell,
|
|
||||||
HistoryEntry,
|
|
||||||
RoundSnapshot,
|
|
||||||
} from './types'
|
|
||||||
|
|
||||||
const MOCK_GAME_BASE_TIME = '2026-04-23T12:00:00.000Z'
|
|
||||||
const MOCK_HISTORY_RESULTS = [8, 12, 12, 4, 31, 9, 17, 22, 17, 5, 28, 13]
|
|
||||||
|
|
||||||
function offsetIso(baseIso: string, offsetMs: number) {
|
|
||||||
return new Date(Date.parse(baseIso) + offsetMs).toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGameCells() {
|
|
||||||
return Array.from({ length: GAME_TOTAL_CELLS }, (_, index) => {
|
|
||||||
const id = index + 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
column: (index % GAME_GRID_COLUMNS) + 1,
|
|
||||||
id,
|
|
||||||
label: String(id).padStart(2, '0'),
|
|
||||||
odds: 36,
|
|
||||||
row: Math.floor(index / GAME_GRID_COLUMNS) + 1,
|
|
||||||
} satisfies GameCell
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultChips() {
|
|
||||||
return DEFAULT_CHIP_AMOUNTS.map((chip, index) => ({
|
|
||||||
amount: chip.amount,
|
|
||||||
color: DEFAULT_GAME_CHIP_COLORS[index],
|
|
||||||
id: chip.id,
|
|
||||||
isDefault: chip.id === DEFAULT_ACTIVE_CHIP_ID,
|
|
||||||
label: chip.amount >= 100 ? `${chip.amount / 100}x` : String(chip.amount),
|
|
||||||
})) satisfies Chip[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMockHistoryEntries(baseIso = MOCK_GAME_BASE_TIME) {
|
|
||||||
return MOCK_HISTORY_RESULTS.map((winningCellId, index) => {
|
|
||||||
const settledAt = offsetIso(baseIso, -(index + 1) * 30_000)
|
|
||||||
|
|
||||||
return {
|
|
||||||
payoutMultiplier: 36,
|
|
||||||
roundId: `round-${6200 - index}`,
|
|
||||||
settledAt,
|
|
||||||
totalPoolAmount: 12_000 + index * 850,
|
|
||||||
winningCellId,
|
|
||||||
} satisfies HistoryEntry
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMockRoundSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
|
|
||||||
return {
|
|
||||||
bettingClosesAt: offsetIso(baseIso, 18_000),
|
|
||||||
id: 'round-6201',
|
|
||||||
phase: 'betting',
|
|
||||||
revealingAt: offsetIso(baseIso, 24_000),
|
|
||||||
settledAt: offsetIso(baseIso, 30_000),
|
|
||||||
startedAt: baseIso,
|
|
||||||
winningCellId: null,
|
|
||||||
} satisfies RoundSnapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMockBetSelections() {
|
|
||||||
return [] satisfies BetSelection[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMockAnnouncementState(baseIso = MOCK_GAME_BASE_TIME) {
|
|
||||||
return {
|
|
||||||
activeAnnouncementId: 'announcement-maintenance',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
createdAt: offsetIso(baseIso, -20_000),
|
|
||||||
expiresAt: offsetIso(baseIso, DEFAULT_ANNOUNCEMENT_TTL_MS),
|
|
||||||
id: 'announcement-maintenance',
|
|
||||||
isPinned: true,
|
|
||||||
isRead: false,
|
|
||||||
message: 'Realtime sync upgrades finish after the current cycle.',
|
|
||||||
title: 'Table maintenance',
|
|
||||||
tone: 'warning',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
createdAt: offsetIso(baseIso, -55_000),
|
|
||||||
expiresAt: null,
|
|
||||||
id: 'announcement-promo',
|
|
||||||
isRead: true,
|
|
||||||
message: 'Warm-up round rebates are credited every 5 settled rounds.',
|
|
||||||
title: 'Reward window live',
|
|
||||||
tone: 'success',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
lastUpdatedAt: offsetIso(baseIso, -10_000),
|
|
||||||
} satisfies AnnouncementState
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMockDashboardState(
|
|
||||||
baseIso = MOCK_GAME_BASE_TIME,
|
|
||||||
round = createMockRoundSnapshot(baseIso),
|
|
||||||
history = createMockHistoryEntries(baseIso),
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
countdownMs: getRoundCountdownMs(round, baseIso),
|
|
||||||
featuredCellId: history[0]?.winningCellId ?? null,
|
|
||||||
onlinePlayers: 1_284,
|
|
||||||
tableLimitMax: 5_000,
|
|
||||||
tableLimitMin: 10,
|
|
||||||
totalPoolAmount: 84_300,
|
|
||||||
updatedAt: baseIso,
|
|
||||||
} satisfies DashboardState
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMockConnectionState(baseIso = MOCK_GAME_BASE_TIME) {
|
|
||||||
return {
|
|
||||||
connectedAt: offsetIso(baseIso, -180_000),
|
|
||||||
lastError: null,
|
|
||||||
lastMessageAt: offsetIso(baseIso, -500),
|
|
||||||
latencyMs: 48,
|
|
||||||
reconnectAttempt: 0,
|
|
||||||
status: 'connected',
|
|
||||||
transport: 'websocket',
|
|
||||||
} satisfies ConnectionState
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMockGameBootstrapSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
|
|
||||||
const cells = createGameCells()
|
|
||||||
const chips = createDefaultChips()
|
|
||||||
const history = createMockHistoryEntries(baseIso)
|
|
||||||
const round = createMockRoundSnapshot(baseIso)
|
|
||||||
|
|
||||||
return {
|
|
||||||
announcements: createMockAnnouncementState(baseIso),
|
|
||||||
cells,
|
|
||||||
chips,
|
|
||||||
connection: createMockConnectionState(baseIso),
|
|
||||||
dashboard: createMockDashboardState(baseIso, round, history),
|
|
||||||
history,
|
|
||||||
maxSelectionCount: GAME_MAX_SELECTION_CELLS,
|
|
||||||
round,
|
|
||||||
selections: createMockBetSelections(),
|
|
||||||
trends: deriveTrendEntries(history),
|
|
||||||
} satisfies GameBootstrapSnapshot
|
|
||||||
}
|
|
||||||
@@ -36,6 +36,10 @@ function normalizeApiBaseUrl(baseUrl: string | undefined) {
|
|||||||
throw new Error('VITE_API_BASE_URL 未配置')
|
throw new Error('VITE_API_BASE_URL 未配置')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (candidate === '/') {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
|
||||||
if (/^https?:\/\//.test(candidate)) {
|
if (/^https?:\/\//.test(candidate)) {
|
||||||
return candidate.replace(/\/+$/, '')
|
return candidate.replace(/\/+$/, '')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,19 @@ export default {
|
|||||||
'This area will later load the real event announcement body, rich media, and a longer scrollable message. The current version focuses on shared multilingual modal wiring.',
|
'This area will later load the real event announcement body, rich media, and a longer scrollable message. The current version focuses on shared multilingual modal wiring.',
|
||||||
check: 'View',
|
check: 'View',
|
||||||
},
|
},
|
||||||
|
protocol: {
|
||||||
|
title: 'User Agreement',
|
||||||
|
content:
|
||||||
|
'Welcome to the 36-Character Flower game lobby.\n\nBefore entering the site, please read and confirm the following:\n1. You have reached the legal age required in your region.\n2. You understand the current content is only for use within this account and this site, and must not be copied, redistributed, or used for unlawful purposes.\n3. You agree to follow the site rules regarding account usage, top-up, withdrawal, risk control, and gameplay.\n4. By continuing into the game lobby, you acknowledge and accept the relevant service terms and data handling rules.\n\nPlease check the agreement to continue.',
|
||||||
|
agreeLabel: 'I have read and agree to the User Agreement',
|
||||||
|
confirm: 'Agree and Enter',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
title: 'Game Rules',
|
||||||
|
content:
|
||||||
|
'1. Basic Gameplay\n1) After each round starts, players may select one or more numbers on the board to place bets.\n2) After betting closes, the system enters the draw phase and settles rewards based on the round result.\n3) Different chip levels correspond to different bet amounts, subject to the current table limits and configuration.\n\n2. Betting Notes\n1) Bets can only be submitted during the betting phase.\n2) Before confirming, please verify your selected numbers, chip amount, and total bet.\n3) If your balance is insufficient, the round is no longer valid, or betting has closed, the request will be rejected.\n\n3. Draw and Settlement\n1) The final displayed draw result is the valid outcome.\n2) Hit rules, odds, payouts, and streak performance are settled in real time according to the current room configuration.\n3) In case of network fluctuation, please refer to the re-synced official data.\n\n4. Additional Notes\n1) Please manage your play time responsibly.\n2) Any abnormal behavior intended to interfere with the system, exploit rewards, or bypass risk control is strictly prohibited.\n3) The platform reserves the right to review orders, payouts, and account status in exceptional situations.',
|
||||||
|
confirm: 'Understood',
|
||||||
|
},
|
||||||
procedures: {
|
procedures: {
|
||||||
title: 'Top Up / Withdraw',
|
title: 'Top Up / Withdraw',
|
||||||
contentPlaceholder: 'Choose the action you want to continue with',
|
contentPlaceholder: 'Choose the action you want to continue with',
|
||||||
@@ -178,7 +191,7 @@ export default {
|
|||||||
implementationBody:
|
implementationBody:
|
||||||
'Next steps are the real API, WebSocket, full UI store, and round lifecycle state machine.',
|
'Next steps are the real API, WebSocket, full UI store, and round lifecycle state machine.',
|
||||||
limitsTitle: 'Table limits',
|
limitsTitle: 'Table limits',
|
||||||
limitsSubtitle: 'Derived from dashboard mock data',
|
limitsSubtitle: 'Derived from the current lobby data',
|
||||||
minBet: 'Min bet',
|
minBet: 'Min bet',
|
||||||
maxBet: 'Max bet',
|
maxBet: 'Max bet',
|
||||||
},
|
},
|
||||||
@@ -193,6 +206,15 @@ export default {
|
|||||||
loginRequired: 'Please log in before entering the game',
|
loginRequired: 'Please log in before entering the game',
|
||||||
loginSuccess: 'Login successful',
|
loginSuccess: 'Login successful',
|
||||||
registerSuccess: 'Registration successful',
|
registerSuccess: 'Registration successful',
|
||||||
|
insufficientBalance: 'Insufficient balance. Please adjust your bet.',
|
||||||
|
betUnavailable: 'Betting is not available for this round',
|
||||||
|
betPlaced: 'Bet placed successfully',
|
||||||
|
noRecentSuccessfulBet:
|
||||||
|
'No successful bet from the previous round was found',
|
||||||
|
repeatSelectionsRestored:
|
||||||
|
'Selections from the last successful round have been restored',
|
||||||
|
betRejected: 'Bet was not accepted',
|
||||||
|
betPlaceFailed: 'Failed to place the bet. Please try again.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
@@ -299,6 +321,9 @@ export default {
|
|||||||
selected: 'Selected',
|
selected: 'Selected',
|
||||||
totalBet: 'Total Bet',
|
totalBet: 'Total Bet',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
|
selectNumbers: 'Select Numbers',
|
||||||
|
insufficientBalance: 'Insufficient Balance',
|
||||||
|
submitting: 'Submitting...',
|
||||||
actions: {
|
actions: {
|
||||||
clear: 'Clear',
|
clear: 'Clear',
|
||||||
repeat: 'Repeat',
|
repeat: 'Repeat',
|
||||||
|
|||||||
@@ -123,6 +123,19 @@ export default {
|
|||||||
'Bagian ini nantinya akan memuat konten pengumuman acara yang sebenarnya, materi visual, dan pesan panjang yang dapat digulir. Versi saat ini fokus pada sambungan modal multibahasa.',
|
'Bagian ini nantinya akan memuat konten pengumuman acara yang sebenarnya, materi visual, dan pesan panjang yang dapat digulir. Versi saat ini fokus pada sambungan modal multibahasa.',
|
||||||
check: 'Lihat',
|
check: 'Lihat',
|
||||||
},
|
},
|
||||||
|
protocol: {
|
||||||
|
title: 'Perjanjian Pengguna',
|
||||||
|
content:
|
||||||
|
'Selamat datang di lobi game 36-Character Flower.\n\nSebelum masuk ke situs, mohon baca dan konfirmasi hal berikut:\n1. Kamu telah mencapai usia legal yang diwajibkan di wilayahmu.\n2. Kamu memahami bahwa konten saat ini hanya untuk penggunaan pada akun dan situs ini, serta tidak boleh disalin, disebarkan, atau digunakan untuk tujuan yang melanggar hukum.\n3. Kamu setuju untuk mematuhi aturan situs terkait akun, isi ulang, penarikan, kontrol risiko, dan permainan.\n4. Dengan melanjutkan ke lobi game, kamu menyatakan telah mengetahui dan menerima ketentuan layanan serta aturan pemrosesan data yang berlaku.\n\nSilakan centang persetujuan untuk melanjutkan.',
|
||||||
|
agreeLabel: 'Saya telah membaca dan menyetujui Perjanjian Pengguna',
|
||||||
|
confirm: 'Setuju dan Masuk',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
title: 'Aturan Permainan',
|
||||||
|
content:
|
||||||
|
'1. Gameplay Dasar\n1) Setelah setiap ronde dimulai, pemain dapat memilih satu atau beberapa angka di papan untuk memasang taruhan.\n2) Setelah taruhan ditutup, sistem masuk ke fase undian dan menyelesaikan hadiah berdasarkan hasil ronde.\n3) Level chip yang berbeda mewakili jumlah taruhan yang berbeda, mengikuti batas meja dan konfigurasi saat ini.\n\n2. Catatan Taruhan\n1) Taruhan hanya bisa dikirim saat fase taruhan berlangsung.\n2) Sebelum konfirmasi, periksa kembali angka yang dipilih, nominal chip, dan total taruhan.\n3) Jika saldo tidak cukup, ronde tidak lagi valid, atau taruhan sudah ditutup, permintaan akan ditolak.\n\n3. Undian dan Penyelesaian\n1) Hasil undian akhir yang ditampilkan sistem adalah hasil yang berlaku.\n2) Aturan kena, odds, pembayaran, dan performa streak diselesaikan secara real time sesuai konfigurasi room saat ini.\n3) Jika terjadi gangguan jaringan, silakan mengacu pada data resmi setelah sinkronisasi ulang.\n\n4. Catatan Tambahan\n1) Mohon atur waktu bermain secara bertanggung jawab.\n2) Segala tindakan tidak wajar untuk mengganggu sistem, mengeksploitasi hadiah, atau menghindari kontrol risiko dilarang keras.\n3) Platform berhak meninjau pesanan, pembayaran, dan status akun dalam kondisi khusus.',
|
||||||
|
confirm: 'Saya Mengerti',
|
||||||
|
},
|
||||||
procedures: {
|
procedures: {
|
||||||
title: 'Isi Ulang / Tarik Dana',
|
title: 'Isi Ulang / Tarik Dana',
|
||||||
contentPlaceholder: 'Pilih tindakan yang ingin kamu lanjutkan',
|
contentPlaceholder: 'Pilih tindakan yang ingin kamu lanjutkan',
|
||||||
@@ -177,7 +190,7 @@ export default {
|
|||||||
implementationBody:
|
implementationBody:
|
||||||
'Langkah berikutnya adalah API nyata, WebSocket, UI store penuh, dan state machine siklus ronde.',
|
'Langkah berikutnya adalah API nyata, WebSocket, UI store penuh, dan state machine siklus ronde.',
|
||||||
limitsTitle: 'Batas meja',
|
limitsTitle: 'Batas meja',
|
||||||
limitsSubtitle: 'Berasal dari data mock dashboard',
|
limitsSubtitle: 'Berasal dari data lobby saat ini',
|
||||||
minBet: 'Bet minimum',
|
minBet: 'Bet minimum',
|
||||||
maxBet: 'Bet maksimum',
|
maxBet: 'Bet maksimum',
|
||||||
},
|
},
|
||||||
@@ -192,6 +205,15 @@ export default {
|
|||||||
loginRequired: 'Silakan masuk sebelum memasuki game',
|
loginRequired: 'Silakan masuk sebelum memasuki game',
|
||||||
loginSuccess: 'Berhasil masuk',
|
loginSuccess: 'Berhasil masuk',
|
||||||
registerSuccess: 'Pendaftaran berhasil',
|
registerSuccess: 'Pendaftaran berhasil',
|
||||||
|
insufficientBalance: 'Saldo tidak cukup. Silakan sesuaikan taruhan.',
|
||||||
|
betUnavailable: 'Taruhan tidak tersedia untuk ronde ini',
|
||||||
|
betPlaced: 'Taruhan berhasil dikirim',
|
||||||
|
noRecentSuccessfulBet:
|
||||||
|
'Tidak ada riwayat taruhan berhasil dari ronde sebelumnya',
|
||||||
|
repeatSelectionsRestored:
|
||||||
|
'Pilihan dari ronde berhasil terakhir telah dipulihkan',
|
||||||
|
betRejected: 'Taruhan tidak diterima',
|
||||||
|
betPlaceFailed: 'Gagal mengirim taruhan. Silakan coba lagi.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
@@ -298,6 +320,9 @@ export default {
|
|||||||
selected: 'Dipilih',
|
selected: 'Dipilih',
|
||||||
totalBet: 'Total Bet',
|
totalBet: 'Total Bet',
|
||||||
confirm: 'Konfirmasi',
|
confirm: 'Konfirmasi',
|
||||||
|
selectNumbers: 'Pilih Nombor',
|
||||||
|
insufficientBalance: 'Saldo Tidak Cukup',
|
||||||
|
submitting: 'Mengirim...',
|
||||||
actions: {
|
actions: {
|
||||||
clear: 'Hapus',
|
clear: 'Hapus',
|
||||||
repeat: 'Ulang',
|
repeat: 'Ulang',
|
||||||
|
|||||||
@@ -126,6 +126,20 @@ export default {
|
|||||||
'Bahagian ini akan memuatkan kandungan notis acara sebenar, bahan visual, dan mesej boleh skrol yang lebih panjang. Versi semasa memfokuskan sambungan modal pelbagai bahasa.',
|
'Bahagian ini akan memuatkan kandungan notis acara sebenar, bahan visual, dan mesej boleh skrol yang lebih panjang. Versi semasa memfokuskan sambungan modal pelbagai bahasa.',
|
||||||
check: 'Semak',
|
check: 'Semak',
|
||||||
},
|
},
|
||||||
|
protocol: {
|
||||||
|
title: 'Perjanjian Pengguna',
|
||||||
|
content:
|
||||||
|
'Selamat datang ke lobi permainan 36-Character Flower.\n\nSebelum memasuki laman ini, sila baca dan sahkan perkara berikut:\n1. Anda telah mencapai umur sah yang ditetapkan di kawasan anda.\n2. Anda memahami bahawa kandungan semasa hanya untuk digunakan dalam akaun dan laman ini, dan tidak boleh disalin, diedarkan semula, atau digunakan untuk tujuan yang menyalahi undang-undang.\n3. Anda bersetuju untuk mematuhi peraturan laman berkaitan akaun, tambah nilai, pengeluaran, kawalan risiko, dan permainan.\n4. Dengan meneruskan ke lobi permainan, anda mengakui dan menerima terma perkhidmatan serta peraturan pemprosesan data yang berkaitan.\n\nSila tandakan persetujuan untuk meneruskan.',
|
||||||
|
agreeLabel:
|
||||||
|
'Saya telah membaca dan bersetuju dengan Perjanjian Pengguna',
|
||||||
|
confirm: 'Setuju dan Masuk',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
title: 'Peraturan Permainan',
|
||||||
|
content:
|
||||||
|
'1. Permainan Asas\n1) Selepas setiap pusingan bermula, pemain boleh memilih satu atau beberapa nombor pada papan untuk membuat taruhan.\n2) Selepas taruhan ditutup, sistem memasuki fasa cabutan dan menyelesaikan ganjaran berdasarkan keputusan pusingan.\n3) Tahap cip yang berbeza mewakili jumlah taruhan yang berbeza, tertakluk kepada had meja dan konfigurasi semasa.\n\n2. Nota Taruhan\n1) Taruhan hanya boleh dihantar semasa fasa taruhan.\n2) Sebelum mengesahkan, sila semak nombor yang dipilih, jumlah cip, dan jumlah taruhan keseluruhan.\n3) Jika baki tidak mencukupi, pusingan tidak lagi sah, atau taruhan telah ditutup, permintaan akan ditolak.\n\n3. Cabutan dan Penyelesaian\n1) Keputusan cabutan akhir yang dipaparkan sistem ialah keputusan yang sah.\n2) Peraturan kena, odds, bayaran, dan prestasi streak diselesaikan secara masa nyata mengikut konfigurasi bilik semasa.\n3) Jika berlaku gangguan rangkaian, sila rujuk data rasmi selepas penyegerakan semula.\n\n4. Nota Tambahan\n1) Sila urus masa permainan anda dengan bertanggungjawab.\n2) Sebarang tingkah laku tidak normal untuk mengganggu sistem, mengeksploitasi ganjaran, atau memintas kawalan risiko adalah dilarang sama sekali.\n3) Platform berhak menyemak pesanan, bayaran, dan status akaun dalam keadaan khas.',
|
||||||
|
confirm: 'Saya Faham',
|
||||||
|
},
|
||||||
procedures: {
|
procedures: {
|
||||||
title: 'Tambah Nilai / Pengeluaran',
|
title: 'Tambah Nilai / Pengeluaran',
|
||||||
contentPlaceholder: 'Pilih tindakan yang ingin anda teruskan',
|
contentPlaceholder: 'Pilih tindakan yang ingin anda teruskan',
|
||||||
@@ -180,7 +194,7 @@ export default {
|
|||||||
implementationBody:
|
implementationBody:
|
||||||
'Langkah seterusnya ialah API sebenar, WebSocket, UI store penuh, dan state machine kitaran pusingan.',
|
'Langkah seterusnya ialah API sebenar, WebSocket, UI store penuh, dan state machine kitaran pusingan.',
|
||||||
limitsTitle: 'Had meja',
|
limitsTitle: 'Had meja',
|
||||||
limitsSubtitle: 'Diambil daripada data mock dashboard',
|
limitsSubtitle: 'Diambil daripada data lobi semasa',
|
||||||
minBet: 'Taruhan minimum',
|
minBet: 'Taruhan minimum',
|
||||||
maxBet: 'Taruhan maksimum',
|
maxBet: 'Taruhan maksimum',
|
||||||
},
|
},
|
||||||
@@ -195,6 +209,15 @@ export default {
|
|||||||
loginRequired: 'Sila log masuk sebelum memasuki permainan',
|
loginRequired: 'Sila log masuk sebelum memasuki permainan',
|
||||||
loginSuccess: 'Log masuk berjaya',
|
loginSuccess: 'Log masuk berjaya',
|
||||||
registerSuccess: 'Pendaftaran berjaya',
|
registerSuccess: 'Pendaftaran berjaya',
|
||||||
|
insufficientBalance: 'Baki tidak mencukupi. Sila laraskan taruhan.',
|
||||||
|
betUnavailable: 'Taruhan tidak tersedia untuk pusingan ini',
|
||||||
|
betPlaced: 'Taruhan berjaya dihantar',
|
||||||
|
noRecentSuccessfulBet:
|
||||||
|
'Tiada rekod taruhan berjaya untuk pusingan sebelumnya',
|
||||||
|
repeatSelectionsRestored:
|
||||||
|
'Pilihan dari pusingan berjaya terakhir telah dipulihkan',
|
||||||
|
betRejected: 'Taruhan tidak diterima',
|
||||||
|
betPlaceFailed: 'Gagal menghantar taruhan. Sila cuba lagi.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
@@ -301,6 +324,9 @@ export default {
|
|||||||
selected: 'Dipilih',
|
selected: 'Dipilih',
|
||||||
totalBet: 'Jumlah Taruhan',
|
totalBet: 'Jumlah Taruhan',
|
||||||
confirm: 'Sahkan',
|
confirm: 'Sahkan',
|
||||||
|
selectNumbers: 'Pilih Nombor',
|
||||||
|
insufficientBalance: 'Baki Tidak Mencukupi',
|
||||||
|
submitting: 'Menghantar...',
|
||||||
actions: {
|
actions: {
|
||||||
clear: 'Kosongkan',
|
clear: 'Kosongkan',
|
||||||
repeat: 'Ulang',
|
repeat: 'Ulang',
|
||||||
|
|||||||
@@ -121,6 +121,19 @@ export default {
|
|||||||
'这里后续将接入真实的活动公告内容、图文素材和滚动阅读区域。当前版本先用多语言结构完成桌面端公告弹窗能力。',
|
'这里后续将接入真实的活动公告内容、图文素材和滚动阅读区域。当前版本先用多语言结构完成桌面端公告弹窗能力。',
|
||||||
check: '查看',
|
check: '查看',
|
||||||
},
|
},
|
||||||
|
protocol: {
|
||||||
|
title: '用户协议',
|
||||||
|
content:
|
||||||
|
'欢迎进入 36 字花游戏大厅。\n\n进入站点前,请先阅读并确认以下协议内容:\n1. 你已年满所在地区法律要求的法定年龄。\n2. 你理解当前展示内容仅限当前账号与当前站点使用,不得擅自复制、转发或用于非法用途。\n3. 你同意遵守站点的账户、充值、提现、风控与游戏规则说明。\n4. 若你继续进入游戏大厅,即表示你已知悉并接受相关服务条款与数据处理规则。\n\n请勾选同意后继续进入游戏界面。',
|
||||||
|
agreeLabel: '我已阅读并同意《用户协议》',
|
||||||
|
confirm: '同意并进入',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
title: '玩法规则',
|
||||||
|
content:
|
||||||
|
'一、基础玩法\n1. 每一局开始后,玩家可以在盘面上选择一个或多个号码进行下注。\n2. 系统会在封盘后进入开奖阶段,并根据当期结果结算对应奖金。\n3. 不同下注档位对应不同下注金额,实际以当前系统配置和桌限为准。\n\n二、下注说明\n1. 只有在“下注中”阶段可以提交下注。\n2. 每次提交前,请确认已选号码、下注金额和总下注额。\n3. 若余额不足、期号失效或已封盘,系统将拒绝该次下注请求。\n\n三、开奖与结算\n1. 开奖结果以系统最终展示为准。\n2. 命中规则、赔率、派彩与连中表现按当前房间配置实时结算。\n3. 如遇网络波动,请以重新同步后的官方数据为准。\n\n四、其他说明\n1. 请合理安排游戏时间,理性参与。\n2. 严禁使用任何异常手段干扰系统、刷取奖励或规避风控。\n3. 平台保留在异常情况下对订单、派奖和账户状态进行复核的权利。',
|
||||||
|
confirm: '我知道了',
|
||||||
|
},
|
||||||
procedures: {
|
procedures: {
|
||||||
title: '充值 / 提现',
|
title: '充值 / 提现',
|
||||||
contentPlaceholder: '请选择你要进行的操作',
|
contentPlaceholder: '请选择你要进行的操作',
|
||||||
@@ -172,7 +185,7 @@ export default {
|
|||||||
implementationBody:
|
implementationBody:
|
||||||
'下一步会继续接入真实 API、WebSocket、完整 UI Store 和回合状态机。',
|
'下一步会继续接入真实 API、WebSocket、完整 UI Store 和回合状态机。',
|
||||||
limitsTitle: '桌限信息',
|
limitsTitle: '桌限信息',
|
||||||
limitsSubtitle: '来自 dashboard mock 数据',
|
limitsSubtitle: '来自当前大厅数据',
|
||||||
minBet: '最低下注',
|
minBet: '最低下注',
|
||||||
maxBet: '最高下注',
|
maxBet: '最高下注',
|
||||||
},
|
},
|
||||||
@@ -187,6 +200,13 @@ export default {
|
|||||||
loginRequired: '请先登录后进入游戏',
|
loginRequired: '请先登录后进入游戏',
|
||||||
loginSuccess: '登录成功',
|
loginSuccess: '登录成功',
|
||||||
registerSuccess: '注册成功',
|
registerSuccess: '注册成功',
|
||||||
|
insufficientBalance: '余额不足,请调整下注金额',
|
||||||
|
betUnavailable: '当前期不可下注',
|
||||||
|
betPlaced: '下注成功',
|
||||||
|
noRecentSuccessfulBet: '暂无上一局成功下注记录',
|
||||||
|
repeatSelectionsRestored: '已恢复上一局成功下注的花字',
|
||||||
|
betRejected: '下注未受理',
|
||||||
|
betPlaceFailed: '下注失败,请稍后重试',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
@@ -291,6 +311,9 @@ export default {
|
|||||||
selected: '已选',
|
selected: '已选',
|
||||||
totalBet: '总下注',
|
totalBet: '总下注',
|
||||||
confirm: '确认',
|
confirm: '确认',
|
||||||
|
selectNumbers: '请选择号码',
|
||||||
|
insufficientBalance: '余额不足',
|
||||||
|
submitting: '提交中...',
|
||||||
actions: {
|
actions: {
|
||||||
clear: '清空',
|
clear: '清空',
|
||||||
repeat: '重复',
|
repeat: '重复',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getCurrentUserProfile,
|
getCurrentUserProfile,
|
||||||
refreshAuthSession,
|
refreshAuthSession,
|
||||||
} from '@/features/auth/api/auth-api'
|
} from '@/features/auth/api/auth-api'
|
||||||
|
import { GlobalAudioController } from '@/features/game/audio/global-audio-controller'
|
||||||
import '@/i18n'
|
import '@/i18n'
|
||||||
import { prefetchAuthToken } from '@/lib/api/api-client'
|
import { prefetchAuthToken } from '@/lib/api/api-client'
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,7 @@ void initializeAuthSession().then(async () => {
|
|||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<GlobalAudioController />
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
<AppToaster />
|
<AppToaster />
|
||||||
{shouldShowQueryDevtools && <ReactQueryDevtools initialIsOpen={false} />}
|
{shouldShowQueryDevtools && <ReactQueryDevtools initialIsOpen={false} />}
|
||||||
|
|||||||
42
src/store/audio/audio-store.ts
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
export * from './audio-store'
|
||||||
@@ -50,6 +50,7 @@ interface PersistedAuthState {
|
|||||||
interface PersistedAppPreferenceState {
|
interface PersistedAppPreferenceState {
|
||||||
appLanguage: string | null
|
appLanguage: string | null
|
||||||
deviceId: string | null
|
deviceId: string | null
|
||||||
|
hasAcceptedProtocol: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState extends PersistedAuthState {
|
interface AuthState extends PersistedAuthState {
|
||||||
@@ -217,8 +218,11 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface AppPreferenceStoreState extends PersistedAppPreferenceState {
|
interface AppPreferenceStoreState extends PersistedAppPreferenceState {
|
||||||
|
finishHydration: () => void
|
||||||
getOrCreateDeviceId: () => string
|
getOrCreateDeviceId: () => string
|
||||||
|
isHydrated: boolean
|
||||||
setAppLanguage: (language: string) => void
|
setAppLanguage: (language: string) => void
|
||||||
|
setProtocolAccepted: (accepted: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
|
export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
|
||||||
@@ -226,6 +230,11 @@ export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
appLanguage: null,
|
appLanguage: null,
|
||||||
deviceId: null,
|
deviceId: null,
|
||||||
|
hasAcceptedProtocol: false,
|
||||||
|
isHydrated: false,
|
||||||
|
finishHydration: () => {
|
||||||
|
set({ isHydrated: true })
|
||||||
|
},
|
||||||
getOrCreateDeviceId: () => {
|
getOrCreateDeviceId: () => {
|
||||||
const deviceId = get().deviceId
|
const deviceId = get().deviceId
|
||||||
|
|
||||||
@@ -242,6 +251,9 @@ export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
|
|||||||
setAppLanguage: (language) => {
|
setAppLanguage: (language) => {
|
||||||
set({ appLanguage: language })
|
set({ appLanguage: language })
|
||||||
},
|
},
|
||||||
|
setProtocolAccepted: (accepted) => {
|
||||||
|
set({ hasAcceptedProtocol: accepted })
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: APP_PREFERENCES_STORAGE_KEY,
|
name: APP_PREFERENCES_STORAGE_KEY,
|
||||||
@@ -249,7 +261,11 @@ export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
|
|||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
appLanguage: state.appLanguage,
|
appLanguage: state.appLanguage,
|
||||||
deviceId: state.deviceId,
|
deviceId: state.deviceId,
|
||||||
|
hasAcceptedProtocol: state.hasAcceptedProtocol,
|
||||||
}),
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
state?.finishHydration()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -265,3 +281,11 @@ export function getStoredAppLanguage() {
|
|||||||
export function setStoredAppLanguage(language: string) {
|
export function setStoredAppLanguage(language: string) {
|
||||||
useAppPreferenceStore.getState().setAppLanguage(language)
|
useAppPreferenceStore.getState().setAppLanguage(language)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStoredProtocolAccepted() {
|
||||||
|
return useAppPreferenceStore.getState().hasAcceptedProtocol
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredProtocolAccepted(accepted: boolean) {
|
||||||
|
useAppPreferenceStore.getState().setProtocolAccepted(accepted)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
} from '@/features/game/shared'
|
} from '@/features/game/shared'
|
||||||
import {
|
import {
|
||||||
buildGameCellViewModels,
|
buildGameCellViewModels,
|
||||||
createMockGameBootstrapSnapshot,
|
createEmptyGameBootstrapSnapshot,
|
||||||
DEFAULT_ACTIVE_CHIP_ID,
|
DEFAULT_ACTIVE_CHIP_ID,
|
||||||
getChipById,
|
getChipById,
|
||||||
getRecentWinningCellIds,
|
getRecentWinningCellIds,
|
||||||
@@ -31,29 +31,54 @@ type GameRoundSlice = Pick<
|
|||||||
| 'trends'
|
| '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 {
|
export interface GameRoundStoreState extends GameRoundSlice {
|
||||||
activeChipId: string
|
activeChipId: string
|
||||||
clearSelections: () => void
|
clearSelections: () => void
|
||||||
hydrateRound: (snapshot: GameRoundSlice) => void
|
hydrateRound: (snapshot: GameRoundSlice) => void
|
||||||
placeBet: (cellId: number) => void
|
placeBet: (cellId: number) => void
|
||||||
|
recentSuccessfulSelections: BetSelection[]
|
||||||
removeSelectionsForCell: (cellId: number) => void
|
removeSelectionsForCell: (cellId: number) => void
|
||||||
|
restoreRecentSuccessfulSelections: () => boolean
|
||||||
|
setRecentSuccessfulSelections: (selections: BetSelection[]) => void
|
||||||
selectChip: (chipId: string) => void
|
selectChip: (chipId: string) => void
|
||||||
setPhase: (phase: RoundPhase) => void
|
setPhase: (phase: RoundPhase) => void
|
||||||
syncRound: (round: Partial<RoundSnapshot>) => void
|
syncRound: (round: Partial<RoundSnapshot>) => void
|
||||||
upsertSelections: (selections: BetSelection[]) => void
|
upsertSelections: (selections: BetSelection[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInitialRoundState(): GameRoundSlice & { activeChipId: string } {
|
function createInitialRoundState(): GameRoundSlice & {
|
||||||
const snapshot = createMockGameBootstrapSnapshot()
|
activeChipId: string
|
||||||
|
recentSuccessfulSelections: BetSelection[]
|
||||||
|
} {
|
||||||
|
const snapshot = createEmptyGameBootstrapSnapshot()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeChipId:
|
activeChipId: DEFAULT_ACTIVE_CHIP_ID,
|
||||||
snapshot.chips.find((chip) => chip.isDefault)?.id ??
|
|
||||||
DEFAULT_ACTIVE_CHIP_ID,
|
|
||||||
cells: snapshot.cells,
|
cells: snapshot.cells,
|
||||||
chips: snapshot.chips,
|
chips: snapshot.chips,
|
||||||
history: snapshot.history,
|
history: snapshot.history,
|
||||||
maxSelectionCount: snapshot.maxSelectionCount,
|
maxSelectionCount: snapshot.maxSelectionCount,
|
||||||
|
recentSuccessfulSelections: [],
|
||||||
round: snapshot.round,
|
round: snapshot.round,
|
||||||
selections: snapshot.selections,
|
selections: snapshot.selections,
|
||||||
trends: snapshot.trends,
|
trends: snapshot.trends,
|
||||||
@@ -118,6 +143,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
recentSuccessfulSelections: [],
|
||||||
removeSelectionsForCell: (cellId) => {
|
removeSelectionsForCell: (cellId) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
selections: state.selections.filter(
|
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) => {
|
selectChip: (chipId) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!getChipById(state.chips, chipId)) {
|
if (!getChipById(state.chips, chipId)) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
GameBootstrapSnapshot,
|
GameBootstrapSnapshot,
|
||||||
} from '@/features/game/shared'
|
} from '@/features/game/shared'
|
||||||
import {
|
import {
|
||||||
createMockGameBootstrapSnapshot,
|
createEmptyGameBootstrapSnapshot,
|
||||||
getUnreadAnnouncementCount,
|
getUnreadAnnouncementCount,
|
||||||
getVisibleAnnouncements,
|
getVisibleAnnouncements,
|
||||||
} from '@/features/game/shared'
|
} from '@/features/game/shared'
|
||||||
@@ -32,7 +32,7 @@ export interface GameSessionStoreState extends GameSessionSlice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createInitialSessionState(): GameSessionSlice {
|
function createInitialSessionState(): GameSessionSlice {
|
||||||
const snapshot = createMockGameBootstrapSnapshot()
|
const snapshot = createEmptyGameBootstrapSnapshot()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
announcements: snapshot.announcements,
|
announcements: snapshot.announcements,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './audio'
|
||||||
export * from './auth'
|
export * from './auth'
|
||||||
export * from './game'
|
export * from './game'
|
||||||
export * from './modal'
|
export * from './modal'
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ export const MODAL_KEYS = [
|
|||||||
'desktopLogin',
|
'desktopLogin',
|
||||||
/**@description 桌面端注册弹窗*/
|
/**@description 桌面端注册弹窗*/
|
||||||
'desktopRegister',
|
'desktopRegister',
|
||||||
|
/**@description 桌面端多语言弹窗*/
|
||||||
|
'desktopLanguage',
|
||||||
|
/**@description 桌面端协议弹窗*/
|
||||||
|
'desktopProtocol',
|
||||||
|
/**@description 桌面端规则弹窗*/
|
||||||
|
'desktopRules',
|
||||||
/**@description 桌面端用户信息弹窗*/
|
/**@description 桌面端用户信息弹窗*/
|
||||||
'desktopUserInfo',
|
'desktopUserInfo',
|
||||||
/**@description 桌面端公告弹窗*/
|
/**@description 桌面端公告弹窗*/
|
||||||
@@ -25,6 +31,9 @@ type ModalVisibilityMap = Record<ModalKey, boolean>
|
|||||||
const INITIAL_MODAL_VISIBILITY: ModalVisibilityMap = {
|
const INITIAL_MODAL_VISIBILITY: ModalVisibilityMap = {
|
||||||
desktopLogin: false,
|
desktopLogin: false,
|
||||||
desktopRegister: false,
|
desktopRegister: false,
|
||||||
|
desktopLanguage: false,
|
||||||
|
desktopProtocol: false,
|
||||||
|
desktopRules: false,
|
||||||
desktopUserInfo: false,
|
desktopUserInfo: false,
|
||||||
desktopNotice: false,
|
desktopNotice: false,
|
||||||
desktopAutoSetting: false,
|
desktopAutoSetting: false,
|
||||||
|
|||||||