- 引入 light-animal-sprite 图像资源和 variant 属性支持 - 在动物组件中实现正常和浅色精灵图像切换功能 - 重构桌面和移动版动物组件的揭示动画逻辑 - 移除冗余的运动偏好检测和布局效果钩子 - 调整揭示动画的时间参数和平滑度曲线 - 更新资源预加载配置以包含浅色动物图像 - 修改控制组件中的投注数量调整状态管理 - 优化脚本生成精灵图像的复用性
355 lines
10 KiB
TypeScript
355 lines
10 KiB
TypeScript
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,
|
|
isDisabled: tableLimitMax > 0 && chip.amount > tableLimitMax,
|
|
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, tableLimitMax])
|
|
|
|
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 canIncreaseBetQuantity = useMemo(() => {
|
|
if (!hasSelections) {
|
|
return true
|
|
}
|
|
|
|
const activeChip = chips.find((chip) => chip.id === activeChipId)
|
|
|
|
if (!activeChip) {
|
|
return false
|
|
}
|
|
|
|
const nextQuantity = activeBetQuantity + 1
|
|
const nextTotalPerSelection = activeChip.amount * nextQuantity
|
|
const nextTotal = nextTotalPerSelection * selections.length
|
|
|
|
return tableLimitMax <= 0 || nextTotal <= tableLimitMax
|
|
}, [
|
|
hasSelections,
|
|
chips,
|
|
activeChipId,
|
|
activeBetQuantity,
|
|
selections.length,
|
|
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 latestBalance = parseBalance(
|
|
useAuthStore.getState().currentUser?.coin,
|
|
)
|
|
|
|
if (totalBetAmount > latestBalance) {
|
|
notify.warning(t('commonUi.toast.insufficientBalance'))
|
|
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,
|
|
totalBetAmount,
|
|
])
|
|
|
|
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,
|
|
canIncreaseBetQuantity,
|
|
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: () => {
|
|
if (!canIncreaseBetQuantity) {
|
|
notify.warning(t('commonUi.toast.betLimitExceeded'))
|
|
return
|
|
}
|
|
|
|
adjustBetQuantity(1)
|
|
},
|
|
onConfirm: handleConfirm,
|
|
onClearSelections: clearSelections,
|
|
onOpenAutoSetting: handleOpenAutoSetting,
|
|
onRepeatSelections: handleRepeatSelections,
|
|
maxSelectionCountLabel: maxSelectionCount,
|
|
selectedBetQuantityLabel: activeBetQuantity,
|
|
selectedChipId: activeChipId,
|
|
selectedCountLabel: selections.length,
|
|
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
|
|
chips: chipItems,
|
|
}
|
|
}
|