refactor(game): 优化游戏组件并实现国际化支持
- 在AppBootResourceGate组件中集成react-i18next实现资源加载文本的国际化 - 修改DesktopAnimal组件中的loading dots key以提高渲染性能 - 在DesktopControl组件中添加useRef和useEffect钩子管理定时器清理逻辑 - 将DesktopTitle组件重构为MessageBroadcast组件并增强其响应式设计 - 更新DesktopSupportModal组件中的客户服务文本为国际化格式 - 在AuthSession模块中实现本地存储数据清理时保留关键偏好设置 - 调整多个游戏组件的样式类以改进移动端适配效果 - 移除未使用的桌面提取功能相关代码文件 - 更新GitNexus索引统计数据反映最新的代码变更
This commit is contained in:
550
src/features/game/components/mobile/mobile-control.tsx
Normal file
550
src/features/game/components/mobile/mobile-control.tsx
Normal file
@@ -0,0 +1,550 @@
|
||||
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'
|
||||
import { ACTION_OPTIONS } from '@/constants'
|
||||
import { useGameControlVm } from '@/hooks/use-game-control-vm.ts'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
export function MobileControl() {
|
||||
const { t } = useTranslation()
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const {
|
||||
acceptingBets,
|
||||
actionsEnabled,
|
||||
canClear,
|
||||
canDecreaseBetQuantity,
|
||||
chips,
|
||||
confirmLabel,
|
||||
confirmState,
|
||||
isConfirmClickable,
|
||||
maxSelectionCountLabel,
|
||||
onChipSelect,
|
||||
onConfirm,
|
||||
onClearSelections,
|
||||
onDecreaseBetQuantity,
|
||||
onIncreaseBetQuantity,
|
||||
onOpenAutoSetting,
|
||||
onRepeatSelections,
|
||||
selectedBetQuantityLabel,
|
||||
selectedChipId,
|
||||
selectedCountLabel,
|
||||
totalBetAmountLabel,
|
||||
} = useGameControlVm()
|
||||
const [clickedId, setClickedId] = useState<string | null>(null)
|
||||
const [hidingId, setHidingId] = useState<string | null>(null)
|
||||
const [confirmClicked, setConfirmClicked] = useState(false)
|
||||
const clickResetTimerRef = useRef<number | null>(null)
|
||||
const hideResetTimerRef = useRef<number | null>(null)
|
||||
const confirmResetTimerRef = useRef<number | null>(null)
|
||||
const isConfirmWarning =
|
||||
confirmState === 'insufficient' || confirmState === 'limit'
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clickResetTimerRef.current !== null) {
|
||||
window.clearTimeout(clickResetTimerRef.current)
|
||||
}
|
||||
|
||||
if (hideResetTimerRef.current !== null) {
|
||||
window.clearTimeout(hideResetTimerRef.current)
|
||||
}
|
||||
|
||||
if (confirmResetTimerRef.current !== null) {
|
||||
window.clearTimeout(confirmResetTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChipClick = (chipId: string) => {
|
||||
if (!acceptingBets) {
|
||||
return
|
||||
}
|
||||
|
||||
onChipSelect(chipId)
|
||||
}
|
||||
|
||||
const handleActionClick = useCallback(
|
||||
(id: string) => {
|
||||
if (!actionsEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (id === 'clear' && canClear) {
|
||||
onClearSelections()
|
||||
}
|
||||
|
||||
if (id === 'repeat') {
|
||||
onRepeatSelections()
|
||||
}
|
||||
|
||||
if (id === 'auto-spin') {
|
||||
onOpenAutoSetting()
|
||||
}
|
||||
|
||||
setClickedId(id)
|
||||
|
||||
if (clickResetTimerRef.current !== null) {
|
||||
window.clearTimeout(clickResetTimerRef.current)
|
||||
}
|
||||
|
||||
clickResetTimerRef.current = window.setTimeout(() => {
|
||||
setClickedId(null)
|
||||
setHidingId(id)
|
||||
|
||||
if (hideResetTimerRef.current !== null) {
|
||||
window.clearTimeout(hideResetTimerRef.current)
|
||||
}
|
||||
|
||||
hideResetTimerRef.current = window.setTimeout(() => {
|
||||
setHidingId(null)
|
||||
}, 180)
|
||||
}, 200)
|
||||
},
|
||||
[
|
||||
actionsEnabled,
|
||||
canClear,
|
||||
onClearSelections,
|
||||
onOpenAutoSetting,
|
||||
onRepeatSelections,
|
||||
],
|
||||
)
|
||||
|
||||
const handleConfirmClick = useCallback(() => {
|
||||
if (!isConfirmClickable) {
|
||||
void onConfirm()
|
||||
return
|
||||
}
|
||||
|
||||
setConfirmClicked(true)
|
||||
|
||||
if (confirmResetTimerRef.current !== null) {
|
||||
window.clearTimeout(confirmResetTimerRef.current)
|
||||
}
|
||||
|
||||
confirmResetTimerRef.current = window.setTimeout(() => {
|
||||
setConfirmClicked(false)
|
||||
}, 200)
|
||||
void onConfirm()
|
||||
}, [isConfirmClickable, onConfirm])
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-design-3 overflow-visible text-[#D5FBFF]">
|
||||
<div className="flex h-design-42 w-full items-center gap-design-3">
|
||||
<SmartBackground
|
||||
src={chipBg}
|
||||
size="100% 100%"
|
||||
className="relative z-10 flex h-full min-w-0 flex-1 items-center bg-center bg-no-repeat px-design-8"
|
||||
>
|
||||
<SmartBackground
|
||||
src={chipLineBg}
|
||||
size="100% 100%"
|
||||
className="flex h-design-32 min-w-0 flex-1 items-center justify-between gap-design-2 overflow-visible px-design-4"
|
||||
>
|
||||
{chips.map((chip) => {
|
||||
const isSelected = chip.id === selectedChipId
|
||||
const showLockedState = !acceptingBets
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={chip.id}
|
||||
layout
|
||||
type="button"
|
||||
onClick={() => handleChipClick(chip.id)}
|
||||
disabled={showLockedState}
|
||||
whileTap={showLockedState ? undefined : { scale: 0.94 }}
|
||||
transition={{
|
||||
layout: {
|
||||
type: 'spring',
|
||||
stiffness: 360,
|
||||
damping: 26,
|
||||
},
|
||||
duration: 0.26,
|
||||
}}
|
||||
className="relative flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-full"
|
||||
style={
|
||||
showLockedState
|
||||
? {
|
||||
WebkitFilter: 'grayscale(100%)',
|
||||
filter: 'grayscale(100%)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<motion.span
|
||||
animate={
|
||||
isSelected && !showLockedState
|
||||
? {
|
||||
opacity: [0.22, 0.5, 0.22],
|
||||
scaleX: [0.82, 1.02, 0.82],
|
||||
scaleY: [0.42, 0.56, 0.42],
|
||||
}
|
||||
: {
|
||||
opacity: 0.32,
|
||||
scaleX: 0.88,
|
||||
scaleY: 0.48,
|
||||
}
|
||||
}
|
||||
transition={
|
||||
isSelected
|
||||
? {
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'easeInOut',
|
||||
}
|
||||
: { duration: 0.2, ease: 'easeOut' }
|
||||
}
|
||||
className="pointer-events-none absolute bottom-[calc(var(--design-unit)*-2)] left-1/2 h-design-8 w-design-24 -translate-x-1/2 rounded-full bg-[rgba(0,0,0,0.48)] blur-[3px]"
|
||||
/>
|
||||
<motion.span
|
||||
animate={
|
||||
isSelected && !showLockedState
|
||||
? {
|
||||
opacity: [0.72, 1, 0.72],
|
||||
scale: [0.96, 1.04, 0.96],
|
||||
boxShadow: [
|
||||
'0 0 0 1px rgba(245, 200, 107, 0.68), 0 0 6px rgba(245, 200, 107, 0.44), 0 0 12px rgba(245, 200, 107, 0.22), inset 0 0 6px rgba(255, 214, 110, 0.18)',
|
||||
'0 0 0 1px rgba(245, 200, 107, 0.96), 0 0 10px rgba(245, 200, 107, 0.78), 0 0 18px rgba(245, 200, 107, 0.42), inset 0 0 8px rgba(255, 214, 110, 0.32)',
|
||||
'0 0 0 1px rgba(245, 200, 107, 0.68), 0 0 6px rgba(245, 200, 107, 0.44), 0 0 12px rgba(245, 200, 107, 0.22), inset 0 0 6px rgba(255, 214, 110, 0.18)',
|
||||
],
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.92,
|
||||
boxShadow: '0 0 0 0 rgba(0,0,0,0)',
|
||||
}
|
||||
}
|
||||
transition={
|
||||
isSelected
|
||||
? {
|
||||
duration: 1.6,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'easeInOut',
|
||||
}
|
||||
: { duration: 0.22, ease: 'easeOut' }
|
||||
}
|
||||
className="pointer-events-none absolute inset-0 rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
layout
|
||||
animate={
|
||||
isSelected && !showLockedState
|
||||
? {
|
||||
y: [-1, -3, -1],
|
||||
scale: [1.04, 1.1, 1.04],
|
||||
filter: [
|
||||
'drop-shadow(0 4px 6px rgba(0,0,0,0.22))',
|
||||
'drop-shadow(0 7px 10px rgba(245, 200, 107, 0.28))',
|
||||
'drop-shadow(0 4px 6px rgba(0,0,0,0.22))',
|
||||
],
|
||||
}
|
||||
: {
|
||||
y: 0,
|
||||
scale: 1,
|
||||
filter: showLockedState
|
||||
? 'none'
|
||||
: 'drop-shadow(0 3px 5px rgba(0,0,0,0.34))',
|
||||
}
|
||||
}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 24 }}
|
||||
className="relative z-10"
|
||||
>
|
||||
<motion.img
|
||||
src={chip.src}
|
||||
alt={`chip-${chip.amount}`}
|
||||
draggable={false}
|
||||
className="h-design-32 w-design-32 object-contain"
|
||||
/>
|
||||
{showLockedState && (
|
||||
<img
|
||||
src={chipLock}
|
||||
alt="chip-locked"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 z-40 h-design-25 w-design-25 -translate-x-1/2 -translate-y-1/2 object-contain"
|
||||
/>
|
||||
)}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-1/2 z-30 -translate-y-1/2 text-center text-design-7 font-black leading-none tracking-[0.03em] text-white [text-shadow:0_1px_0_rgba(255,255,255,0.6),0_2px_3px_rgba(0,0,0,0.72),0_0_6px_rgba(255,255,255,0.22)]">
|
||||
{chip.valueLabel}
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</SmartBackground>
|
||||
</SmartBackground>
|
||||
|
||||
<SmartBackground
|
||||
src={mobileAddReduceBg}
|
||||
size="100% 100%"
|
||||
className="relative z-20 -ml-design-6 flex h-full w-design-103 shrink-0 items-center justify-center gap-design-6 bg-center bg-no-repeat px-design-9 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-22 w-design-22"
|
||||
/>
|
||||
</button>
|
||||
<div className="flex h-design-24 w-design-34 items-center justify-center rounded-sm bg-[#091118]">
|
||||
{selectedBetQuantityLabel}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!acceptingBets}
|
||||
onClick={onIncreaseBetQuantity}
|
||||
className={cn(
|
||||
'flex h-design-24 w-design-24 shrink-0 items-center justify-center',
|
||||
acceptingBets
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<SmartImage
|
||||
src={reduce}
|
||||
alt="increase"
|
||||
className="h-design-22 w-design-22"
|
||||
/>
|
||||
</button>
|
||||
</SmartBackground>
|
||||
</div>
|
||||
|
||||
<div className="flex h-design-40 w-full items-center gap-design-3">
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={leftBottomBg}
|
||||
size="100% 100%"
|
||||
onClick={() => setModalOpen('desktopPeriodHistory', true)}
|
||||
aria-label={t('gameDesktop.history.title')}
|
||||
className="relative z-20 flex h-full w-design-60 shrink-0 items-center justify-center bg-center bg-no-repeat px-design-6 text-center !text-design-8 font-bold leading-[1.15] text-[#D5FBFF] transition-[filter] duration-200 hover:brightness-125 focus-visible:ring-2 focus-visible:ring-[#4FEAFF]"
|
||||
>
|
||||
{t('gameDesktop.history.title')}
|
||||
</SmartBackground>
|
||||
|
||||
<SmartBackground
|
||||
src={mobileTotalBg}
|
||||
size="100% 100%"
|
||||
className="relative z-10 flex h-full min-w-0 flex-[0.9] items-center justify-center gap-design-14 bg-center bg-no-repeat px-design-8 text-design-9 font-bold"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-design-3 leading-none">
|
||||
<div>
|
||||
{t('gameDesktop.control.selected')}:
|
||||
<span className="text-[#FF5A5A]">{selectedCountLabel}</span>/
|
||||
{maxSelectionCountLabel}
|
||||
</div>
|
||||
<div className="flex items-center gap-design-4">
|
||||
<span>{t('gameDesktop.control.totalBet')}:</span>
|
||||
<SmartImage
|
||||
className="h-design-13 w-design-13"
|
||||
src={diamond}
|
||||
alt="diamond"
|
||||
/>
|
||||
<span>{totalBetAmountLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
|
||||
<SmartBackground
|
||||
src={controlBg}
|
||||
size="100% 100%"
|
||||
className={cn(
|
||||
'desktop-control-actions !ml-[calc(var(--design-unit)*-12)] relative z-20 flex h-full min-w-0 flex-1 items-center overflow-hidden bg-center bg-no-repeat pl-design-4',
|
||||
)}
|
||||
style={
|
||||
!actionsEnabled
|
||||
? {
|
||||
WebkitFilter: 'grayscale(100%)',
|
||||
filter: 'grayscale(100%)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{ACTION_OPTIONS.map(({ id, labelKey, Icon, bg }) => {
|
||||
const isClicked = clickedId === id
|
||||
const isHiding = hidingId === id
|
||||
const showBg = actionsEnabled && (isClicked || isHiding)
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={id}
|
||||
type="button"
|
||||
disabled={!actionsEnabled}
|
||||
onClick={() => handleActionClick(id)}
|
||||
whileHover={actionsEnabled ? { y: -1, scale: 1.01 } : undefined}
|
||||
whileTap={actionsEnabled ? { scale: 0.96 } : undefined}
|
||||
className={cn(
|
||||
'relative flex h-full min-w-0 flex-1 items-center justify-center overflow-hidden',
|
||||
actionsEnabled ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
id === 'auto-spin' &&
|
||||
'-translate-x-[calc(var(--design-unit)*1.5)]',
|
||||
)}
|
||||
>
|
||||
{showBg && (
|
||||
<SmartBackground
|
||||
as={motion.span}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isClicked ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{
|
||||
duration: isClicked ? 0.15 : 0.18,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
src={bg}
|
||||
className="pointer-events-none absolute inset-0 h-full bg-center bg-no-repeat"
|
||||
size={
|
||||
id === 'clear'
|
||||
? '110% 90%'
|
||||
: id === 'repeat'
|
||||
? '98% 80%'
|
||||
: '96% 80%'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<motion.div
|
||||
animate={
|
||||
showBg
|
||||
? {
|
||||
y: -1,
|
||||
scale: 1.01,
|
||||
}
|
||||
: {
|
||||
y: 0,
|
||||
scale: 1,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
duration: showBg ? 0.22 : 0.18,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
className="relative z-10 flex flex-col items-center justify-center"
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
strokeWidth={2.2}
|
||||
className={showBg ? 'text-[#D9FEFF]' : 'text-[#37D5CB]'}
|
||||
/>
|
||||
<div className="mt-design-4 text-design-8 leading-none">
|
||||
{t(labelKey)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
|
||||
<SmartBackground
|
||||
as={motion.button}
|
||||
src={isConfirmWarning ? confirmRedBg : mobileConfirmBg}
|
||||
size="100% 100%"
|
||||
type="button"
|
||||
onClick={handleConfirmClick}
|
||||
whileHover={isConfirmClickable ? { scale: 1.005 } : undefined}
|
||||
whileTap={isConfirmClickable ? { scale: 0.97 } : undefined}
|
||||
animate={
|
||||
confirmState === 'ready'
|
||||
? {
|
||||
scale: [1, 1.018, 1],
|
||||
filter: [
|
||||
'drop-shadow(0 0 0 rgba(0,0,0,0))',
|
||||
'drop-shadow(0 0 12px rgba(245,200,107,0.72))',
|
||||
'drop-shadow(0 0 0 rgba(0,0,0,0))',
|
||||
],
|
||||
}
|
||||
: confirmState === 'idle'
|
||||
? { scale: 1, filter: 'grayscale(.95)' }
|
||||
: { scale: 1, filter: 'none' }
|
||||
}
|
||||
transition={
|
||||
confirmState === 'ready'
|
||||
? {
|
||||
duration: 1.2,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'easeInOut',
|
||||
}
|
||||
: { duration: 0.2, ease: 'easeOut' }
|
||||
}
|
||||
style={
|
||||
confirmState === 'idle'
|
||||
? {
|
||||
WebkitFilter: 'grayscale(.95)',
|
||||
filter: 'grayscale(.95)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'relative z-10 flex h-design-39 w-full shrink-0 items-center justify-center overflow-hidden bg-center bg-no-repeat text-design-16 font-black tracking-[0.04em]',
|
||||
isConfirmClickable ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{confirmClicked && (
|
||||
<SmartBackground
|
||||
as={motion.span}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="pointer-events-none absolute inset-0 bg-center bg-no-repeat"
|
||||
src={isConfirmWarning ? confirmRedBg : mobileConfirmBg}
|
||||
size="100% 100%"
|
||||
/>
|
||||
)}
|
||||
<motion.span
|
||||
animate={
|
||||
confirmState === 'ready'
|
||||
? {
|
||||
opacity: confirmClicked ? 0 : 1,
|
||||
y: confirmClicked ? 2 : [0, -1, 0],
|
||||
textShadow: [
|
||||
'0 0 8px rgba(255,238,173,0.72), 0 0 16px rgba(255,214,96,0.4)',
|
||||
'0 0 12px rgba(255,252,220,0.95), 0 0 24px rgba(255,214,96,0.8)',
|
||||
'0 0 8px rgba(255,238,173,0.72), 0 0 16px rgba(255,214,96,0.4)',
|
||||
],
|
||||
}
|
||||
: {
|
||||
opacity: confirmClicked ? 0 : 1,
|
||||
y: confirmClicked ? 2 : 0,
|
||||
textShadow: isConfirmWarning
|
||||
? '0 0 8px rgba(255,206,206,0.55)'
|
||||
: '0 0 8px rgba(210,255,255,0.38)',
|
||||
}
|
||||
}
|
||||
transition={
|
||||
confirmState === 'ready'
|
||||
? {
|
||||
duration: 1.2,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: 'easeInOut',
|
||||
}
|
||||
: { duration: 0.15 }
|
||||
}
|
||||
className={cn('relative z-10', isConfirmWarning && 'text-[#FFF1F1]')}
|
||||
>
|
||||
{confirmLabel}
|
||||
</motion.span>
|
||||
</SmartBackground>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user