- 在桌面和移动版动物游戏组件中添加长按取消下注功能 - 新增 LongPressProgress 组件显示长按进度动画 - 重构游戏控制组件,移除增加/减少投注数量按钮 - 更新投注逻辑,优化单笔投注金额计算方式 - 修复移动端触摸目标选择器样式问题 - 添加右键菜单禁用和键盘删除操作支持 - 更新多语言文件中的游戏说明和取消投注标签 - 优化自动托管运行器中的投注分组逻辑
362 lines
9.8 KiB
TypeScript
362 lines
9.8 KiB
TypeScript
import {
|
|
type PointerEvent as ReactPointerEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { notify } from '@/lib/notify'
|
|
import { useAudioStore, useAuthStore, useModalStore } from '@/store'
|
|
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
|
import type { DesktopAnimalWarningType } from '@/type'
|
|
|
|
const LONG_PRESS_DURATION_MS = 500
|
|
const LONG_PRESS_CLICK_SUPPRESSION_MS = 1_000
|
|
const LONG_PRESS_MOVE_TOLERANCE_PX = 8
|
|
|
|
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 getNextMarqueeId(ids: number[], currentId: number | null) {
|
|
if (ids.length === 0) {
|
|
return null
|
|
}
|
|
|
|
if (ids.length === 1) {
|
|
return ids[0] ?? null
|
|
}
|
|
|
|
let nextId = currentId
|
|
|
|
while (nextId === currentId) {
|
|
nextId = ids[Math.floor(Math.random() * ids.length)] ?? currentId
|
|
}
|
|
|
|
return nextId
|
|
}
|
|
|
|
export function useAnimalVm(
|
|
animalIds: number[],
|
|
onSelect?: (animalId: number) => void,
|
|
) {
|
|
const { t } = useTranslation()
|
|
const authStatus = useAuthStore((state) => state.status)
|
|
const currentUser = useAuthStore((state) => state.currentUser)
|
|
const markSoundPlaybackUnlocked = useAudioStore(
|
|
(state) => state.markSoundPlaybackUnlocked,
|
|
)
|
|
const isLoginModalOpen = useModalStore((state) => state.modals.desktopLogin)
|
|
const isRegisterModalOpen = useModalStore(
|
|
(state) => state.modals.desktopRegister,
|
|
)
|
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
|
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
|
const chips = useGameRoundStore((state) => state.chips)
|
|
const clearSelections = useGameRoundStore((state) => state.clearSelections)
|
|
const roundId = useGameRoundStore((state) => state.round.id)
|
|
const roundPhase = useGameRoundStore((state) => state.round.phase)
|
|
const maxSelectionCount = useGameRoundStore(
|
|
(state) => state.maxSelectionCount,
|
|
)
|
|
const placeBet = useGameRoundStore((state) => state.placeBet)
|
|
const removeSelectionsForCell = useGameRoundStore(
|
|
(state) => state.removeSelectionsForCell,
|
|
)
|
|
const selections = useGameRoundStore((state) => state.selections)
|
|
const connection = useGameSessionStore((state) => state.connection)
|
|
const tableLimitMax = useGameSessionStore(
|
|
(state) => state.dashboard.tableLimitMax,
|
|
)
|
|
const requestRealtimeConnection = useGameSessionStore(
|
|
(state) => state.requestRealtimeConnection,
|
|
)
|
|
const shouldConnectRealtime = useGameSessionStore(
|
|
(state) => state.shouldConnectRealtime,
|
|
)
|
|
const [marqueeId, setMarqueeId] = useState<number | null>(() =>
|
|
getNextMarqueeId(animalIds, null),
|
|
)
|
|
const [cellWarning, setCellWarning] = useState<{
|
|
cellId: number
|
|
type: DesktopAnimalWarningType
|
|
} | null>(null)
|
|
const [longPressCellId, setLongPressCellId] = useState<number | null>(null)
|
|
const longPressTimerRef = useRef<number | null>(null)
|
|
const longPressOriginRef = useRef<{
|
|
pointerId: number
|
|
x: number
|
|
y: number
|
|
} | null>(null)
|
|
const suppressedClickRef = useRef<{
|
|
cellId: number
|
|
expiresAt: number
|
|
} | null>(null)
|
|
|
|
const activeChip = useMemo(
|
|
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
|
|
[activeChipId, chips],
|
|
)
|
|
const balance = parseBalance(currentUser?.coin)
|
|
const selectionByCell = useMemo(() => {
|
|
return selections.reduce<Record<number, { amount: number; count: number }>>(
|
|
(accumulator, selection) => {
|
|
const current = accumulator[selection.cellId] ?? { amount: 0, count: 0 }
|
|
|
|
accumulator[selection.cellId] = {
|
|
amount: current.amount + selection.amount,
|
|
count: current.count + 1,
|
|
}
|
|
|
|
return accumulator
|
|
},
|
|
{},
|
|
)
|
|
}, [selections])
|
|
|
|
const isRealtimeConnected = connection.status === 'connected'
|
|
const isRealtimeConnecting =
|
|
shouldConnectRealtime &&
|
|
(connection.status === 'connecting' || connection.status === 'reconnecting')
|
|
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
|
|
const isAuthModalOpen = isLoginModalOpen || isRegisterModalOpen
|
|
const shouldAnimateStandby = showStandbyState && !isAuthModalOpen
|
|
const hasSubmittedCurrentRound =
|
|
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
|
|
const lockInteraction =
|
|
showStandbyState || hasSubmittedCurrentRound || roundPhase !== 'betting'
|
|
const selectedCellCount = Object.keys(selectionByCell).length
|
|
|
|
const clearLongPressTimer = useCallback(() => {
|
|
if (longPressTimerRef.current !== null) {
|
|
window.clearTimeout(longPressTimerRef.current)
|
|
longPressTimerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (cellWarning === null) {
|
|
return
|
|
}
|
|
|
|
const timerId = window.setTimeout(() => {
|
|
setCellWarning((currentWarning) =>
|
|
currentWarning?.cellId === cellWarning.cellId &&
|
|
currentWarning.type === cellWarning.type
|
|
? null
|
|
: currentWarning,
|
|
)
|
|
}, 1200)
|
|
|
|
return () => {
|
|
window.clearTimeout(timerId)
|
|
}
|
|
}, [cellWarning])
|
|
|
|
useEffect(() => {
|
|
if (!shouldAnimateStandby) {
|
|
setMarqueeId(null)
|
|
return
|
|
}
|
|
|
|
setMarqueeId((currentId) => getNextMarqueeId(animalIds, currentId))
|
|
|
|
let timerId = 0
|
|
|
|
const loop = () => {
|
|
setMarqueeId((currentId) => getNextMarqueeId(animalIds, currentId))
|
|
timerId = window.setTimeout(loop, 180 + Math.floor(Math.random() * 220))
|
|
}
|
|
|
|
timerId = window.setTimeout(loop, 220)
|
|
|
|
return () => {
|
|
window.clearTimeout(timerId)
|
|
}
|
|
}, [animalIds, shouldAnimateStandby])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearLongPressTimer()
|
|
}
|
|
}, [clearLongPressTimer])
|
|
|
|
const handleStart = () => {
|
|
if (authStatus !== 'authenticated') {
|
|
notify.warning(t('commonUi.toast.loginRequired'))
|
|
setModalOpen('desktopLogin', true)
|
|
return
|
|
}
|
|
|
|
clearSelections()
|
|
markSoundPlaybackUnlocked()
|
|
requestRealtimeConnection()
|
|
}
|
|
|
|
const handleSelect = (animalId: number) => {
|
|
const suppressedClick = suppressedClickRef.current
|
|
|
|
if (suppressedClick !== null) {
|
|
suppressedClickRef.current = null
|
|
|
|
if (
|
|
suppressedClick.cellId === animalId &&
|
|
Date.now() <= suppressedClick.expiresAt
|
|
) {
|
|
return
|
|
}
|
|
}
|
|
|
|
if (roundPhase !== 'betting' || lockInteraction) {
|
|
return
|
|
}
|
|
|
|
if (onSelect) {
|
|
onSelect(animalId)
|
|
return
|
|
}
|
|
|
|
const hasExistingSelection = Boolean(selectionByCell[animalId])
|
|
|
|
if (!hasExistingSelection && selectedCellCount >= maxSelectionCount) {
|
|
setCellWarning({
|
|
cellId: animalId,
|
|
type: 'limit',
|
|
})
|
|
return
|
|
}
|
|
|
|
const currentSingleBetAmount = selections[0]?.amount ?? 0
|
|
const nextSingleBetAmount = hasExistingSelection
|
|
? currentSingleBetAmount + (activeChip?.amount ?? 0)
|
|
: (activeChip?.amount ?? 0)
|
|
const nextSelectedCellCount = hasExistingSelection
|
|
? selectedCellCount
|
|
: selectedCellCount + 1
|
|
const nextTotalBetAmount = nextSingleBetAmount * nextSelectedCellCount
|
|
|
|
if (tableLimitMax > 0 && nextSingleBetAmount > tableLimitMax) {
|
|
setCellWarning({
|
|
cellId: animalId,
|
|
type: 'betLimit',
|
|
})
|
|
return
|
|
}
|
|
|
|
if (nextTotalBetAmount > balance) {
|
|
setCellWarning({
|
|
cellId: animalId,
|
|
type: 'balance',
|
|
})
|
|
return
|
|
}
|
|
|
|
placeBet(animalId)
|
|
}
|
|
|
|
const handleClearCell = (animalId: number) => {
|
|
if (lockInteraction || !selectionByCell[animalId]) {
|
|
return
|
|
}
|
|
|
|
removeSelectionsForCell(animalId)
|
|
}
|
|
|
|
const handleLongPressStart = (
|
|
animalId: number,
|
|
event: ReactPointerEvent<HTMLButtonElement>,
|
|
) => {
|
|
if (
|
|
lockInteraction ||
|
|
!selectionByCell[animalId] ||
|
|
(event.pointerType === 'mouse' && event.button !== 0)
|
|
) {
|
|
return
|
|
}
|
|
|
|
clearLongPressTimer()
|
|
suppressedClickRef.current = null
|
|
setLongPressCellId(animalId)
|
|
longPressOriginRef.current = {
|
|
pointerId: event.pointerId,
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
}
|
|
event.currentTarget.setPointerCapture(event.pointerId)
|
|
longPressTimerRef.current = window.setTimeout(() => {
|
|
suppressedClickRef.current = {
|
|
cellId: animalId,
|
|
expiresAt: Date.now() + LONG_PRESS_CLICK_SUPPRESSION_MS,
|
|
}
|
|
longPressTimerRef.current = null
|
|
longPressOriginRef.current = null
|
|
setLongPressCellId(null)
|
|
handleClearCell(animalId)
|
|
|
|
if (typeof navigator.vibrate === 'function') {
|
|
navigator.vibrate(30)
|
|
}
|
|
}, LONG_PRESS_DURATION_MS)
|
|
}
|
|
|
|
const handleLongPressMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
|
const origin = longPressOriginRef.current
|
|
|
|
if (!origin || origin.pointerId !== event.pointerId) {
|
|
return
|
|
}
|
|
|
|
if (
|
|
Math.abs(event.clientX - origin.x) > LONG_PRESS_MOVE_TOLERANCE_PX ||
|
|
Math.abs(event.clientY - origin.y) > LONG_PRESS_MOVE_TOLERANCE_PX
|
|
) {
|
|
clearLongPressTimer()
|
|
longPressOriginRef.current = null
|
|
setLongPressCellId(null)
|
|
}
|
|
}
|
|
|
|
const handleLongPressEnd = () => {
|
|
clearLongPressTimer()
|
|
longPressOriginRef.current = null
|
|
setLongPressCellId(null)
|
|
}
|
|
|
|
const handleLongPressCancel = () => {
|
|
clearLongPressTimer()
|
|
longPressOriginRef.current = null
|
|
suppressedClickRef.current = null
|
|
setLongPressCellId(null)
|
|
}
|
|
|
|
return {
|
|
cellWarning,
|
|
handleClearCell,
|
|
handleLongPressCancel,
|
|
handleLongPressEnd,
|
|
handleLongPressMove,
|
|
handleLongPressStart,
|
|
handleSelect,
|
|
handleStart,
|
|
isRealtimeConnecting,
|
|
lockInteraction,
|
|
longPressCellId,
|
|
longPressDurationMs: LONG_PRESS_DURATION_MS,
|
|
marqueeId: shouldAnimateStandby ? marqueeId : null,
|
|
selectionByCell,
|
|
showStandbyState,
|
|
}
|
|
}
|