refactor(game): 重构项目结构,优化链路, 移动端适配

- 移除 useGameBoardVm 数据层实施说明文档
- 移除核心玩法与前端规则摘要文档
- 移除游戏模块数据与界面分层第一阶段实施稿文档
- 清理与数据层重构相关的技术方案说明
- 删除关于 PC 和 Mobile 界面分离的设计规划
- 移除 view-model hooks 架构设计相关内容
This commit is contained in:
JiaJun
2026-06-03 17:21:13 +08:00
parent 3efcb3bba6
commit bfb4b76611
129 changed files with 4534 additions and 4227 deletions

View 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,
}
}