Files
36-character-flower/src/features/game/components/mobile/mobile-control.tsx
JiaJun d41e8e7f1c fix(game): 修复移动端动物组件渲染和奖励数据显示问题
- 修复了GitNexus索引统计信息中的符号、关系和执行流数量
- 为桌面端动物组件的投注开始警告添加了z-index类以确保正确显示层级
- 修复了游戏回合存储中奖励金额和类型的返回逻辑,避免显示旧奖励数据
- 调整了移动端金币揭示效果的内边距样式以改善视觉效果
- 重构了移动端动物网格组件,添加了容器引用并修复了揭示动画定位问题
- 清理了未使用的移动揭示帧偏移常量并优化了网格布局结构
- 更新了移动端控制组件中的内边距和间距以改善UI布局
- 在移动端头部组件中添加了新的图像资源和CSS工具函数
- 调整了移动端头部组件中的按钮大小、间距和对齐方式
- 优化了移动端工具栏按钮的样式类以实现更好的响应式设计
2026-06-05 16:10:44 +08:00

551 lines
20 KiB
TypeScript

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 pl-design-8 pr-design-1"
>
<SmartBackground
src={chipLineBg}
size="100% 100%"
className="flex h-design-32 min-w-0 items-center justify-between gap-design-7 overflow-visible"
>
{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-[calc(var(--design-unit)*-8)] flex h-full w-design-103 shrink-0 items-center justify-center gap-design-5 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-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>
)
}