Files
36-character-flower/src/hooks/use-game-control-vm.ts
JiaJun 5768632313 feat(game): 添加动物精灵图像变体支持并优化揭示动画
- 引入 light-animal-sprite 图像资源和 variant 属性支持
- 在动物组件中实现正常和浅色精灵图像切换功能
- 重构桌面和移动版动物组件的揭示动画逻辑
- 移除冗余的运动偏好检测和布局效果钩子
- 调整揭示动画的时间参数和平滑度曲线
- 更新资源预加载配置以包含浅色动物图像
- 修改控制组件中的投注数量调整状态管理
- 优化脚本生成精灵图像的复用性
2026-06-11 15:50:22 +08:00

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