feat(game): 添加长按取消下注功能和优化控制组件

- 在桌面和移动版动物游戏组件中添加长按取消下注功能
- 新增 LongPressProgress 组件显示长按进度动画
- 重构游戏控制组件,移除增加/减少投注数量按钮
- 更新投注逻辑,优化单笔投注金额计算方式
- 修复移动端触摸目标选择器样式问题
- 添加右键菜单禁用和键盘删除操作支持
- 更新多语言文件中的游戏说明和取消投注标签
- 优化自动托管运行器中的投注分组逻辑
This commit is contained in:
JiaJun
2026-07-17 18:22:14 +08:00
parent 2a76f1d722
commit 1b7557e2cc
17 changed files with 373 additions and 274 deletions

View File

@@ -1,14 +1,21 @@
import { useEffect, useMemo, useState } from 'react'
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 {
selectSelectionTotal,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
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
@@ -57,9 +64,6 @@ export function useAnimalVm(
)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
(state) => state.activeBetQuantity,
)
const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const roundId = useGameRoundStore((state) => state.round.id)
@@ -72,7 +76,6 @@ export function useAnimalVm(
(state) => state.removeSelectionsForCell,
)
const selections = useGameRoundStore((state) => state.selections)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const connection = useGameSessionStore((state) => state.connection)
const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax,
@@ -90,6 +93,17 @@ export function useAnimalVm(
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,
@@ -125,6 +139,13 @@ export function useAnimalVm(
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
@@ -166,6 +187,12 @@ export function useAnimalVm(
}
}, [animalIds, shouldAnimateStandby])
useEffect(() => {
return () => {
clearLongPressTimer()
}
}, [clearLongPressTimer])
const handleStart = () => {
if (authStatus !== 'authenticated') {
notify.warning(t('commonUi.toast.loginRequired'))
@@ -179,6 +206,19 @@ export function useAnimalVm(
}
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
}
@@ -188,12 +228,9 @@ export function useAnimalVm(
return
}
if (selectionByCell[animalId]) {
removeSelectionsForCell(animalId)
return
}
const hasExistingSelection = Boolean(selectionByCell[animalId])
if (selectedCellCount >= maxSelectionCount) {
if (!hasExistingSelection && selectedCellCount >= maxSelectionCount) {
setCellWarning({
cellId: animalId,
type: 'limit',
@@ -201,9 +238,16 @@ export function useAnimalVm(
return
}
const nextBetAmount = (activeChip?.amount ?? 0) * activeBetQuantity
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 && totalBetAmount + nextBetAmount > tableLimitMax) {
if (tableLimitMax > 0 && nextSingleBetAmount > tableLimitMax) {
setCellWarning({
cellId: animalId,
type: 'betLimit',
@@ -211,7 +255,7 @@ export function useAnimalVm(
return
}
if (totalBetAmount + nextBetAmount > balance) {
if (nextTotalBetAmount > balance) {
setCellWarning({
cellId: animalId,
type: 'balance',
@@ -222,12 +266,94 @@ export function useAnimalVm(
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,