refactor(game): 重构项目结构,优化链路, 移动端适配
- 移除 useGameBoardVm 数据层实施说明文档 - 移除核心玩法与前端规则摘要文档 - 移除游戏模块数据与界面分层第一阶段实施稿文档 - 清理与数据层重构相关的技术方案说明 - 删除关于 PC 和 Mobile 界面分离的设计规划 - 移除 view-model hooks 架构设计相关内容
This commit is contained in:
311
src/hooks/use-game-control-vm.ts
Normal file
311
src/hooks/use-game-control-vm.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { placeGameBet } from '@/api'
|
||||
import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
import {
|
||||
selectSelectionTotal,
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
import type { ConfirmState } from '@/type'
|
||||
|
||||
function formatChipDisplayValue(amount: number) {
|
||||
if (Number.isInteger(amount)) {
|
||||
return String(amount)
|
||||
}
|
||||
|
||||
return amount.toFixed(2).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function createIdempotencyKey() {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return `bet-${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
return `bet-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function toBetId(chipId: string) {
|
||||
const match = chipId.match(/^chip-(\d+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const betId = Number(match[1])
|
||||
|
||||
return Number.isInteger(betId) && betId >= 1 && betId <= 6 ? betId : null
|
||||
}
|
||||
|
||||
export function useGameControlVm() {
|
||||
const { t } = useTranslation()
|
||||
const chips = useGameRoundStore((state) => state.chips)
|
||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||
const activeBetQuantity = useGameRoundStore(
|
||||
(state) => state.activeBetQuantity,
|
||||
)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const maxSelectionCount = useGameRoundStore(
|
||||
(state) => state.maxSelectionCount,
|
||||
)
|
||||
const adjustBetQuantity = useGameRoundStore(
|
||||
(state) => state.adjustBetQuantity,
|
||||
)
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
||||
const restoreRecentSuccessfulSelections = useGameRoundStore(
|
||||
(state) => state.restoreRecentSuccessfulSelections,
|
||||
)
|
||||
const setRecentSuccessfulSelections = useGameRoundStore(
|
||||
(state) => state.setRecentSuccessfulSelections,
|
||||
)
|
||||
const isAutoHosting = useGameAutoHostingStore((state) => state.isHosting)
|
||||
const selectChip = useGameRoundStore((state) => state.selectChip)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const connectionStatus = useGameSessionStore(
|
||||
(state) => state.connection.status,
|
||||
)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const shouldConnectRealtime = useGameSessionStore(
|
||||
(state) => state.shouldConnectRealtime,
|
||||
)
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const chipItems = useMemo(() => {
|
||||
const items = chips.map((chip) => ({
|
||||
amount: chip.amount,
|
||||
id: chip.id,
|
||||
isSelected: chip.id === activeChipId,
|
||||
src: CHIP_IMAGE_MAP.get(chip.id) ?? CHIP_IMAGE_OPTIONS[0]?.src ?? '',
|
||||
valueLabel: formatChipDisplayValue(chip.amount),
|
||||
}))
|
||||
|
||||
return items.sort((left, right) => {
|
||||
if (left.isSelected === right.isSelected) {
|
||||
return left.id.localeCompare(right.id, undefined, { numeric: true })
|
||||
}
|
||||
|
||||
return left.isSelected ? 1 : -1
|
||||
})
|
||||
}, [activeChipId, chips])
|
||||
|
||||
const selectedChip =
|
||||
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
const hasSelections = selections.length > 0
|
||||
const hasEnteredGame =
|
||||
shouldConnectRealtime && connectionStatus === 'connected'
|
||||
const hasSubmittedCurrentRound =
|
||||
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
|
||||
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
|
||||
const hasExceededBetLimit =
|
||||
hasSelections && tableLimitMax > 0 && totalBetAmount > tableLimitMax
|
||||
const confirmState: ConfirmState =
|
||||
isSubmitting || isAutoHosting
|
||||
? 'submitting'
|
||||
: !hasSelections
|
||||
? 'idle'
|
||||
: hasExceededBetLimit
|
||||
? 'limit'
|
||||
: 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 (hasExceededBetLimit) {
|
||||
notify.warning(t('commonUi.toast.betLimitExceeded'))
|
||||
return
|
||||
}
|
||||
|
||||
if (hasInsufficientBalance) {
|
||||
notify.warning(t('commonUi.toast.insufficientBalance'))
|
||||
return
|
||||
}
|
||||
|
||||
if (round.phase !== 'betting' || !round.id) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
if (hasSubmittedCurrentRound) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
const betId = toBetId(selections[0]?.chipId ?? activeChipId)
|
||||
const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
|
||||
|
||||
if (betId === null || singleBetAmount <= 0) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
let latestBalance = currentUser?.coin ?? '0'
|
||||
|
||||
const uniqueNumbers = [
|
||||
...new Set(selections.map((item) => item.cellId)),
|
||||
].sort((left, right) => left - right)
|
||||
const formattedSingleBetAmount = formatChipDisplayValue(singleBetAmount)
|
||||
const result = await placeGameBet({
|
||||
bet_amount: formattedSingleBetAmount,
|
||||
bet_id: betId,
|
||||
idempotency_key: createIdempotencyKey(),
|
||||
numbers: uniqueNumbers.join(','),
|
||||
period_no: round.id,
|
||||
single_bet_amount: formattedSingleBetAmount,
|
||||
})
|
||||
|
||||
if (result.status !== 'accepted') {
|
||||
throw new Error(t('commonUi.toast.betRejected'))
|
||||
}
|
||||
|
||||
latestBalance = result.balance_after
|
||||
|
||||
if (currentUser) {
|
||||
setCurrentUser({
|
||||
...currentUser,
|
||||
coin: latestBalance,
|
||||
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)
|
||||
}
|
||||
}, [
|
||||
activeChipId,
|
||||
authStatus,
|
||||
clearSelections,
|
||||
confirmState,
|
||||
currentUser,
|
||||
hasInsufficientBalance,
|
||||
hasExceededBetLimit,
|
||||
hasSelections,
|
||||
hasSubmittedCurrentRound,
|
||||
round.id,
|
||||
round.phase,
|
||||
selections,
|
||||
selectedChip?.amount,
|
||||
setRecentSuccessfulSelections,
|
||||
setCurrentUser,
|
||||
setModalOpen,
|
||||
t,
|
||||
])
|
||||
|
||||
const handleRepeatSelections = useCallback(() => {
|
||||
if (round.phase !== 'betting' || hasSubmittedCurrentRound) {
|
||||
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'))
|
||||
}, [
|
||||
hasSubmittedCurrentRound,
|
||||
restoreRecentSuccessfulSelections,
|
||||
round.phase,
|
||||
t,
|
||||
])
|
||||
|
||||
const handleOpenAutoSetting = useCallback(() => {
|
||||
if (!hasSelections) {
|
||||
notify.warning(t('commonUi.toast.selectNumbersBeforeAutoHosting'))
|
||||
return
|
||||
}
|
||||
|
||||
setModalOpen('desktopAutoSetting', true)
|
||||
}, [hasSelections, setModalOpen, t])
|
||||
|
||||
return {
|
||||
acceptingBets:
|
||||
round.phase === 'betting' && !hasSubmittedCurrentRound && !isAutoHosting,
|
||||
actionsEnabled:
|
||||
hasEnteredGame &&
|
||||
round.phase === 'betting' &&
|
||||
!hasSubmittedCurrentRound &&
|
||||
!isAutoHosting,
|
||||
canClear:
|
||||
selections.length > 0 &&
|
||||
round.phase === 'betting' &&
|
||||
!hasSubmittedCurrentRound &&
|
||||
!isAutoHosting,
|
||||
canDecreaseBetQuantity: activeBetQuantity > 1,
|
||||
confirmLabel:
|
||||
confirmState === 'idle'
|
||||
? t('gameDesktop.control.selectNumbers')
|
||||
: confirmState === 'insufficient'
|
||||
? t('gameDesktop.control.insufficientBalance')
|
||||
: confirmState === 'limit'
|
||||
? t('gameDesktop.control.betLimitExceeded')
|
||||
: confirmState === 'submitting'
|
||||
? t('gameDesktop.control.submitting')
|
||||
: t('gameDesktop.control.confirm'),
|
||||
confirmState,
|
||||
isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
|
||||
onChipSelect: selectChip,
|
||||
onDecreaseBetQuantity: () => adjustBetQuantity(-1),
|
||||
onIncreaseBetQuantity: () => adjustBetQuantity(1),
|
||||
onConfirm: handleConfirm,
|
||||
onClearSelections: clearSelections,
|
||||
onOpenAutoSetting: handleOpenAutoSetting,
|
||||
onRepeatSelections: handleRepeatSelections,
|
||||
maxSelectionCountLabel: maxSelectionCount,
|
||||
selectedBetQuantityLabel: activeBetQuantity,
|
||||
selectedChipId: activeChipId,
|
||||
selectedCountLabel: selections.length,
|
||||
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
|
||||
chips: chipItems,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user