From 1b7557e2cce98775ef438295a188c9ee97217438 Mon Sep 17 00:00:00 2001
From: JiaJun <2394389886@qq.com>
Date: Fri, 17 Jul 2026 18:22:14 +0800
Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E9=95=BF?=
=?UTF-8?q?=E6=8C=89=E5=8F=96=E6=B6=88=E4=B8=8B=E6=B3=A8=E5=8A=9F=E8=83=BD?=
=?UTF-8?q?=E5=92=8C=E4=BC=98=E5=8C=96=E6=8E=A7=E5=88=B6=E7=BB=84=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在桌面和移动版动物游戏组件中添加长按取消下注功能
- 新增 LongPressProgress 组件显示长按进度动画
- 重构游戏控制组件,移除增加/减少投注数量按钮
- 更新投注逻辑,优化单笔投注金额计算方式
- 修复移动端触摸目标选择器样式问题
- 添加右键菜单禁用和键盘删除操作支持
- 更新多语言文件中的游戏说明和取消投注标签
- 优化自动托管运行器中的投注分组逻辑
---
.../components/desktop/desktop-animal.tsx | 25 +++
.../components/desktop/desktop-control.tsx | 55 +-----
.../game/components/mobile/mobile-animal.tsx | 29 +++-
.../game/components/mobile/mobile-control.tsx | 54 +-----
.../game/components/mobile/mobile-status.tsx | 2 +-
.../components/shared/long-press-progress.tsx | 77 +++++++++
src/hooks/use-animal-vm.ts | 162 ++++++++++++++++--
src/hooks/use-auto-hosting-runner.ts | 85 ++++-----
src/hooks/use-game-control-vm.ts | 56 ++----
src/locales/en-US.ts | 2 +
src/locales/id-ID.ts | 2 +
src/locales/ms-MY.ts | 2 +
src/locales/zh-CN.ts | 2 +
.../desktop/desktop-auto-setting-modal.tsx | 5 +-
.../mobile/mobile-auto-setting-modal.tsx | 5 +-
src/store/game/game-round-store.ts | 73 ++++----
src/style/index.css | 11 ++
17 files changed, 373 insertions(+), 274 deletions(-)
create mode 100644 src/features/game/components/shared/long-press-progress.tsx
diff --git a/src/features/game/components/desktop/desktop-animal.tsx b/src/features/game/components/desktop/desktop-animal.tsx
index 4958585..545d650 100644
--- a/src/features/game/components/desktop/desktop-animal.tsx
+++ b/src/features/game/components/desktop/desktop-animal.tsx
@@ -7,6 +7,7 @@ import diamondIcon from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image'
import { DesktopAnimalOverlay } from '@/features/game/components/desktop/desktop-animal-overlay.tsx'
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 { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { useAnimalVm } from '@/hooks/use-animal-vm'
@@ -83,10 +84,17 @@ export function DesktopAnimal({
)
const {
cellWarning,
+ handleClearCell,
+ handleLongPressCancel,
+ handleLongPressEnd,
+ handleLongPressMove,
+ handleLongPressStart,
handleSelect,
handleStart,
isRealtimeConnecting,
lockInteraction,
+ longPressCellId,
+ longPressDurationMs,
marqueeId,
selectionByCell,
showStandbyState,
@@ -212,6 +220,17 @@ export function DesktopAnimal({
type="button"
disabled={lockInteraction || showStopOverlay}
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={
showCellWarning
? {
@@ -372,6 +391,12 @@ export function DesktopAnimal({
) : null}
+ {longPressCellId === item.id ? (
+
+ ) : null}
)
})}
diff --git a/src/features/game/components/desktop/desktop-control.tsx b/src/features/game/components/desktop/desktop-control.tsx
index 57b3820..8b9bbc9 100644
--- a/src/features/game/components/desktop/desktop-control.tsx
+++ b/src/features/game/components/desktop/desktop-control.tsx
@@ -1,7 +1,6 @@
import { motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import reduce from '@/assets/game/add.webp'
import arrow from '@/assets/game/arrow.webp'
import chipBg from '@/assets/game/chip-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 controlBg from '@/assets/game/control-bg.png'
import leftBottomBg from '@/assets/game/left-bg.webp'
-import add from '@/assets/game/reduce.webp'
import totalBg from '@/assets/game/total-bg.webp'
import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
@@ -27,8 +25,6 @@ export function DesktopControl() {
acceptingBets,
actionsEnabled,
canClear,
- canDecreaseBetQuantity,
- canIncreaseBetQuantity,
chips,
confirmLabel,
confirmState,
@@ -37,11 +33,8 @@ export function DesktopControl() {
onChipSelect,
onConfirm,
onClearSelections,
- onDecreaseBetQuantity,
- onIncreaseBetQuantity,
onOpenAutoSetting,
onRepeatSelections,
- selectedBetQuantityLabel,
selectedChipId,
selectedCountLabel,
totalBetAmountLabel,
@@ -181,7 +174,7 @@ export function DesktopControl() {
src={chipLineBg}
size="100% 100%"
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) => {
@@ -338,52 +331,6 @@ export function DesktopControl() {
)
})}
-
-
-
-
- {selectedBetQuantityLabel}
-
-
-
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={
showCellWarning
? {
@@ -238,7 +258,7 @@ export function MobileAnimal({
: { duration: 0.16, ease: 'easeOut' }
}
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',
lockInteraction
? 'cursor-not-allowed opacity-90'
@@ -376,6 +396,13 @@ export function MobileAnimal({
) : null}
+ {longPressCellId === item.id ? (
+
+ ) : null}
)
})}
diff --git a/src/features/game/components/mobile/mobile-control.tsx b/src/features/game/components/mobile/mobile-control.tsx
index f23ef2c..95c361f 100644
--- a/src/features/game/components/mobile/mobile-control.tsx
+++ b/src/features/game/components/mobile/mobile-control.tsx
@@ -1,17 +1,14 @@
import { motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import reduce from '@/assets/game/add.webp'
import chipBg from '@/assets/game/chip-bg.webp'
import chipLineBg from '@/assets/game/chip-line-bg.webp'
import chipLock from '@/assets/game/chip-lock.webp'
import confirmRedBg from '@/assets/game/confirm-red-bg.png'
import controlBg from '@/assets/game/control-bg.png'
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 mobileTotalBg from '@/assets/game/mobile-control-number.webp'
-import add from '@/assets/game/reduce.webp'
import diamond from '@/assets/system/diamond.webp'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
@@ -27,8 +24,6 @@ export function MobileControl() {
acceptingBets,
actionsEnabled,
canClear,
- canDecreaseBetQuantity,
- canIncreaseBetQuantity,
chips,
confirmLabel,
confirmState,
@@ -37,11 +32,8 @@ export function MobileControl() {
onChipSelect,
onConfirm,
onClearSelections,
- onDecreaseBetQuantity,
- onIncreaseBetQuantity,
onOpenAutoSetting,
onRepeatSelections,
- selectedBetQuantityLabel,
selectedChipId,
selectedCountLabel,
totalBetAmountLabel,
@@ -154,7 +146,7 @@ export function MobileControl() {
{chips.map((chip) => {
const isSelected = chip.id === selectedChipId
@@ -287,50 +279,6 @@ export function MobileControl() {
})}
-
-
-
-
- {selectedBetQuantityLabel}
-
-
-
diff --git a/src/features/game/components/mobile/mobile-status.tsx b/src/features/game/components/mobile/mobile-status.tsx
index 8490d70..46e3f99 100644
--- a/src/features/game/components/mobile/mobile-status.tsx
+++ b/src/features/game/components/mobile/mobile-status.tsx
@@ -15,7 +15,7 @@ import { DesktopCountdown } from '@/features/game/components/desktop/desktop-cou
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
import { cn } from '@/lib/utils.ts'
-const FORCE_MOBILE_STREAK_EFFECT_PREVIEW = true
+const FORCE_MOBILE_STREAK_EFFECT_PREVIEW = false
function isIosDevice() {
if (typeof navigator === 'undefined') {
diff --git a/src/features/game/components/shared/long-press-progress.tsx b/src/features/game/components/shared/long-press-progress.tsx
new file mode 100644
index 0000000..44ed592
--- /dev/null
+++ b/src/features/game/components/shared/long-press-progress.tsx
@@ -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 (
+
+
+
+
+ {label}
+
+
+ )
+}
diff --git a/src/hooks/use-animal-vm.ts b/src/hooks/use-animal-vm.ts
index dddb845..df3dc3e 100644
--- a/src/hooks/use-animal-vm.ts
+++ b/src/hooks/use-animal-vm.ts
@@ -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(null)
+ const longPressTimerRef = useRef(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,
+ ) => {
+ 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) => {
+ 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,
diff --git a/src/hooks/use-auto-hosting-runner.ts b/src/hooks/use-auto-hosting-runner.ts
index 6dd6341..649b6af 100644
--- a/src/hooks/use-auto-hosting-runner.ts
+++ b/src/hooks/use-auto-hosting-runner.ts
@@ -56,32 +56,21 @@ function formatBetAmount(amount: number) {
return amount.toFixed(2).replace(/\.?0+$/, '')
}
-function groupSelections(selections: BetSelection[]) {
- return selections.reduce<
- Map
- >((accumulator, selection) => {
- const betId = toBetId(selection.chipId)
+function resolveUniformBet(selections: BetSelection[]) {
+ const latestSelection = selections.at(-1)
+ const betId = toBetId(latestSelection?.chipId ?? '')
- if (betId === null) {
- return accumulator
- }
+ if (!latestSelection || betId === null || latestSelection.amount <= 0) {
+ return null
+ }
- const groupKey = `${betId}:${selection.amount}`
- const current = accumulator.get(groupKey)
-
- if (current) {
- current.numbers.push(selection.cellId)
- return accumulator
- }
-
- accumulator.set(groupKey, {
- amount: selection.amount,
- betId,
- numbers: [selection.cellId],
- })
-
- return accumulator
- }, new Map())
+ return {
+ amount: latestSelection.amount,
+ betId,
+ numbers: [...new Set(selections.map((selection) => selection.cellId))].sort(
+ (left, right) => left - right,
+ ),
+ }
}
export function useAutoHostingRunner() {
@@ -167,9 +156,9 @@ export function useAutoHostingRunner() {
return
}
- const groupedSelections = groupSelections(selections)
+ const uniformBet = resolveUniformBet(selections)
- if (groupedSelections.size === 0) {
+ if (!uniformBet || uniformBet.numbers.length === 0) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStopped'))
return
@@ -181,7 +170,7 @@ export function useAutoHostingRunner() {
)
const balance = parseBalance(currentUser.coin)
- if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
+ if (tableLimitMax > 0 && uniformBet.amount > tableLimitMax) {
stopHosting()
notify.warning(t('commonUi.toast.autoHostingStoppedBetLimit'))
return
@@ -199,38 +188,22 @@ export function useAutoHostingRunner() {
const submitAutoBet = async () => {
try {
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()) {
- const uniqueNumbers = [...new Set(group.numbers)].sort(
- (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)
+ if (result.status !== 'accepted') {
+ throw new Error(t('commonUi.toast.betRejected'))
}
+ latestBalance = result.balance_after
+
const latestHostingState = useGameAutoHostingStore.getState()
const latestUser = useAuthStore.getState().currentUser
diff --git a/src/hooks/use-game-control-vm.ts b/src/hooks/use-game-control-vm.ts
index bc1d90e..5278eec 100644
--- a/src/hooks/use-game-control-vm.ts
+++ b/src/hooks/use-game-control-vm.ts
@@ -61,16 +61,10 @@ 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(
@@ -126,31 +120,12 @@ export function useGameControlVm() {
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,
- ])
+ hasSelections &&
+ tableLimitMax > 0 &&
+ selections.some((selection) => selection.amount > tableLimitMax)
+ const selectedCellCount = new Set(
+ selections.map((selection) => selection.cellId),
+ ).size
const confirmState: ConfirmState =
isSubmitting || isAutoHosting
? 'submitting'
@@ -202,8 +177,9 @@ export function useGameControlVm() {
return
}
- const betId = toBetId(selections[0]?.chipId ?? activeChipId)
- const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
+ const latestSelection = selections.at(-1)
+ const betId = toBetId(latestSelection?.chipId ?? activeChipId)
+ const singleBetAmount = latestSelection?.amount ?? selectedChip?.amount ?? 0
if (betId === null || singleBetAmount <= 0) {
notify.warning(t('commonUi.toast.betUnavailable'))
@@ -316,8 +292,6 @@ export function useGameControlVm() {
round.phase === 'betting' &&
!hasSubmittedCurrentRound &&
!isAutoHosting,
- canDecreaseBetQuantity: activeBetQuantity > 1,
- canIncreaseBetQuantity,
confirmLabel:
confirmState === 'idle'
? t('gameDesktop.control.selectNumbers')
@@ -331,23 +305,13 @@ export function useGameControlVm() {
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,
+ selectedCountLabel: selectedCellCount,
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
chips: chipItems,
}
diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts
index 97c86f3..11a086d 100644
--- a/src/locales/en-US.ts
+++ b/src/locales/en-US.ts
@@ -237,6 +237,7 @@ export default {
'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.',
' 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**',
' Once the round enters the closing stage, the system stops accepting new bets.',
@@ -701,6 +702,7 @@ export default {
announcement: 'Announcement',
},
animal: {
+ cancelBet: 'Cancel Bet',
insufficientBalanceRecharge: 'Insufficient balance, please top up',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Loading',
diff --git a/src/locales/id-ID.ts b/src/locales/id-ID.ts
index 77c7c44..6de6959 100644
--- a/src/locales/id-ID.ts
+++ b/src/locales/id-ID.ts
@@ -236,6 +236,7 @@ export default {
'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.',
' Jumlah maksimum nomor yang dapat dipilih per putaran bergantung pada konfigurasi sistem saat ini.',
+ ' (Tekan lama nomor untuk batalkan taruhan)',
'',
'2. **Tahap Penutupan Taruhan**',
' Setelah putaran memasuki tahap penutupan, sistem berhenti menerima taruhan baru.',
@@ -701,6 +702,7 @@ export default {
announcement: 'Pengumuman',
},
animal: {
+ cancelBet: 'Batalkan Taruhan',
insufficientBalanceRecharge: 'Saldo tidak cukup, silakan isi ulang',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Memuat',
diff --git a/src/locales/ms-MY.ts b/src/locales/ms-MY.ts
index 7d4f64a..9b35440 100644
--- a/src/locales/ms-MY.ts
+++ b/src/locales/ms-MY.ts
@@ -239,6 +239,7 @@ export default {
'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.',
' Bilangan maksimum nombor yang boleh dipilih bagi setiap pusingan bergantung pada konfigurasi semasa sistem.',
+ ' (Tekan lama nombor untuk batalkan pertaruhan)',
'',
'2. **Peringkat Penutupan Pertaruhan**',
' Apabila pusingan memasuki peringkat penutupan, sistem akan berhenti menerima pertaruhan baharu.',
@@ -706,6 +707,7 @@ export default {
announcement: 'Pengumuman',
},
animal: {
+ cancelBet: 'Batalkan Pertaruhan',
insufficientBalanceRecharge: 'Baki tidak mencukupi, sila tambah nilai',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: 'Memuatkan',
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
index ed04774..af46589 100644
--- a/src/locales/zh-CN.ts
+++ b/src/locales/zh-CN.ts
@@ -231,6 +231,7 @@ export default {
'1. **下注阶段**',
' 玩家在 36 个号码中选择想要押注的号码,选择筹码后确认下注,系统扣款并生成注单。',
' 单期最多可选号码数,以系统当前配置为准。',
+ ' (长按号码取消下注)',
'',
'2. **封盘阶段**',
' 进入封盘后,系统停止接受新注单。',
@@ -676,6 +677,7 @@ export default {
announcement: '公告栏',
},
animal: {
+ cancelBet: '取消下注',
insufficientBalanceRecharge: '余额不足,请充值',
betLimitExceeded: TEXT_BET_LIMIT_EXCEEDED,
loading: '加载中',
diff --git a/src/modal/desktop/desktop-auto-setting-modal.tsx b/src/modal/desktop/desktop-auto-setting-modal.tsx
index 9aa5d95..0190373 100644
--- a/src/modal/desktop/desktop-auto-setting-modal.tsx
+++ b/src/modal/desktop/desktop-auto-setting-modal.tsx
@@ -76,7 +76,10 @@ function DesktopAutoSettingModal() {
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'))
return
}
diff --git a/src/modal/mobile/mobile-auto-setting-modal.tsx b/src/modal/mobile/mobile-auto-setting-modal.tsx
index f910bf2..ed48a7e 100644
--- a/src/modal/mobile/mobile-auto-setting-modal.tsx
+++ b/src/modal/mobile/mobile-auto-setting-modal.tsx
@@ -76,7 +76,10 @@ function MobileAutoSettingModal() {
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'))
return
}
diff --git a/src/store/game/game-round-store.ts b/src/store/game/game-round-store.ts
index 0313ef5..4c50cee 100644
--- a/src/store/game/game-round-store.ts
+++ b/src/store/game/game-round-store.ts
@@ -86,10 +86,6 @@ function normalizeBetQuantity(quantity: number) {
return Math.max(MIN_BET_QUANTITY, Math.floor(quantity))
}
-function getSelectionBetAmount(chip: Chip, quantity: number) {
- return chip.amount * normalizeBetQuantity(quantity)
-}
-
function syncSelectionsBetAmount(
selections: BetSelection[],
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 & {
activeChipId: string
activeBetQuantity: number
@@ -158,12 +139,18 @@ export const useGameRoundStore = create()((set, get) => ({
}
}
+ const currentBetAmount = state.selections[0]?.amount ?? activeChip.amount
+ const nextBetAmount = Math.max(
+ activeChip.amount,
+ currentBetAmount + activeChip.amount * delta,
+ )
+
return {
activeBetQuantity: nextQuantity,
selections: syncSelectionsBetAmount(
state.selections,
activeChip.id,
- getSelectionBetAmount(activeChip, nextQuantity),
+ nextBetAmount,
),
}
})
@@ -276,17 +263,34 @@ export const useGameRoundStore = create()((set, get) => ({
if (
!activeChip ||
state.round.phase !== 'betting' ||
- hasExistingSelection ||
- selectedCellCount >= state.maxSelectionCount
+ (!hasExistingSelection && selectedCellCount >= state.maxSelectionCount)
) {
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 {
+ activeBetQuantity: MIN_BET_QUANTITY,
selections: [
- ...state.selections,
+ ...syncedSelections,
{
- amount: getSelectionBetAmount(activeChip, state.activeBetQuantity),
+ amount: nextBetAmount,
cellId,
chipId: activeChip.id,
id: `bet-${cellId}-${state.selections.length + 1}-${Date.now()}`,
@@ -372,23 +376,11 @@ export const useGameRoundStore = create()((set, get) => ({
nextSelections,
state.activeChipId,
)
- const nextActiveChip = getChipById(state.chips, nextActiveChipId)
- const nextBetQuantity = resolveSelectionQuantity(
- nextSelections,
- state.chips,
- nextActiveChipId,
- )
set({
- activeBetQuantity: nextBetQuantity,
+ activeBetQuantity: MIN_BET_QUANTITY,
activeChipId: nextActiveChipId,
- selections: nextActiveChip
- ? syncSelectionsBetAmount(
- nextSelections,
- nextActiveChipId,
- getSelectionBetAmount(nextActiveChip, nextBetQuantity),
- )
- : nextSelections,
+ selections: nextSelections,
})
return true
@@ -411,11 +403,6 @@ export const useGameRoundStore = create()((set, get) => ({
return {
activeBetQuantity: MIN_BET_QUANTITY,
activeChipId: chipId,
- selections: syncSelectionsBetAmount(
- state.selections,
- chipId,
- getSelectionBetAmount(nextChip, MIN_BET_QUANTITY),
- ),
}
})
},
diff --git a/src/style/index.css b/src/style/index.css
index c218802..43369bd 100644
--- a/src/style/index.css
+++ b/src/style/index.css
@@ -197,6 +197,17 @@
}
@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-reveal {
display: none;