feat(game): 添加长按取消下注功能和优化控制组件
- 在桌面和移动版动物游戏组件中添加长按取消下注功能 - 新增 LongPressProgress 组件显示长按进度动画 - 重构游戏控制组件,移除增加/减少投注数量按钮 - 更新投注逻辑,优化单笔投注金额计算方式 - 修复移动端触摸目标选择器样式问题 - 添加右键菜单禁用和键盘删除操作支持 - 更新多语言文件中的游戏说明和取消投注标签 - 优化自动托管运行器中的投注分组逻辑
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
77
src/features/game/components/shared/long-press-progress.tsx
Normal file
77
src/features/game/components/shared/long-press-progress.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
if (current) {
|
|
||||||
current.numbers.push(selection.cellId)
|
|
||||||
return accumulator
|
|
||||||
}
|
|
||||||
|
|
||||||
accumulator.set(groupKey, {
|
|
||||||
amount: selection.amount,
|
|
||||||
betId,
|
betId,
|
||||||
numbers: [selection.cellId],
|
numbers: [...new Set(selections.map((selection) => selection.cellId))].sort(
|
||||||
})
|
(left, right) => left - right,
|
||||||
|
),
|
||||||
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,26 +188,12 @@ 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)
|
||||||
|
|
||||||
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({
|
const result = await placeGameBet({
|
||||||
bet_amount: formattedSingleBetAmount,
|
bet_amount: formattedSingleBetAmount,
|
||||||
bet_id: group.betId,
|
bet_id: uniformBet.betId,
|
||||||
idempotency_key: createIdempotencyKey(),
|
idempotency_key: createIdempotencyKey(),
|
||||||
numbers: uniqueNumbers.join(','),
|
numbers: uniformBet.numbers.join(','),
|
||||||
period_no: round.id,
|
period_no: round.id,
|
||||||
single_bet_amount: formattedSingleBetAmount,
|
single_bet_amount: formattedSingleBetAmount,
|
||||||
})
|
})
|
||||||
@@ -228,8 +203,6 @@ export function useAutoHostingRunner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
latestBalance = result.balance_after
|
latestBalance = result.balance_after
|
||||||
remainingBalance = parseBalance(latestBalance)
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestHostingState = useGameAutoHostingStore.getState()
|
const latestHostingState = useGameAutoHostingStore.getState()
|
||||||
const latestUser = useAuthStore.getState().currentUser
|
const latestUser = useAuthStore.getState().currentUser
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '加载中',
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
|
activeBetQuantity: MIN_BET_QUANTITY,
|
||||||
|
selections: syncedSelections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user