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

@@ -7,6 +7,7 @@ import diamondIcon from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image' import { SmartImage } from '@/components/smart-image'
import { DesktopAnimalOverlay } from '@/features/game/components/desktop/desktop-animal-overlay.tsx' import { DesktopAnimalOverlay } from '@/features/game/components/desktop/desktop-animal-overlay.tsx'
import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image' import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image'
import { LongPressProgress } from '@/features/game/components/shared/long-press-progress'
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx' import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
import { FLOWER_IMAGE_LIST } from '@/features/game/shared' import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { useAnimalVm } from '@/hooks/use-animal-vm' import { useAnimalVm } from '@/hooks/use-animal-vm'
@@ -83,10 +84,17 @@ export function DesktopAnimal({
) )
const { const {
cellWarning, cellWarning,
handleClearCell,
handleLongPressCancel,
handleLongPressEnd,
handleLongPressMove,
handleLongPressStart,
handleSelect, handleSelect,
handleStart, handleStart,
isRealtimeConnecting, isRealtimeConnecting,
lockInteraction, lockInteraction,
longPressCellId,
longPressDurationMs,
marqueeId, marqueeId,
selectionByCell, selectionByCell,
showStandbyState, showStandbyState,
@@ -212,6 +220,17 @@ export function DesktopAnimal({
type="button" type="button"
disabled={lockInteraction || showStopOverlay} disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)} onClick={() => handleSelect(item.id)}
onContextMenu={(event) => event.preventDefault()}
onKeyDown={(event) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
handleClearCell(item.id)
}
}}
onPointerCancel={handleLongPressCancel}
onPointerDown={(event) => handleLongPressStart(item.id, event)}
onPointerMove={handleLongPressMove}
onPointerUp={handleLongPressEnd}
animate={ animate={
showCellWarning showCellWarning
? { ? {
@@ -372,6 +391,12 @@ export function DesktopAnimal({
</span> </span>
</span> </span>
) : null} ) : null}
{longPressCellId === item.id ? (
<LongPressProgress
durationMs={longPressDurationMs}
label={t('gameDesktop.animal.cancelBet')}
/>
) : null}
</motion.button> </motion.button>
) )
})} })}

View File

@@ -1,7 +1,6 @@
import { motion } from 'motion/react' import { motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import reduce from '@/assets/game/add.webp'
import arrow from '@/assets/game/arrow.webp' import arrow from '@/assets/game/arrow.webp'
import chipBg from '@/assets/game/chip-bg.webp' import chipBg from '@/assets/game/chip-bg.webp'
import chipLineBg from '@/assets/game/chip-line-bg.webp' import chipLineBg from '@/assets/game/chip-line-bg.webp'
@@ -10,7 +9,6 @@ import confirmBg from '@/assets/game/confirm-bg.webp'
import confirmRedBg from '@/assets/game/confirm-red-bg.png' import confirmRedBg from '@/assets/game/confirm-red-bg.png'
import controlBg from '@/assets/game/control-bg.png' import controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp' import leftBottomBg from '@/assets/game/left-bg.webp'
import add from '@/assets/game/reduce.webp'
import totalBg from '@/assets/game/total-bg.webp' import totalBg from '@/assets/game/total-bg.webp'
import diamond from '@/assets/system/diamond.webp' import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx' import { SmartBackground } from '@/components/smart-background.tsx'
@@ -27,8 +25,6 @@ export function DesktopControl() {
acceptingBets, acceptingBets,
actionsEnabled, actionsEnabled,
canClear, canClear,
canDecreaseBetQuantity,
canIncreaseBetQuantity,
chips, chips,
confirmLabel, confirmLabel,
confirmState, confirmState,
@@ -37,11 +33,8 @@ export function DesktopControl() {
onChipSelect, onChipSelect,
onConfirm, onConfirm,
onClearSelections, onClearSelections,
onDecreaseBetQuantity,
onIncreaseBetQuantity,
onOpenAutoSetting, onOpenAutoSetting,
onRepeatSelections, onRepeatSelections,
selectedBetQuantityLabel,
selectedChipId, selectedChipId,
selectedCountLabel, selectedCountLabel,
totalBetAmountLabel, totalBetAmountLabel,
@@ -181,7 +174,7 @@ export function DesktopControl() {
src={chipLineBg} src={chipLineBg}
size="100% 100%" size="100% 100%"
className={ className={
'flex min-w-0 flex-1 items-center gap-design-10 overflow-visible' 'flex min-w-0 flex-1 items-center justify-between overflow-visible'
} }
> >
{chips.map((chip) => { {chips.map((chip) => {
@@ -338,52 +331,6 @@ export function DesktopControl() {
) )
})} })}
</SmartBackground> </SmartBackground>
<div
className={
'flex h-design-50 shrink-0 items-center rounded-md bg-[#091118] box-border px-design-2 py-design-3'
}
>
<button
type="button"
disabled={!acceptingBets || !canDecreaseBetQuantity}
onClick={onDecreaseBetQuantity}
className={cn(
'flex h-design-40 w-design-40 shrink-0 items-center justify-center',
acceptingBets && canDecreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={add}
alt={`add`}
className={'w-design-40 h-design-40'}
/>
</button>
<div
className={'w-design-80 h-full flex items-center justify-center'}
>
{selectedBetQuantityLabel}
</div>
<button
type="button"
disabled={!acceptingBets || !canIncreaseBetQuantity}
onClick={onIncreaseBetQuantity}
className={cn(
'flex h-design-40 w-design-40 shrink-0 items-center justify-center',
acceptingBets && canIncreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={reduce}
alt={`reduce`}
className={'w-design-40 h-design-40'}
/>
</button>
</div>
</SmartBackground> </SmartBackground>
<SmartBackground <SmartBackground
src={totalBg} src={totalBg}

View File

@@ -8,6 +8,7 @@ import { SmartImage } from '@/components/smart-image'
import { MobileAnimalOverlay } from '@/features/game/components/mobile/mobile-animal-overlay.tsx' import { MobileAnimalOverlay } from '@/features/game/components/mobile/mobile-animal-overlay.tsx'
import { MobileStatusLine } from '@/features/game/components/mobile/mobile-status.tsx' import { MobileStatusLine } from '@/features/game/components/mobile/mobile-status.tsx'
import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image' import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image'
import { LongPressProgress } from '@/features/game/components/shared/long-press-progress'
import { FLOWER_IMAGE_LIST } from '@/features/game/shared' import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { useAnimalVm } from '@/hooks/use-animal-vm' import { useAnimalVm } from '@/hooks/use-animal-vm'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -83,10 +84,17 @@ export function MobileAnimal({
) )
const { const {
cellWarning, cellWarning,
handleClearCell,
handleLongPressCancel,
handleLongPressEnd,
handleLongPressMove,
handleLongPressStart,
handleSelect, handleSelect,
handleStart, handleStart,
isRealtimeConnecting, isRealtimeConnecting,
lockInteraction, lockInteraction,
longPressCellId,
longPressDurationMs,
marqueeId, marqueeId,
selectionByCell, selectionByCell,
showStandbyState, showStandbyState,
@@ -216,6 +224,18 @@ export function MobileAnimal({
type="button" type="button"
disabled={lockInteraction || showStopOverlay} disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)} onClick={() => handleSelect(item.id)}
onContextMenu={(event) => event.preventDefault()}
onDragStart={(event) => event.preventDefault()}
onKeyDown={(event) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
handleClearCell(item.id)
}
}}
onPointerCancel={handleLongPressCancel}
onPointerDown={(event) => handleLongPressStart(item.id, event)}
onPointerMove={handleLongPressMove}
onPointerUp={handleLongPressEnd}
animate={ animate={
showCellWarning showCellWarning
? { ? {
@@ -238,7 +258,7 @@ export function MobileAnimal({
: { duration: 0.16, ease: 'easeOut' } : { duration: 0.16, ease: 'easeOut' }
} }
className={cn( className={cn(
'relative flex h-design-36 flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150', 'mobile-animal-touch-target relative flex h-design-36 flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
!isRevealActive && 'overflow-hidden', !isRevealActive && 'overflow-hidden',
lockInteraction lockInteraction
? 'cursor-not-allowed opacity-90' ? 'cursor-not-allowed opacity-90'
@@ -376,6 +396,13 @@ export function MobileAnimal({
</span> </span>
</span> </span>
) : null} ) : null}
{longPressCellId === item.id ? (
<LongPressProgress
compact
durationMs={longPressDurationMs}
label={t('gameDesktop.animal.cancelBet')}
/>
) : null}
</motion.button> </motion.button>
) )
})} })}

View File

@@ -1,17 +1,14 @@
import { motion } from 'motion/react' import { motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import reduce from '@/assets/game/add.webp'
import chipBg from '@/assets/game/chip-bg.webp' import chipBg from '@/assets/game/chip-bg.webp'
import chipLineBg from '@/assets/game/chip-line-bg.webp' import chipLineBg from '@/assets/game/chip-line-bg.webp'
import chipLock from '@/assets/game/chip-lock.webp' import chipLock from '@/assets/game/chip-lock.webp'
import confirmRedBg from '@/assets/game/confirm-red-bg.png' import confirmRedBg from '@/assets/game/confirm-red-bg.png'
import controlBg from '@/assets/game/control-bg.png' import controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp' import leftBottomBg from '@/assets/game/left-bg.webp'
import mobileAddReduceBg from '@/assets/game/mobile-add-reduce-bg.webp'
import mobileConfirmBg from '@/assets/game/mobile-contro-comfirm.webp' import mobileConfirmBg from '@/assets/game/mobile-contro-comfirm.webp'
import mobileTotalBg from '@/assets/game/mobile-control-number.webp' import mobileTotalBg from '@/assets/game/mobile-control-number.webp'
import add from '@/assets/game/reduce.webp'
import diamond from '@/assets/system/diamond.webp' import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx' import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx' import { SmartImage } from '@/components/smart-image.tsx'
@@ -27,8 +24,6 @@ export function MobileControl() {
acceptingBets, acceptingBets,
actionsEnabled, actionsEnabled,
canClear, canClear,
canDecreaseBetQuantity,
canIncreaseBetQuantity,
chips, chips,
confirmLabel, confirmLabel,
confirmState, confirmState,
@@ -37,11 +32,8 @@ export function MobileControl() {
onChipSelect, onChipSelect,
onConfirm, onConfirm,
onClearSelections, onClearSelections,
onDecreaseBetQuantity,
onIncreaseBetQuantity,
onOpenAutoSetting, onOpenAutoSetting,
onRepeatSelections, onRepeatSelections,
selectedBetQuantityLabel,
selectedChipId, selectedChipId,
selectedCountLabel, selectedCountLabel,
totalBetAmountLabel, totalBetAmountLabel,
@@ -154,7 +146,7 @@ export function MobileControl() {
<SmartBackground <SmartBackground
src={chipLineBg} src={chipLineBg}
size="100% 100%" size="100% 100%"
className="flex h-design-32 min-w-0 items-center justify-between gap-design-7 overflow-visible" className="flex h-design-32 min-w-0 flex-1 items-center justify-around overflow-visible"
> >
{chips.map((chip) => { {chips.map((chip) => {
const isSelected = chip.id === selectedChipId const isSelected = chip.id === selectedChipId
@@ -287,50 +279,6 @@ export function MobileControl() {
})} })}
</SmartBackground> </SmartBackground>
</SmartBackground> </SmartBackground>
<SmartBackground
src={mobileAddReduceBg}
size="100% 100%"
className="relative z-20 !ml-[calc(var(--design-unit)*-8)] pl-design-15 flex h-full w-design-103 shrink-0 items-center justify-center bg-center bg-no-repeat px-design-7 text-design-13 font-bold"
>
<button
type="button"
disabled={!acceptingBets || !canDecreaseBetQuantity}
onClick={onDecreaseBetQuantity}
className={cn(
'flex h-design-24 w-design-24 shrink-0 items-center justify-center',
acceptingBets && canDecreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={add}
alt="decrease"
className="h-design-16 w-design-16"
/>
</button>
<div className="flex h-design-24 w-design-20 items-center justify-center rounded-sm">
{selectedBetQuantityLabel}
</div>
<button
type="button"
disabled={!acceptingBets || !canIncreaseBetQuantity}
onClick={onIncreaseBetQuantity}
className={cn(
'flex h-design-24 w-design-24 shrink-0 items-center justify-center',
acceptingBets && canIncreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage
src={reduce}
alt="increase"
className="h-design-16 w-design-16"
/>
</button>
</SmartBackground>
</div> </div>
<div className="flex h-design-40 w-full items-center gap-design-3"> <div className="flex h-design-40 w-full items-center gap-design-3">

View File

@@ -15,7 +15,7 @@ import { DesktopCountdown } from '@/features/game/components/desktop/desktop-cou
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts' import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
import { cn } from '@/lib/utils.ts' import { cn } from '@/lib/utils.ts'
const FORCE_MOBILE_STREAK_EFFECT_PREVIEW = true const FORCE_MOBILE_STREAK_EFFECT_PREVIEW = false
function isIosDevice() { function isIosDevice() {
if (typeof navigator === 'undefined') { if (typeof navigator === 'undefined') {

View File

@@ -0,0 +1,77 @@
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
interface LongPressProgressProps {
compact?: boolean
durationMs: number
label: string
}
export function LongPressProgress({
compact = false,
durationMs,
label,
}: LongPressProgressProps) {
return (
<motion.span
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn(
'pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-[rgba(20,3,6,0.42)]',
compact
? 'rounded-[calc(var(--design-unit)*6)]'
: 'rounded-[calc(var(--design-unit)*18)]',
)}
>
<motion.span
initial={{ opacity: 0.25, scale: 0.82 }}
animate={{ opacity: 0.9, scale: 1 }}
transition={{ duration: durationMs / 1000, ease: 'linear' }}
className={cn(
'absolute rounded-full bg-[radial-gradient(circle,rgba(255,104,104,0.28)_0%,rgba(255,48,64,0.12)_52%,transparent_72%)]',
compact ? 'h-design-25 w-design-25' : 'h-design-76 w-design-76',
)}
/>
<svg
aria-hidden="true"
viewBox="0 0 36 36"
className={cn(
'relative -rotate-90 drop-shadow-[0_0_calc(var(--design-unit)*6)_rgba(255,74,86,0.72)]',
compact ? 'h-design-22 w-design-22' : 'h-design-64 w-design-64',
)}
>
<circle
cx="18"
cy="18"
r="15.5"
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="3"
/>
<motion.circle
cx="18"
cy="18"
r="15.5"
fill="none"
stroke="#FF5968"
strokeLinecap="round"
strokeWidth="3"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: durationMs / 1000, ease: 'linear' }}
/>
</svg>
<span
className={cn(
'absolute z-10 text-center font-bold uppercase leading-[1.05] text-[#FFE5E8] [text-shadow:0_0_calc(var(--design-unit)*4)_rgba(255,40,58,0.95)]',
compact
? 'max-w-design-18 text-[calc(var(--design-unit)*3)]'
: 'max-w-design-52 text-design-9',
)}
>
{label}
</span>
</motion.span>
)
}

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 { useTranslation } from 'react-i18next'
import { notify } from '@/lib/notify' import { notify } from '@/lib/notify'
import { useAudioStore, useAuthStore, useModalStore } from '@/store' import { useAudioStore, useAuthStore, useModalStore } from '@/store'
import { import { useGameRoundStore, useGameSessionStore } from '@/store/game'
selectSelectionTotal,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
import type { DesktopAnimalWarningType } from '@/type' 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) { function parseBalance(value: string | number | null | undefined) {
if (typeof value === 'number') { if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0 return Number.isFinite(value) ? value : 0
@@ -57,9 +64,6 @@ export function useAnimalVm(
) )
const setModalOpen = useModalStore((state) => state.setModalOpen) const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId) const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
(state) => state.activeBetQuantity,
)
const chips = useGameRoundStore((state) => state.chips) const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections) const clearSelections = useGameRoundStore((state) => state.clearSelections)
const roundId = useGameRoundStore((state) => state.round.id) const roundId = useGameRoundStore((state) => state.round.id)
@@ -72,7 +76,6 @@ export function useAnimalVm(
(state) => state.removeSelectionsForCell, (state) => state.removeSelectionsForCell,
) )
const selections = useGameRoundStore((state) => state.selections) const selections = useGameRoundStore((state) => state.selections)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const connection = useGameSessionStore((state) => state.connection) const connection = useGameSessionStore((state) => state.connection)
const tableLimitMax = useGameSessionStore( const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax, (state) => state.dashboard.tableLimitMax,
@@ -90,6 +93,17 @@ export function useAnimalVm(
cellId: number cellId: number
type: DesktopAnimalWarningType type: DesktopAnimalWarningType
} | null>(null) } | 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( const activeChip = useMemo(
() => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null, () => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null,
@@ -125,6 +139,13 @@ export function useAnimalVm(
showStandbyState || hasSubmittedCurrentRound || roundPhase !== 'betting' showStandbyState || hasSubmittedCurrentRound || roundPhase !== 'betting'
const selectedCellCount = Object.keys(selectionByCell).length const selectedCellCount = Object.keys(selectionByCell).length
const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
}, [])
useEffect(() => { useEffect(() => {
if (cellWarning === null) { if (cellWarning === null) {
return return
@@ -166,6 +187,12 @@ export function useAnimalVm(
} }
}, [animalIds, shouldAnimateStandby]) }, [animalIds, shouldAnimateStandby])
useEffect(() => {
return () => {
clearLongPressTimer()
}
}, [clearLongPressTimer])
const handleStart = () => { const handleStart = () => {
if (authStatus !== 'authenticated') { if (authStatus !== 'authenticated') {
notify.warning(t('commonUi.toast.loginRequired')) notify.warning(t('commonUi.toast.loginRequired'))
@@ -179,6 +206,19 @@ export function useAnimalVm(
} }
const handleSelect = (animalId: number) => { 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) { if (roundPhase !== 'betting' || lockInteraction) {
return return
} }
@@ -188,12 +228,9 @@ export function useAnimalVm(
return return
} }
if (selectionByCell[animalId]) { const hasExistingSelection = Boolean(selectionByCell[animalId])
removeSelectionsForCell(animalId)
return
}
if (selectedCellCount >= maxSelectionCount) { if (!hasExistingSelection && selectedCellCount >= maxSelectionCount) {
setCellWarning({ setCellWarning({
cellId: animalId, cellId: animalId,
type: 'limit', type: 'limit',
@@ -201,9 +238,16 @@ export function useAnimalVm(
return 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({ setCellWarning({
cellId: animalId, cellId: animalId,
type: 'betLimit', type: 'betLimit',
@@ -211,7 +255,7 @@ export function useAnimalVm(
return return
} }
if (totalBetAmount + nextBetAmount > balance) { if (nextTotalBetAmount > balance) {
setCellWarning({ setCellWarning({
cellId: animalId, cellId: animalId,
type: 'balance', type: 'balance',
@@ -222,12 +266,94 @@ export function useAnimalVm(
placeBet(animalId) 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 { return {
cellWarning, cellWarning,
handleClearCell,
handleLongPressCancel,
handleLongPressEnd,
handleLongPressMove,
handleLongPressStart,
handleSelect, handleSelect,
handleStart, handleStart,
isRealtimeConnecting, isRealtimeConnecting,
lockInteraction, lockInteraction,
longPressCellId,
longPressDurationMs: LONG_PRESS_DURATION_MS,
marqueeId: shouldAnimateStandby ? marqueeId : null, marqueeId: shouldAnimateStandby ? marqueeId : null,
selectionByCell, selectionByCell,
showStandbyState, showStandbyState,

View File

@@ -56,32 +56,21 @@ function formatBetAmount(amount: number) {
return amount.toFixed(2).replace(/\.?0+$/, '') return amount.toFixed(2).replace(/\.?0+$/, '')
} }
function groupSelections(selections: BetSelection[]) { function resolveUniformBet(selections: BetSelection[]) {
return selections.reduce< const latestSelection = selections.at(-1)
Map<string, { amount: number; betId: number; numbers: number[] }> const betId = toBetId(latestSelection?.chipId ?? '')
>((accumulator, selection) => {
const betId = toBetId(selection.chipId)
if (betId === null) { if (!latestSelection || betId === null || latestSelection.amount <= 0) {
return accumulator return null
} }
const groupKey = `${betId}:${selection.amount}` return {
const current = accumulator.get(groupKey) amount: latestSelection.amount,
betId,
if (current) { numbers: [...new Set(selections.map((selection) => selection.cellId))].sort(
current.numbers.push(selection.cellId) (left, right) => left - right,
return accumulator ),
} }
accumulator.set(groupKey, {
amount: selection.amount,
betId,
numbers: [selection.cellId],
})
return accumulator
}, new Map())
} }
export function useAutoHostingRunner() { export function useAutoHostingRunner() {
@@ -167,9 +156,9 @@ export function useAutoHostingRunner() {
return return
} }
const groupedSelections = groupSelections(selections) const uniformBet = resolveUniformBet(selections)
if (groupedSelections.size === 0) { if (!uniformBet || uniformBet.numbers.length === 0) {
stopHosting() stopHosting()
notify.warning(t('commonUi.toast.autoHostingStopped')) notify.warning(t('commonUi.toast.autoHostingStopped'))
return return
@@ -181,7 +170,7 @@ export function useAutoHostingRunner() {
) )
const balance = parseBalance(currentUser.coin) const balance = parseBalance(currentUser.coin)
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) { if (tableLimitMax > 0 && uniformBet.amount > tableLimitMax) {
stopHosting() stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBetLimit')) notify.warning(t('commonUi.toast.autoHostingStoppedBetLimit'))
return return
@@ -199,38 +188,22 @@ export function useAutoHostingRunner() {
const submitAutoBet = async () => { const submitAutoBet = async () => {
try { try {
let latestBalance = currentUser.coin ?? '0' let latestBalance = currentUser.coin ?? '0'
let remainingBalance = parseBalance(latestBalance) const formattedSingleBetAmount = formatBetAmount(uniformBet.amount)
const result = await placeGameBet({
bet_amount: formattedSingleBetAmount,
bet_id: uniformBet.betId,
idempotency_key: createIdempotencyKey(),
numbers: uniformBet.numbers.join(','),
period_no: round.id,
single_bet_amount: formattedSingleBetAmount,
})
for (const group of groupedSelections.values()) { if (result.status !== 'accepted') {
const uniqueNumbers = [...new Set(group.numbers)].sort( throw new Error(t('commonUi.toast.betRejected'))
(left, right) => left - right,
)
const groupCost = group.amount * uniqueNumbers.length
if (groupCost > remainingBalance) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBalance'))
return
}
const formattedSingleBetAmount = formatBetAmount(group.amount)
const result = await placeGameBet({
bet_amount: formattedSingleBetAmount,
bet_id: group.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
remainingBalance = parseBalance(latestBalance)
} }
latestBalance = result.balance_after
const latestHostingState = useGameAutoHostingStore.getState() const latestHostingState = useGameAutoHostingStore.getState()
const latestUser = useAuthStore.getState().currentUser const latestUser = useAuthStore.getState().currentUser

View File

@@ -61,16 +61,10 @@ export function useGameControlVm() {
const { t } = useTranslation() const { t } = useTranslation()
const chips = useGameRoundStore((state) => state.chips) const chips = useGameRoundStore((state) => state.chips)
const activeChipId = useGameRoundStore((state) => state.activeChipId) const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
(state) => state.activeBetQuantity,
)
const round = useGameRoundStore((state) => state.round) const round = useGameRoundStore((state) => state.round)
const maxSelectionCount = useGameRoundStore( const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount, (state) => state.maxSelectionCount,
) )
const adjustBetQuantity = useGameRoundStore(
(state) => state.adjustBetQuantity,
)
const selections = useGameRoundStore((state) => state.selections) const selections = useGameRoundStore((state) => state.selections)
const clearSelections = useGameRoundStore((state) => state.clearSelections) const clearSelections = useGameRoundStore((state) => state.clearSelections)
const restoreRecentSuccessfulSelections = useGameRoundStore( const restoreRecentSuccessfulSelections = useGameRoundStore(
@@ -126,31 +120,12 @@ export function useGameControlVm() {
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
const hasInsufficientBalance = hasSelections && totalBetAmount > balance const hasInsufficientBalance = hasSelections && totalBetAmount > balance
const hasExceededBetLimit = const hasExceededBetLimit =
hasSelections && tableLimitMax > 0 && totalBetAmount > tableLimitMax hasSelections &&
const canIncreaseBetQuantity = useMemo(() => { tableLimitMax > 0 &&
if (!hasSelections) { selections.some((selection) => selection.amount > tableLimitMax)
return true const selectedCellCount = new Set(
} selections.map((selection) => selection.cellId),
).size
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 = const confirmState: ConfirmState =
isSubmitting || isAutoHosting isSubmitting || isAutoHosting
? 'submitting' ? 'submitting'
@@ -202,8 +177,9 @@ export function useGameControlVm() {
return return
} }
const betId = toBetId(selections[0]?.chipId ?? activeChipId) const latestSelection = selections.at(-1)
const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0 const betId = toBetId(latestSelection?.chipId ?? activeChipId)
const singleBetAmount = latestSelection?.amount ?? selectedChip?.amount ?? 0
if (betId === null || singleBetAmount <= 0) { if (betId === null || singleBetAmount <= 0) {
notify.warning(t('commonUi.toast.betUnavailable')) notify.warning(t('commonUi.toast.betUnavailable'))
@@ -316,8 +292,6 @@ export function useGameControlVm() {
round.phase === 'betting' && round.phase === 'betting' &&
!hasSubmittedCurrentRound && !hasSubmittedCurrentRound &&
!isAutoHosting, !isAutoHosting,
canDecreaseBetQuantity: activeBetQuantity > 1,
canIncreaseBetQuantity,
confirmLabel: confirmLabel:
confirmState === 'idle' confirmState === 'idle'
? t('gameDesktop.control.selectNumbers') ? t('gameDesktop.control.selectNumbers')
@@ -331,23 +305,13 @@ export function useGameControlVm() {
confirmState, confirmState,
isConfirmClickable: confirmState === 'ready' && !isAutoHosting, isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
onChipSelect: selectChip, onChipSelect: selectChip,
onDecreaseBetQuantity: () => adjustBetQuantity(-1),
onIncreaseBetQuantity: () => {
if (!canIncreaseBetQuantity) {
notify.warning(t('commonUi.toast.betLimitExceeded'))
return
}
adjustBetQuantity(1)
},
onConfirm: handleConfirm, onConfirm: handleConfirm,
onClearSelections: clearSelections, onClearSelections: clearSelections,
onOpenAutoSetting: handleOpenAutoSetting, onOpenAutoSetting: handleOpenAutoSetting,
onRepeatSelections: handleRepeatSelections, onRepeatSelections: handleRepeatSelections,
maxSelectionCountLabel: maxSelectionCount, maxSelectionCountLabel: maxSelectionCount,
selectedBetQuantityLabel: activeBetQuantity,
selectedChipId: activeChipId, selectedChipId: activeChipId,
selectedCountLabel: selections.length, selectedCountLabel: selectedCellCount,
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount), totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
chips: chipItems, chips: chipItems,
} }

View File

@@ -237,6 +237,7 @@ export default {
'1. **Betting Stage**', '1. **Betting Stage**',
' Players choose the numbers they want to bet on from 36 numbers, select chips, confirm the bet, and the system deducts the balance and creates the bet order.', ' Players choose the numbers they want to bet on from 36 numbers, select chips, confirm the bet, and the system deducts the balance and creates the bet order.',
' The maximum number of selections per round depends on the current system configuration.', ' The maximum number of selections per round depends on the current system configuration.',
' (Long press the number to cancel the bet)',
'', '',
'2. **Bet Closing Stage**', '2. **Bet Closing Stage**',
' Once the round enters the closing stage, the system stops accepting new bets.', ' Once the round enters the closing stage, the system stops accepting new bets.',
@@ -701,6 +702,7 @@ export default {
announcement: 'Announcement', announcement: 'Announcement',
}, },
animal: { animal: {
cancelBet: 'Cancel Bet',
insufficientBalanceRecharge: 'Insufficient balance, please top up', insufficientBalanceRecharge: 'Insufficient balance, please top up',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED, betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Loading', loading: 'Loading',

View File

@@ -236,6 +236,7 @@ export default {
'1. **Tahap Taruhan**', '1. **Tahap Taruhan**',
' Pemain memilih nomor yang ingin dipertaruhkan dari 36 nomor, memilih chip, mengonfirmasi taruhan, dan sistem akan memotong saldo serta membuat catatan taruhan.', ' Pemain memilih nomor yang ingin dipertaruhkan dari 36 nomor, memilih chip, mengonfirmasi taruhan, dan sistem akan memotong saldo serta membuat catatan taruhan.',
' Jumlah maksimum nomor yang dapat dipilih per putaran bergantung pada konfigurasi sistem saat ini.', ' Jumlah maksimum nomor yang dapat dipilih per putaran bergantung pada konfigurasi sistem saat ini.',
' (Tekan lama nomor untuk batalkan taruhan)',
'', '',
'2. **Tahap Penutupan Taruhan**', '2. **Tahap Penutupan Taruhan**',
' Setelah putaran memasuki tahap penutupan, sistem berhenti menerima taruhan baru.', ' Setelah putaran memasuki tahap penutupan, sistem berhenti menerima taruhan baru.',
@@ -701,6 +702,7 @@ export default {
announcement: 'Pengumuman', announcement: 'Pengumuman',
}, },
animal: { animal: {
cancelBet: 'Batalkan Taruhan',
insufficientBalanceRecharge: 'Saldo tidak cukup, silakan isi ulang', insufficientBalanceRecharge: 'Saldo tidak cukup, silakan isi ulang',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED, betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Memuat', loading: 'Memuat',

View File

@@ -239,6 +239,7 @@ export default {
'1. **Peringkat Pertaruhan**', '1. **Peringkat Pertaruhan**',
' Pemain memilih nombor yang ingin dipertaruhkan daripada 36 nombor, memilih cip, mengesahkan pertaruhan, dan sistem akan menolak baki serta menjana rekod pertaruhan.', ' Pemain memilih nombor yang ingin dipertaruhkan daripada 36 nombor, memilih cip, mengesahkan pertaruhan, dan sistem akan menolak baki serta menjana rekod pertaruhan.',
' Bilangan maksimum nombor yang boleh dipilih bagi setiap pusingan bergantung pada konfigurasi semasa sistem.', ' Bilangan maksimum nombor yang boleh dipilih bagi setiap pusingan bergantung pada konfigurasi semasa sistem.',
' (Tekan lama nombor untuk batalkan pertaruhan)',
'', '',
'2. **Peringkat Penutupan Pertaruhan**', '2. **Peringkat Penutupan Pertaruhan**',
' Apabila pusingan memasuki peringkat penutupan, sistem akan berhenti menerima pertaruhan baharu.', ' Apabila pusingan memasuki peringkat penutupan, sistem akan berhenti menerima pertaruhan baharu.',
@@ -706,6 +707,7 @@ export default {
announcement: 'Pengumuman', announcement: 'Pengumuman',
}, },
animal: { animal: {
cancelBet: 'Batalkan Pertaruhan',
insufficientBalanceRecharge: 'Baki tidak mencukupi, sila tambah nilai', insufficientBalanceRecharge: 'Baki tidak mencukupi, sila tambah nilai',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED, betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Memuatkan', loading: 'Memuatkan',

View File

@@ -231,6 +231,7 @@ export default {
'1. **下注阶段**', '1. **下注阶段**',
' 玩家在 36 个号码中选择想要押注的号码,选择筹码后确认下注,系统扣款并生成注单。', ' 玩家在 36 个号码中选择想要押注的号码,选择筹码后确认下注,系统扣款并生成注单。',
' 单期最多可选号码数,以系统当前配置为准。', ' 单期最多可选号码数,以系统当前配置为准。',
' (长按号码取消下注)',
'', '',
'2. **封盘阶段**', '2. **封盘阶段**',
' 进入封盘后,系统停止接受新注单。', ' 进入封盘后,系统停止接受新注单。',
@@ -676,6 +677,7 @@ export default {
announcement: '公告栏', announcement: '公告栏',
}, },
animal: { animal: {
cancelBet: '取消下注',
insufficientBalanceRecharge: '余额不足,请充值', insufficientBalanceRecharge: '余额不足,请充值',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED, betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: '加载中', loading: '加载中',

View File

@@ -76,7 +76,10 @@ function DesktopAutoSettingModal() {
const balance = parseBalance(currentUser?.coin) const balance = parseBalance(currentUser?.coin)
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) { if (
tableLimitMax > 0 &&
selections.some((selection) => selection.amount > tableLimitMax)
) {
notify.warning(t('commonUi.toast.betLimitExceeded')) notify.warning(t('commonUi.toast.betLimitExceeded'))
return return
} }

View File

@@ -76,7 +76,10 @@ function MobileAutoSettingModal() {
const balance = parseBalance(currentUser?.coin) const balance = parseBalance(currentUser?.coin)
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) { if (
tableLimitMax > 0 &&
selections.some((selection) => selection.amount > tableLimitMax)
) {
notify.warning(t('commonUi.toast.betLimitExceeded')) notify.warning(t('commonUi.toast.betLimitExceeded'))
return return
} }

View File

@@ -86,10 +86,6 @@ function normalizeBetQuantity(quantity: number) {
return Math.max(MIN_BET_QUANTITY, Math.floor(quantity)) return Math.max(MIN_BET_QUANTITY, Math.floor(quantity))
} }
function getSelectionBetAmount(chip: Chip, quantity: number) {
return chip.amount * normalizeBetQuantity(quantity)
}
function syncSelectionsBetAmount( function syncSelectionsBetAmount(
selections: BetSelection[], selections: BetSelection[],
chipId: string, chipId: string,
@@ -102,21 +98,6 @@ function syncSelectionsBetAmount(
})) }))
} }
function resolveSelectionQuantity(
selections: BetSelection[],
chips: Chip[],
activeChipId: string,
) {
const chip = getChipById(chips, activeChipId)
const firstSelection = selections[0]
if (!chip || !firstSelection || chip.amount <= 0) {
return MIN_BET_QUANTITY
}
return normalizeBetQuantity(firstSelection.amount / chip.amount)
}
function createInitialRoundState(): GameRoundSlice & { function createInitialRoundState(): GameRoundSlice & {
activeChipId: string activeChipId: string
activeBetQuantity: number activeBetQuantity: number
@@ -158,12 +139,18 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set, get) => ({
} }
} }
const currentBetAmount = state.selections[0]?.amount ?? activeChip.amount
const nextBetAmount = Math.max(
activeChip.amount,
currentBetAmount + activeChip.amount * delta,
)
return { return {
activeBetQuantity: nextQuantity, activeBetQuantity: nextQuantity,
selections: syncSelectionsBetAmount( selections: syncSelectionsBetAmount(
state.selections, state.selections,
activeChip.id, activeChip.id,
getSelectionBetAmount(activeChip, nextQuantity), nextBetAmount,
), ),
} }
}) })
@@ -276,17 +263,34 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set, get) => ({
if ( if (
!activeChip || !activeChip ||
state.round.phase !== 'betting' || state.round.phase !== 'betting' ||
hasExistingSelection || (!hasExistingSelection && selectedCellCount >= state.maxSelectionCount)
selectedCellCount >= state.maxSelectionCount
) { ) {
return state return state
} }
const currentBetAmount = state.selections[0]?.amount ?? 0
const nextBetAmount = hasExistingSelection
? currentBetAmount + activeChip.amount
: activeChip.amount
const syncedSelections = syncSelectionsBetAmount(
state.selections,
activeChip.id,
nextBetAmount,
)
if (hasExistingSelection) {
return {
activeBetQuantity: MIN_BET_QUANTITY,
selections: syncedSelections,
}
}
return { return {
activeBetQuantity: MIN_BET_QUANTITY,
selections: [ selections: [
...state.selections, ...syncedSelections,
{ {
amount: getSelectionBetAmount(activeChip, state.activeBetQuantity), amount: nextBetAmount,
cellId, cellId,
chipId: activeChip.id, chipId: activeChip.id,
id: `bet-${cellId}-${state.selections.length + 1}-${Date.now()}`, id: `bet-${cellId}-${state.selections.length + 1}-${Date.now()}`,
@@ -372,23 +376,11 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set, get) => ({
nextSelections, nextSelections,
state.activeChipId, state.activeChipId,
) )
const nextActiveChip = getChipById(state.chips, nextActiveChipId)
const nextBetQuantity = resolveSelectionQuantity(
nextSelections,
state.chips,
nextActiveChipId,
)
set({ set({
activeBetQuantity: nextBetQuantity, activeBetQuantity: MIN_BET_QUANTITY,
activeChipId: nextActiveChipId, activeChipId: nextActiveChipId,
selections: nextActiveChip selections: nextSelections,
? syncSelectionsBetAmount(
nextSelections,
nextActiveChipId,
getSelectionBetAmount(nextActiveChip, nextBetQuantity),
)
: nextSelections,
}) })
return true return true
@@ -411,11 +403,6 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set, get) => ({
return { return {
activeBetQuantity: MIN_BET_QUANTITY, activeBetQuantity: MIN_BET_QUANTITY,
activeChipId: chipId, activeChipId: chipId,
selections: syncSelectionsBetAmount(
state.selections,
chipId,
getSelectionBetAmount(nextChip, MIN_BET_QUANTITY),
),
} }
}) })
}, },

View File

@@ -197,6 +197,17 @@
} }
@layer utilities { @layer utilities {
.mobile-animal-touch-target,
.mobile-animal-touch-target * {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.mobile-animal-touch-target img {
-webkit-user-drag: none;
}
.auth-password-input::-ms-clear, .auth-password-input::-ms-clear,
.auth-password-input::-ms-reveal { .auth-password-input::-ms-reveal {
display: none; display: none;